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 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 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: DiceAI.stufe = int(cfg.get("stufe", 1)) seats.clear() var peers: Array = cfg.peers for p in peers: seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false, "count": START_DICE, "alive": true}) for i in int(cfg.ai): seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true, "count": START_DICE, "alive": true}) # 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.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 [] table.set_dice_count(i, START_DICE) 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 _log("%s hat das Spiel verlassen - übernimmt ein Bot." % seats[seat].name) _push_state() 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) _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" deadline = 0.0 reveal_total = 0 bid = {} last_bids.clear() peeked.clear() gezaehlt.clear() verlauf.clear() must_bid.clear() 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() _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: 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 ## Wer einen Würfel abgibt, bekommt eine Münze. Das ist der einzige Nachschub. func _zieh_muenze(seat: int) -> void: var muenzen: Array = hand.get(seat, []) if muenzen.size() >= BoosterDB.HANDLIMIT: _log("%s hat schon drei Münzen und bekommt keine." % seats[seat].name) return var id := BoosterDB.zieh(rng) muenzen.append(id) hand[seat] = muenzen _log("%s bekommt %s." % [seats[seat].name, BoosterDB.get_booster(id).name]) ## Ausspielen heißt: Münze in den Topf werfen. Geht nur am eigenen Zug, bevor ## man ansagt - so ist der Zug eine Entscheidung und nichts kommt sich in die ## Quere. Wappen verstärkt, Zahl lässt es bei der Grundwirkung. func _do_play(seat: int, id: String, target: int) -> void: if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat): return if _muenze_laeuft: return if BoosterDB.needs_bid(id) and bid.is_empty(): return var muenzen: Array = hand.get(seat, []) if not muenzen.has(id): return muenzen.erase(id) hand[seat] = muenzen _muenze_laeuft = true var stark := rng.randf() < BoosterDB.WAPPEN_CHANCE _wurf_nr += 1 topf.append({"seat": seat, "id": id, "stark": stark, "nr": _wurf_nr}) while topf.size() > BoosterDB.TOPF_MAX: var weg: Dictionary = topf.pop_front() _log("%s fällt vom Tisch - der Topf ist voll." % BoosterDB.get_booster(String(weg.id)).name) _log("%s wirft %s - %s." % [seats[seat].name, BoosterDB.get_booster(id).name, "Wappen" if stark else "Zahl"]) # Erst die Münze zeigen, dann wirken lassen. Andersherum sähe man die # Folge vor der Ursache. _push_state() await _sleep(WURF_ZEIT) if _alive(): await _wirkung(seat, id, target, stark) _muenze_laeuft = false if not _alive(): return _snapshot_all() _push_state() func _wirkung(seat: int, id: String, target: int, stark: bool) -> void: var vals: Array = host_values[seat] var ziel: int = clampi(target, 0, maxi(0, vals.size() - 1)) match id: "peek": var info := {} for o in _alive_seats(): if o != seat: info[o] = _zufalls_paare(o, 2 if stark else 1) peeked[seat] = info "count": # Zahl: ein Fenster von drei, in dem die Wahrheit irgendwo liegt. # Genau wüsste man sonst, ob die Ansage hält - kein Bluff mehr. var n := _count_face(int(bid.face)) var von: int = n if stark else maxi(0, n - rng.randi_range(0, 2)) gezaehlt[seat] = {int(bid.face): [von, von if stark else von + 2]} "set_face": for k: int in _ziel_und_zufall(seat, ziel, stark): _set_die(seat, k, int(bid.face)) "gain_die": seats[seat].count += 1 table.set_dice_count(seat, seats[seat].count) host_values[seat].append(1) var neu: int = host_values[seat].size() - 1 await _neu_werfen(seat, [neu]) if stark: _set_die(seat, neu, 6) "bid_up": bid.count = int(bid.count) + (2 if stark else 1) last_bids[int(bid.seat)] = {"count": bid.count, "face": bid.face} verlauf.append({"seat": int(bid.seat), "count": bid.count, "face": bid.face, "erhoeht": true}) "gag": if stark: for o in _alive_seats(): if o != seat: must_bid[o] = true else: must_bid[_next_alive(seat)] = true func _count_face(face: int) -> int: var n := 0 for i in _alive_seats(): for v in host_values[i]: if v == face: n += 1 return n ## Bis zu `n` verschiedene Würfelplätze eines Sitzes, zufällig gezogen. func _zufalls_idx(seat: int, n: int) -> Array: var frei: Array = range(host_values[seat].size()) var out: Array = [] while out.size() < n and not frei.is_empty(): out.append(frei.pop_at(rng.randi_range(0, frei.size() - 1))) return out func _zufalls_paare(seat: int, n: int) -> Array: var out: Array = [] for k: int in _zufalls_idx(seat, n): out.append([k, host_values[seat][k]]) return out ## Der selbst gewählte Würfel, bei Wappen zusätzlich ein anderer zufälliger. func _ziel_und_zufall(seat: int, ziel: int, stark: bool) -> Array: var out: Array = [ziel] if not stark: return out var frei: Array = [] for k in host_values[seat].size(): if k != ziel: frei.append(k) if not frei.is_empty(): out.append(frei[rng.randi_range(0, frei.size() - 1)]) return out ## Würfel neu werfen und die Augenzahl danach übernehmen. func _neu_werfen(seat: int, idxs: Array) -> void: _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. var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, must_bid.get(turn, false), rng) if await _bot_muenze(turn, a): a = DiceAI.choose_action(host_values[turn], _total_dice(), bid, must_bid.get(turn, false), rng) 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 var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, must_bid.get(turn, false), rng) _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 {} ## Ein Bot wirft eine Münze, wenn sie zu seinem Vorhaben passt. `a` ist die ## geplante Handlung. Gibt zurück, ob geworfen wurde. func _bot_muenze(seat: int, a: Dictionary) -> bool: var muenzen: Array = hand.get(seat, []) if muenzen.is_empty(): return false var vals: Array = host_values[seat] var unknown: int = _total_dice() - vals.size() var will_bieten: bool = a.type == "bid" # Wie glaubwürdig ist das, was der Bot vorhat bzw. was auf dem Tisch liegt? var eigenes: float = DiceAI.chance(vals, unknown, int(a.count), int(a.face)) if will_bieten else 0.0 var liegt: float = DiceAI.chance(vals, unknown, int(bid.count), int(bid.face)) if not bid.is_empty() else 0.0 var wahl := "" var ziel := 0 # Hinten liegen: Nachschub ist der einzige Weg zurück. if muenzen.has("gain_die") and vals.size() <= _kleinste_hand(): wahl = "gain_die" # Vor dem Anfechten lieber noch nachsehen, wenn es knapp ist. elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("count"): wahl = "count" elif not will_bieten and liegt > 0.2 and liegt < 0.55 and muenzen.has("peek"): wahl = "peek" # Zudrücken: die Ansage des Gegners höher schrauben, dann anfechten. elif not will_bieten and muenzen.has("bid_up"): wahl = "bid_up" # Ein Bluff steht an: dem Nächsten den Mund verbieten. elif will_bieten and eigenes < 0.35 and muenzen.has("gag"): wahl = "gag" # Auf derselben Augenzahl erhöhen: einen Würfel dazu drehen. elif will_bieten and not bid.is_empty() and int(a.face) == int(bid.face) \ and muenzen.has("set_face") and vals.count(int(bid.face)) < vals.size(): wahl = "set_face" ziel = DiceAI.target_for(vals, int(bid.face)) # Voller Vorrat: irgendwas Spielbares raus, sonst wird nie geworfen. elif muenzen.size() >= BoosterDB.HANDLIMIT: for id: String in muenzen: if not BoosterDB.needs_bid(id) or not bid.is_empty(): wahl = id break if wahl == "": return false await _do_play(seat, wahl, ziel) await _sleep(0.6) return true ## Die kleinste Würfelzahl am Tisch - wer da liegt, ist hinten. func _kleinste_hand() -> int: var k := 99 for i in _alive_seats(): k = mini(k, seats[i].count) return k ## 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 func _phase_reveal(result: Dictionary) -> void: phase = "reveal" deadline = 0.0 var b: Dictionary = result.bid var rufer: int = result.challenger var genau: bool = result.get("exact", false) var total := _count_face(int(b.face)) # 1. Becher hoch. Noch kein Urteil - erst schauen. reveal_total = -1 banner = "Genau?" if genau else "Aufdecken" for i in _alive_seats(): table.arrange_and_show(i, host_values[i], rng) table.set_cup_down(i, false) # Gezielt an die Mitspieler am Tisch: ein Peer, der nur in der Lobby sitzt, # hat keinen Spielknoten und würde das Paket ins Leere laufen lassen. var values := _all_values() for i in seats.size(): var peer: int = seats[i].peer if _peer_live(peer): _reveal.rpc_id(peer, values, banner) _push_state() await _sleep(ZEIT_BECHER) if not _alive(): return # 2. Zählen. Die Ansage in der Mitte färbt sich jetzt ein. reveal_total = total banner = "%d gefunden, %d angesagt" % [total, b.count] _push_state() await _sleep(ZEIT_ZAEHLEN) if not _alive(): return # 3. Urteil. Bei "Genau" trifft es alle anderen - oder nur den Rufer. var verlierer: Array = [] var winner: int var bonus := -1 if genau and total == int(b.count): # Fester Schwung von zwei, egal wie viele am Tisch sitzen: der Ansager # verliert einen, der Rufer bekommt einen. Mehr als eine Anfechtung # bringt, weniger als "alle anderen" - das kippte bei vielen Spielern. verlierer = [int(b.seat)] winner = rufer bonus = rufer banner = "Genau! %s verliert, %s bekommt einen Würfel" % [ seats[int(b.seat)].name, seats[rufer].name] elif genau: verlierer = [rufer] winner = int(b.seat) banner = "Daneben - %s verliert" % seats[rufer].name else: var loser := rufer if total >= int(b.count) else int(b.seat) verlierer = [loser] winner = int(b.seat) if loser == rufer else rufer banner = "%s verliert" % seats[loser].name _log("%d x %d angesagt - tatsächlich %d. %s" % [b.count, b.face, total, banner]) _topf_an(winner) _push_state() await _sleep(ZEIT_URTEIL) if not _alive(): return for l: int in verlierer: seats[l].count -= 1 if seats[l].count <= 0: seats[l].alive = false seats[l].count = 0 host_values[l] = [] table.set_dice_count(l, 0) _log("%s scheidet aus." % seats[l].name) else: _zieh_muenze(l) if bonus >= 0 and seats[bonus].alive: seats[bonus].count += 1 host_values[bonus].append(rng.randi_range(1, 6)) table.set_dice_count(bonus, seats[bonus].count) # Nach einem Treffer beginnt der Rufer, sonst der Verlierer. var naechster: int = winner if genau and total == int(b.count) else verlierer[0] starter = naechster if seats[naechster].alive else _next_alive(naechster) # Nur wer noch spielt, bekommt den Becher wieder. Auf einer leeren Zone # sähe er aus, als lägen dort noch Würfel. for i in seats.size(): table.set_dice_visible(i, false) table.set_cup_down(i, seats[i].alive) banner = "" _push_state() ## Wer das Aufdecken gewinnt, nimmt aus dem Topf, so viel in den Vorrat ## passt. Der Rest bleibt liegen und wächst in die nächste Runde - so ist ## ein voller Vorrat kein Grund, den Topf zu verschenken. func _topf_an(winner: int) -> void: if topf.is_empty(): return var h: Array = hand.get(winner, []) var genommen := 0 while not topf.is_empty() and h.size() < BoosterDB.HANDLIMIT: h.append(String(topf.pop_front().id)) genommen += 1 hand[winner] = h _log("%s nimmt %d Münze(n) aus dem Topf, %d bleiben." % [ seats[winner].name, genommen, topf.size()]) func _all_values() -> Array: var out: Array = [] for i in seats.size(): out.append(host_values[i] if seats[i].alive else []) return out ## 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, 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), "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", [])) 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 Solopartie mit denselben Bots. Der Wunsch wandert über Net ins ## neu geladene Menü, das dann sofort startet. func _on_rematch() -> void: if _quitting: return var bots := 0 for s in seats: if s.ai: bots += 1 Net.rematch = {"name": Net.player_name, "ai": bots, "stufe": DiceAI.stufe} _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"})