Files
pirate-dice/scripts/game.gd
2026-09-16 18:35:59 +02:00

893 lines
27 KiB
GDScript

extends Node3D
## Host-autoritative Spiellogik + Client-Ansicht.
const START_DICE := 5
const BID_TIMEOUT := 30.0
## Aufdecken in drei Etappen: Becher hoch, zählen, Urteil. So baut sich die
## Spannung auf, statt dass alles auf einmal dasteht.
const ZEIT_BECHER := 1.3
const ZEIT_ZAEHLEN := 2.2 ## reicht, um bis zu 15 passende Würfel aufleuchten zu lassen
const ZEIT_URTEIL := 2.8
const WURF_ZEIT := 1.1 ## wie lange die geworfene Münze zu sehen ist,
## bevor ihre Wirkung einsetzt
var table: GameTable
var hud: CanvasLayer
## Alle Wartezeiten des Ablaufs (Bot-Pausen, Aufdecken, Münzwurf) laufen
## durch `_sleep` und lassen sich hiermit stauchen. Im Spiel 1.0; die Tests
## setzen 0, sonst dauert eine Bot-Partie Minuten statt Sekunden. Kein
## class_name dafür: den Klassencache baut nur der Editor, headless fehlt er.
var tempo := 1.0
## Würfel je Platz zu Beginn. Bonuswürfel (Nachschub, Genau) füllen nur bis
## hierhin auf - so wird der Tisch über die Partie sicher leerer.
var dice_start := START_DICE
## Simulation: kein Würfelwurf, keine Physik - Werte werden direkt gezogen.
## Nur für `make sim`; fünfzig Partien in Sekunden statt Minuten.
var simulation := false
var runde := 0 ## laufende Rundennummer, für Anzeige und Messung
var ausgeschieden: Array = [] ## Plätze in der Reihenfolge ihres Ausscheidens
var cfg_start: Dictionary = {} ## Startaufstellung, fürs Nochmal
var rng := RandomNumberGenerator.new()
var seats: Array = [] ## [{peer, name, ai, count, alive}]
var local_seat := 0 ## -1 auf dem Spielserver: der sitzt nicht mit
var host_values: Array = [] ## nur Host: seat -> Array[int]
var my_values: Array = [] ## Client-Sicht (-1 = vernebelt)
# Rundenzustand (nur Host)
var bid: Dictionary = {}
var turn := 0
var starter := 0
var peeked: Dictionary = {} ## viewer -> {target_seat: Array[[idx, value]]}
var gezaehlt: Dictionary = {} ## seat -> {face: [von, bis]}, durch Zählen
var verlauf: Array = [] ## alle Ansagen dieser Runde, {seat, count, face, erhoeht}
var must_bid: Dictionary = {} ## seat -> bool
var hand: Dictionary = {} ## seat -> Array[String], Münzen beim Spieler
var topf: Array = [] ## geworfene Münzen, {seat, id, stark, nr}; bleibt liegen, bis ihn einer nimmt
var _muenze_laeuft := false ## eine Münze wird gerade aufgelöst
var _wurf_nr := 0 ## zählt die Würfe übers Spiel, damit das HUD neue erkennt
var _klang_phase := "" ## letzte Phase, für die ein Klang lief
var _gezaehlt_gezeigt := false ## Hervorhebung beim Aufdecken lief schon
var _topf_ziel := -1 ## wer den Topf gerade genommen hat, für den Flug der Münzen
var last_bids: Dictionary = {} ## seat -> letzte Ansage dieser Runde
var log_lines: Array = []
var phase := "lobby"
var banner := ""
var _answered: Dictionary = {}
var _stream := false
var _stream_acc := 0.0
var _last_phase := ""
var deadline := 0.0 ## Sekunden-Zeitstempel, 0 = keine Frist
var _pause_left := 0.0
var _quitting := false ## beim Verlassen: laufende Spielschleife sauber abbrechen
var reveal_total := 0 ## beim Aufdecken: wie viele der angesagten Augenzahl wirklich da waren
func _ready() -> void:
rng.randomize()
func setup(cfg: Dictionary) -> void:
cfg_start = cfg.duplicate(true)
DiceAI.stufe = int(cfg.get("stufe", 1))
seats.clear()
if cfg.has("seats"):
# Rückkehrer: die laufende Aufstellung, Platz für Platz, in der
# Reihenfolge des Servers - daran hängen alle Indizes im Zustand.
dice_start = int(cfg.get("dice", START_DICE))
for s in cfg.seats:
seats.append({"peer": int(s.peer), "name": String(s.name), "ai": bool(s.ai),
"count": int(s.count), "alive": bool(s.alive), "verwaist": false})
else:
var dice := int(cfg.get("dice", START_DICE)) # Tests spielen mit weniger
dice_start = dice
var peers: Array = cfg.peers
for p in peers:
seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false,
"count": dice, "alive": true, "verwaist": false})
var bots: Array = cfg.get("bots", [])
for i in int(cfg.ai):
var b: Dictionary = bots[i] if i < bots.size() else {}
seats.append({"peer": 0, "name": String(b.get("name", "Bot %d" % (i + 1))), "ai": true,
"stufe": int(b.get("stufe", DiceAI.stufe)), "regler": b.get("regler", {}),
"count": dice, "alive": true, "verwaist": false})
# Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz.
var me := multiplayer.get_unique_id()
local_seat = -1
for i in seats.size():
if seats[i].peer == me:
local_seat = i
table = GameTable.new()
add_child(table)
# Der Server hat keinen Platz, legt den Tisch aber fest so, als säße Platz 0
# unten. Diese Ausrichtung ist der Bezug für alle Würfelkoordinaten.
table.build(seats.size(), maxi(local_seat, 0))
# Ohne Platz am Tisch gibt es nichts anzuzeigen und nichts zu bedienen.
if local_seat >= 0:
hud = preload("res://scripts/hud.gd").new()
add_child(hud)
hud.bid_pressed.connect(_on_bid_pressed)
hud.challenge_pressed.connect(_on_challenge_pressed)
hud.exact_pressed.connect(_on_exact_pressed)
hud.rematch_pressed.connect(_on_rematch)
hud.karte_pressed.connect(_on_karte)
hud.booster_chosen.connect(_on_booster_chosen)
hud.quit_pressed.connect(_on_quit)
hud.pause_changed.connect(_on_pause_changed)
for i in seats.size():
# Die Startmünze zieht nur der Host. Beim Client kommt sie mit dem
# ersten Zustand, sonst läge dort kurz eine andere.
hand[i] = [BoosterDB.zieh(rng)] if multiplayer.is_server() else []
# Beute aus der Kampagne: eine gewählte Münze dazu, nur für den Spieler.
if i == 0 and multiplayer.is_server() and String(cfg.get("beute", "")) != "":
hand[i].append(String(cfg.beute))
table.set_dice_count(i, seats[i].count)
table.set_dice_visible(i, false)
table.set_cup_down(i, i != local_seat)
host_values.append([])
_refresh_labels()
if multiplayer.is_server():
multiplayer.peer_disconnected.connect(_on_peer_lost)
_host_loop()
func _on_peer_lost(peer: int) -> void:
var seat := _seat_of(peer)
if seat < 0 or seats[seat].ai:
return
seats[seat].ai = true
seats[seat].peer = 0
seats[seat].verwaist = true
_log("%s hat das Spiel verlassen - übernimmt ein Bot." % seats[seat].name)
_push_state()
## Nur Server: ein Rückkehrer mit diesem Namen bekommt seinen Platz zurück.
## Gibt die Aufstellung für seinen Client zurück, oder {} wenn es keinen
## verwaisten Platz mit dem Namen gibt.
func wiedereinstieg(peer: int, pname: String) -> Dictionary:
for i in seats.size():
var s = seats[i]
if not s.verwaist or String(s.name).to_lower() != pname.to_lower():
continue
s.peer = peer
s.ai = false
s.verwaist = false
_log("%s ist zurück und übernimmt wieder." % s.name)
var aufstellung: Array = []
for t in seats:
aufstellung.append({"peer": t.peer, "name": t.name, "ai": t.ai,
"count": t.count, "alive": t.alive})
_nachliefern(i)
return {"seats": aufstellung, "stufe": DiceAI.stufe, "dice": dice_start}
return {}
## Dem Rückkehrer Zustand und Würfellage schicken - kurz verzögert, damit
## sein Spielknoten steht, bevor das erste Paket dort ankommt.
func _nachliefern(seat: int) -> void:
await _sleep(0.6)
if not _alive():
return
_refresh_labels()
_push_state()
if _peer_live(seats[seat].peer):
_dice_snapshot.rpc_id(seats[seat].peer, _pack(seat))
func _refresh_labels() -> void:
for i in seats.size():
var s = seats[i]
# Die Würfelzahl steht im Panel oben rechts, hier reicht der Name.
# Lange Namen werden gekürzt, damit sie auf den Becher passen.
var short: String = s.name if s.name.length() <= 9 else s.name.substr(0, 8) + "…"
var txt: String = short if s.alive else "%s · raus" % short
var col := Color(1, 1, 1)
if not s.alive:
col = Color(0.5, 0.5, 0.55)
elif phase == "bid" and i == turn:
col = Color(1.0, 0.85, 0.3)
table.set_label(i, txt, col)
table.set_active_seat(turn if phase == "bid" else -1)
# ---------------------------------------------------------------- Host-Ablauf
func _host_loop() -> void:
_log("Spiel gestartet - %d Spieler, je %d Würfel." % [seats.size(), START_DICE])
starter = 0
while _alive() and _alive_count() > 1:
await _phase_roll()
# Zwischen den Phasen prüfen: wer während des Wurfs verlässt, darf nicht
# noch in die Boosterphase laufen - dort ist das Netzwerk schon weg.
if not _alive():
return
var result: Dictionary = await _phase_bid()
if not _alive():
return
await _phase_reveal(result)
phase = "over"
var winner := ""
for s in seats:
if s.alive:
winner = s.name
banner = "%s gewinnt" % winner
_log(banner)
Kampagne.ergebnis(seats, ausgeschieden)
_push_state()
func _alive_count() -> int:
var n := 0
for s in seats:
if s.alive:
n += 1
return n
func _alive_seats() -> Array:
var out: Array = []
for i in seats.size():
if seats[i].alive:
out.append(i)
return out
func _next_alive(from: int) -> int:
var i := from
for _n in seats.size():
i = (i + 1) % seats.size()
if seats[i].alive:
return i
return from
func _total_dice() -> int:
var n := 0
for s in seats:
if s.alive:
n += s.count
return n
## Solange falsch, darf die Spielschleife nicht weiterlaufen - beim Verlassen
## ist der Knoten sonst schon aus dem Baum und jeder Zugriff schlägt fehl.
func _alive() -> bool:
return not _quitting and is_inside_tree()
## Ein gerade getrennter Peer steht noch in `seats`, bis das Signal ankommt -
## ein RPC dorthin wäre ein Fehler in der Konsole.
func _peer_live(peer: int) -> bool:
return peer > 1 and multiplayer.get_peers().has(peer)
func _now() -> float:
return Time.get_ticks_msec() / 1000.0
func _phase_roll() -> void:
phase = "roll"
runde += 1
deadline = 0.0
reveal_total = 0
bid = {}
last_bids.clear()
peeked.clear()
gezaehlt.clear()
verlauf.clear()
must_bid.clear()
_topf_ziel = -1
banner = ""
_log("--- Neue Runde ---")
for i in _alive_seats():
table.set_dice_count(i, seats[i].count)
table.set_dice_visible(i, i == local_seat)
table.set_cup_down(i, i != local_seat)
_push_state()
if simulation:
for i in _alive_seats():
host_values[i] = []
for _k in seats[i].count:
host_values[i].append(rng.randi_range(1, 6))
table.arrange_and_show(i, host_values[i], rng)
_push_state()
return
_stream = true
for i in _alive_seats():
table.throw_dice(i, rng)
await _await_settled(_alive_seats())
_stream = false
if not _alive():
return
for i in _alive_seats():
host_values[i] = []
for d in table.dice_of(i):
host_values[i].append(d.top_value())
table.freeze_dice(i)
_snapshot_all()
_push_state()
func _await_settled(list: Array, timeout := 7.0) -> void:
var step := 1.0 / float(Engine.physics_ticks_per_second)
var t := 0.0
while t < timeout:
if not _alive():
return
await get_tree().physics_frame
if not _alive():
return
if get_tree().paused:
continue
t += step
if t < 0.7:
continue
var ok := true
for s in list:
# Verirrte Würfel zurückholen, sonst wartet man hier vergebens.
if table.fange_verirrte(s):
ok = false
continue
for d in table.dice_of(s):
if d.freeze:
continue
if not d.is_settled():
ok = false
elif d.flatness() < 0.86:
d.nudge(rng)
ok = false
if ok:
return
# ---------------------------------------------------------------- Münzen und Aufdecken
# Ausgelagert nach muenzen.gd und aufdecken.gd. Die Hüllen hier bleiben, weil
# Tests und Aufrufer den Weg über das Spiel kennen.
var muenzen := preload("res://scripts/muenzen.gd").new(self)
var aufdecken := preload("res://scripts/aufdecken.gd").new(self)
func _zieh_muenze(seat: int) -> void:
muenzen._zieh_muenze(seat)
func _do_play(seat: int, id: String, target: int) -> void:
await muenzen._do_play(seat, id, target)
func _wirkung(seat: int, id: String, target: int, stark: bool) -> void:
await muenzen._wirkung(seat, id, target, stark)
func _count_face(face: int) -> int:
return muenzen._count_face(face)
func _bot_muenze(seat: int, a: Dictionary) -> bool:
return await muenzen._bot_muenze(seat, a)
func _topf_an(winner: int) -> void:
muenzen._topf_an(winner)
func _phase_reveal(result: Dictionary) -> void:
await aufdecken._phase_reveal(result)
func _all_values() -> Array:
return aufdecken._all_values()
## Würfel neu werfen und die Augenzahl danach übernehmen.
func _neu_werfen(seat: int, idxs: Array) -> void:
if simulation:
for k: int in idxs:
if k < host_values[seat].size():
host_values[seat][k] = rng.randi_range(1, 6)
return
_stream = true
_throw_subset(seat, idxs)
await _await_settled([seat])
_stream = false
if not _alive():
return
var dl: Array = table.dice_of(seat)
for k: int in idxs:
if k < dl.size():
host_values[seat][k] = dl[k].top_value()
table.freeze_dice(seat)
func _set_die(seat: int, idx: int, value: int) -> void:
if idx < 0 or idx >= host_values[seat].size():
return
host_values[seat][idx] = value
var dl: Array = table.dice_of(seat)
if idx < dl.size() and dl[idx].is_inside_tree():
dl[idx].snap_to_value(value, rng)
func _throw_subset(seat: int, idxs: Array) -> void:
var dl: Array = table.dice_of(seat)
var zone: Node3D = table.zones[seat]
for k: int in idxs:
if k >= dl.size():
continue
var ang := rng.randf() * TAU
var rad := GameTable.ZONE_RAD * rng.randf_range(0.25, 0.45)
var off := Vector3(cos(ang) * rad, 0.42, sin(ang) * rad)
dl[k].throw_from(zone.global_transform * off, rng)
func _phase_bid() -> Dictionary:
phase = "bid"
turn = starter if seats[starter].alive else _next_alive(starter)
while _alive():
_answered.clear()
deadline = 0.0 if seats[turn].ai else _now() + BID_TIMEOUT
_push_state()
if seats[turn].ai:
await _sleep(1.1)
# Erst planen, dann passend dazu eine Münze werfen. Die kann die
# Lage ändern - also danach noch einmal entscheiden.
DiceAI.stufe = int(seats[turn].get("stufe", DiceAI.stufe))
DiceAI.temperament = seats[turn].get("regler", {})
var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid,
must_bid.get(turn, false), rng, _bot_wissen(turn))
if await _bot_muenze(turn, a):
a = DiceAI.choose_action(host_values[turn], _total_dice(), bid,
must_bid.get(turn, false), rng, _bot_wissen(turn))
if not _alive():
return {}
var res := _apply_action(turn, a)
if not res.is_empty():
return res
else:
await _wait_until(func() -> bool: return _answered.has(turn) or seats[turn].ai, BID_TIMEOUT)
if _answered.has(turn):
var res: Dictionary = _answered[turn]
if not res.is_empty():
return res
else:
# Zeit abgelaufen oder getrennt: automatisch entscheiden
DiceAI.temperament = {}
var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid,
must_bid.get(turn, false), rng, _bot_wissen(turn))
_log("%s wird automatisch gespielt." % seats[turn].name)
var res := _apply_action(turn, a)
if not res.is_empty():
return res
turn = _next_alive(turn)
return {}
## Nachschub gibt es nur, wer wirklich hinten liegt: höchstens die halbe
## Startzahl. Gemessen: mit jedem Rückweg dehnt sich die Partie - einzelne
## liefen über 50 Runden.
func _hinten(seat: int) -> bool:
return seats[seat].count * 2 <= dice_start
## Was ein Platz über die anderen weiss: aufgedeckte Würfel und Zählfenster.
func _bot_wissen(seat: int) -> Dictionary:
var bekannt: Array = []
for o in peeked.get(seat, {}):
for paar in peeked[seat][o]:
bekannt.append(int(paar[1]))
return {"bekannt": bekannt, "fenster": gezaehlt.get(seat, {})}
## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis.
func _apply_action(seat: int, a: Dictionary) -> Dictionary:
if a.type == "exact":
_log("%s ruft: Genau!" % seats[seat].name)
return {"challenger": seat, "bid": bid.duplicate(), "exact": true}
if a.type == "bid" and not _valid_bid(seat, int(a.count), int(a.face)):
# Wer einen Maulkorb hat, darf sich nicht über ein ungültiges Gebot in
# die Anfechtung retten - dann wird auf das kleinste gültige korrigiert.
var low := _lowest_legal_bid(seat)
if not low.is_empty() and (bid.is_empty() or must_bid.get(seat, false)):
a = {"type": "bid", "count": low.count, "face": low.face}
elif not bid.is_empty():
a = {"type": "challenge"}
else:
a = {"type": "bid", "count": 1, "face": 1}
if a.type == "challenge":
_log("%s fechtet an!" % seats[seat].name)
return {"challenger": seat, "bid": bid.duplicate()}
bid = {"seat": seat, "count": int(a.count), "face": int(a.face)}
last_bids[seat] = {"count": bid.count, "face": bid.face}
verlauf.append({"seat": seat, "count": bid.count, "face": bid.face, "erhoeht": false})
must_bid.erase(seat)
_log("%s sagt %d x %d an." % [seats[seat].name, bid.count, bid.face])
return {}
## Kleinstes Gebot, das dieser Platz noch ansagen darf ({} = gar keins mehr).
func _lowest_legal_bid(_seat: int) -> Dictionary:
if bid.is_empty():
return {"count": 1, "face": 1}
return DiceAI.lowest_raise(bid, _total_dice())
func _valid_bid(seat: int, count: int, face: int) -> bool:
if face < 1 or face > 6 or count < 1 or count > _total_dice():
return false
if bid.is_empty():
return true
if count > bid.count:
return true
return count == bid.count and face > bid.face
## process_always = false: eine offene Pause hält den Timer wirklich an.
func _sleep(sec: float) -> void:
if not _alive():
return
await get_tree().create_timer(sec * tempo, false).timeout
func _wait_until(cond: Callable, timeout: float) -> bool:
var t := 0.0
while t < timeout:
if not _alive():
return false
if cond.call():
return true
await get_tree().process_frame
if not _alive():
return false
# Im Einzelspieler hält das Pausenmenü den Baum an - dann darf auch die
# Frist nicht weiterlaufen, sonst ist der Zug nach dem Fortsetzen weg.
if not get_tree().paused:
t += get_process_delta_time()
return _alive() and cond.call()
func _log(line: String) -> void:
log_lines.append(line)
if log_lines.size() > 8:
log_lines.pop_front()
# ---------------------------------------------------------------- Sync
func _physics_process(delta: float) -> void:
if not _alive() or not _stream or not multiplayer.is_server():
return
_stream_acc += delta
if _stream_acc < 1.0 / 30.0:
return
_stream_acc = 0.0
for i in seats.size():
var peer: int = seats[i].peer
if not _peer_live(peer):
continue
_dice_stream.rpc_id(peer, _pack(i))
func _snapshot_all() -> void:
for i in seats.size():
var peer: int = seats[i].peer
if _peer_live(peer):
_dice_snapshot.rpc_id(peer, _pack(i))
func _pack(seat: int) -> PackedFloat32Array:
var out := PackedFloat32Array()
for d in table.dice_of(seat):
var t: Transform3D = d.global_transform
var q := t.basis.get_rotation_quaternion()
out.append_array([t.origin.x, t.origin.y, t.origin.z, q.x, q.y, q.z, q.w])
return out
func _unpack(data: PackedFloat32Array) -> void:
var dl: Array = table.dice_of(local_seat)
var n := mini(dl.size(), int(data.size() / 7))
# Jeder Client dreht seinen eigenen Platz nach unten, der Server nicht.
# Ohne diese Drehung landen die eigenen Würfel in der Zone des Nachbarn.
var dreh := Transform3D(Basis(Vector3.UP, TAU * local_seat / float(seats.size())),
Vector3.ZERO)
for i in n:
var o := i * 7
var t := Transform3D(Basis(Quaternion(data[o + 3], data[o + 4], data[o + 5], data[o + 6])),
Vector3(data[o], data[o + 1], data[o + 2]))
dl[i].set_kinematic(true)
dl[i].place(dreh * t)
@rpc("authority", "unreliable_ordered")
func _dice_stream(data: PackedFloat32Array) -> void:
_unpack(data)
@rpc("authority", "reliable")
func _dice_snapshot(data: PackedFloat32Array) -> void:
_unpack(data)
@rpc("authority", "reliable")
func _reveal(all_values: Array, text: String) -> void:
# Auch die eigenen Würfel in den Bogen legen - sonst liegen sie als einzige
# noch verstreut da, wo die Physik sie fallen liess. Dafür die Werte des
# Hosts nehmen: my_values ist hier noch vernebelt, ein vernebelter Würfel
# würde sonst mit beliebiger Seite nach oben liegen bleiben.
for i in seats.size():
if i < all_values.size() and not all_values[i].is_empty():
table.arrange_and_show(i, all_values[i], rng)
table.set_cup_down(i, false)
banner = text
hud.set_banner(text)
func _push_state() -> void:
if not _alive():
return
_refresh_labels()
for i in seats.size():
var peer: int = seats[i].peer
if peer == multiplayer.get_unique_id():
_apply_state(_state_for(i))
elif _peer_live(peer):
_state.rpc_id(peer, _state_for(i))
## Was liegt im Topf? Der bleibt auch übers Würfeln hinweg liegen.
func _round_boosters() -> Array:
return topf.duplicate(true)
## Was jeder Platz in dieser Runde zuletzt angesagt hat.
func _seat_bids() -> Array:
var out: Array = []
for i in seats.size():
out.append(last_bids.get(i, {}))
return out
func _state_for(seat: int) -> Dictionary:
var vis: Array = []
if phase == "roll":
for _k in seats[seat].count: # während des Wurfs steht der Wert noch nicht fest
vis.append(-1)
else:
vis = host_values[seat].duplicate()
var pub: Array = []
for s in seats:
pub.append({"name": s.name, "count": s.count, "alive": s.alive, "ai": s.ai})
return {
"phase": phase, "turn": turn, "bid": bid, "seats": pub, "values": vis,
"banner": banner,
"peek": peeked.get(seat, {}), "zaehlung": gezaehlt.get(seat, {}),
"must_bid": must_bid.get(seat, false), "total": _total_dice(),
"time_left": 0.0 if deadline <= 0.0 else maxf(0.0, deadline - _now()),
"open": _all_values() if phase == "reveal" else [],
"boosts": _round_boosters(),
"actual": reveal_total if phase == "reveal" else 0,
"bids": _seat_bids(),
"verlauf": verlauf.duplicate(true),
"topf_an": _topf_ziel,
"voll": not _hinten(seat),
"log": log_lines.duplicate(),
"hand": (hand.get(seat, []) as Array).duplicate(),
}
@rpc("authority", "reliable")
func _state(st: Dictionary) -> void:
_apply_state(st)
func _apply_state(st: Dictionary) -> void:
phase = st.phase
turn = st.turn
bid = st.bid
my_values = st.values
banner = st.banner
if not multiplayer.is_server():
# Die eigene Hand kommt vom Server; ohne sie wüsste der Client nur,
# was das HUD gerade zeichnet.
hand[local_seat] = st.get("hand", [])
for i in mini(seats.size(), st.seats.size()):
seats[i].alive = st.seats[i].alive
if seats[i].count != st.seats[i].count:
seats[i].count = st.seats[i].count
table.set_dice_count(i, seats[i].count)
if phase != _last_phase:
_last_phase = phase
if phase == "roll":
for i in seats.size():
if i != local_seat:
table.set_dice_visible(i, false)
table.set_cup_down(i, seats[i].alive)
table.set_dice_visible(local_seat, true)
_refresh_labels()
table.set_topf(st.get("boosts", []), int(st.get("topf_an", -1)))
if phase == "reveal" and int(st.get("actual", -1)) >= 0 and not _gezaehlt_gezeigt:
_gezaehlt_gezeigt = true
if not bid.is_empty():
table.zaehlen_zeigen(int(bid.face), st.get("open", []))
elif phase != "reveal" and _gezaehlt_gezeigt:
_gezaehlt_gezeigt = false
table.hervorhebung_weg()
if phase != _klang_phase:
_klang_phase = phase
match phase:
"roll":
Klang.wuerfel()
"reveal":
Klang.becher()
"over":
Klang.chime()
var bids: Array = st.get("bids", [])
for i in mini(seats.size(), bids.size()):
var b: Dictionary = bids[i]
if b.is_empty():
table.set_bid(i, 0, 0)
else:
table.set_bid(i, int(b.count), int(b.face))
hud.update_state(st, local_seat)
# ---------------------------------------------------------------- Aktionen
func _on_bid_pressed(count: int, face: int) -> void:
if multiplayer.is_server():
_do_bid(local_seat, count, face)
else:
_req_bid.rpc_id(1, count, face)
func _on_challenge_pressed() -> void:
if multiplayer.is_server():
_do_challenge(local_seat)
else:
_req_challenge.rpc_id(1)
func _on_exact_pressed() -> void:
if multiplayer.is_server():
_do_exact(local_seat)
else:
_req_exact.rpc_id(1)
func _on_booster_chosen(id: String, target: int) -> void:
if multiplayer.is_server():
_do_play(local_seat, id, target)
else:
_req_boost.rpc_id(1, id, target)
## Im Einzelspieler hält das Pausenmenü das Spiel wirklich an. Sobald ein
## zweiter Mensch am Tisch sitzt, läuft die Runde weiter - sonst würde ein
## geöffnetes Menü alle anderen blockieren.
func _on_pause_changed(on: bool) -> void:
if not multiplayer.get_peers().is_empty():
return
if on:
_pause_left = 0.0 if deadline <= 0.0 else maxf(0.0, deadline - _now())
get_tree().paused = on
if not on and _pause_left > 0.0:
deadline = _now() + _pause_left
_pause_left = 0.0
_push_state()
## Noch eine Partie mit denselben Bots. Solo wandert der Wunsch über Net ins
## neu geladene Menü, das dann sofort startet. Im Netz bittet der Raumbesitzer
## den Server, den Raum neu aufzusetzen.
func _on_rematch() -> void:
if _quitting:
return
if Net.room_code != Net.SOLO:
Net.request_rematch()
return
var bots := 0
for s in seats:
if s.ai:
bots += 1
var kap := ""
if Kampagne.kampagnenpartie and cfg_start.get("bots", []).size() > 0:
kap = String(cfg_start.bots[0].name)
Net.rematch = {"name": Net.player_name, "ai": bots, "stufe": int(cfg_start.get("stufe", 1)),
"bots": cfg_start.get("bots", []), "kampagne": kap}
_on_quit()
## Zurück auf die Seekarte: das frisch geladene Menü zeigt sie sofort.
func _on_karte() -> void:
if _quitting:
return
Kampagne.zur_karte = true
_on_quit()
func _on_quit() -> void:
if _quitting:
return
_quitting = true
get_tree().paused = false
# Sofort ausblenden, damit das Verlassen unmittelbar wirkt.
table.visible = false
hud.visible = false
Net.leave()
get_tree().change_scene_to_file("res://scenes/main.tscn")
@rpc("any_peer", "reliable")
func _req_bid(count: int, face: int) -> void:
_do_bid(_seat_of(multiplayer.get_remote_sender_id()), count, face)
@rpc("any_peer", "reliable")
func _req_challenge() -> void:
_do_challenge(_seat_of(multiplayer.get_remote_sender_id()))
@rpc("any_peer", "reliable")
func _req_exact() -> void:
_do_exact(_seat_of(multiplayer.get_remote_sender_id()))
@rpc("any_peer", "reliable")
func _req_boost(id: String, target: int) -> void:
_do_play(_seat_of(multiplayer.get_remote_sender_id()), id, target)
func _seat_of(peer: int) -> int:
if peer <= 0: # 0 steht für einen KI-Platz, das ist nie ein Absender
return -1
for i in seats.size():
if seats[i].peer == peer:
return i
return -1
func _do_bid(seat: int, count: int, face: int) -> void:
if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat):
return
if not _valid_bid(seat, count, face):
return
_answered[seat] = _apply_action(seat, {"type": "bid", "count": count, "face": face})
func _do_challenge(seat: int) -> void:
if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat):
return
if bid.is_empty():
return
# Der Maulkorb sperrt die Anfechtung nur, solange es überhaupt noch ein
# gültiges Gebot gibt - sonst bliebe der Platz ohne jede Handlung.
if must_bid.get(seat, false) and not _lowest_legal_bid(seat).is_empty():
return
_answered[seat] = _apply_action(seat, {"type": "challenge"})
## "Genau" unterliegt denselben Regeln wie die Anfechtung: nur am Zug, nur
## mit Ansage, und der Maulkorb sperrt es genauso.
func _do_exact(seat: int) -> void:
if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat):
return
if bid.is_empty():
return
if must_bid.get(seat, false) and not _lowest_legal_bid(seat).is_empty():
return
_answered[seat] = _apply_action(seat, {"type": "exact"})