extends Node3D ## Host-autoritative Spiellogik + Client-Ansicht. const START_DICE := 5 const BOOST_TIMEOUT := 30.0 const BID_TIMEOUT := 30.0 var table: GameTable var hud: CanvasLayer var rng := RandomNumberGenerator.new() var seats: Array = [] ## [{peer, name, ai, count, alive}] var local_seat := 0 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 fogged: Dictionary = {} ## seat -> int index var peeked: Dictionary = {} ## viewer -> {target_seat: [idx, value]} var min_raise: Dictionary = {} ## seat -> int var must_bid: Dictionary = {} ## seat -> bool var chosen_boost: Dictionary = {} 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 func _ready() -> void: rng.randomize() func setup(cfg: Dictionary) -> void: 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}) var me := multiplayer.get_unique_id() for i in seats.size(): if seats[i].peer == me: local_seat = i table = GameTable.new() add_child(table) table.build(seats.size(), local_seat) 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.booster_chosen.connect(_on_booster_chosen) hud.quit_pressed.connect(_on_quit) hud.pause_changed.connect(_on_pause_changed) for i in seats.size(): 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) # ---------------------------------------------------------------- Host-Ablauf func _host_loop() -> void: _log("Spiel gestartet - %d Spieler, je %d Würfel." % [seats.size(), START_DICE]) starter = 0 while _alive_count() > 1: await _phase_roll() await _phase_boost() var result: Dictionary = await _phase_bid() 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 func _now() -> float: return Time.get_ticks_msec() / 1000.0 func _phase_roll() -> void: phase = "roll" deadline = 0.0 bid = {} fogged.clear() peeked.clear() min_raise.clear() must_bid.clear() chosen_boost.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 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: await get_tree().physics_frame 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 func _phase_boost() -> void: phase = "boost" _answered.clear() var offers := {} for i in _alive_seats(): offers[i] = BoosterDB.draw_offer(rng) if seats[i].ai: var pick := DiceAI.choose_booster(offers[i], host_values[i], seats[i].count, rng) chosen_boost[i] = pick _answered[i] = true else: _send_offer(i, offers[i]) deadline = _now() + BOOST_TIMEOUT _push_state() await _wait_until(_all_boosts_in, BOOST_TIMEOUT) for i in _alive_seats(): if not chosen_boost.has(i): var fallback: String = offers[i][rng.randi_range(0, offers[i].size() - 1)] chosen_boost[i] = {"id": fallback, "target": 0} await _apply_boosters() _snapshot_all() _push_state() func _all_boosts_in() -> bool: for i in _alive_seats(): if not _answered.has(i) and not seats[i].ai: return false return true func _send_offer(seat: int, ids: Array) -> void: var peer: int = seats[seat].peer if peer == multiplayer.get_unique_id(): hud.show_booster_offer(ids, my_values) else: _offer.rpc_id(peer, ids) func _apply_boosters() -> void: var rerolls := {} ## seat -> Array[int] var order := _alive_seats() for seat in order: var pick: Dictionary = chosen_boost[seat] var id: String = pick.id var target: int = clampi(int(pick.get("target", 0)), 0, maxi(0, host_values[seat].size() - 1)) var b: Dictionary = BoosterDB.get_booster(id) _log("%s nutzt %s." % [seats[seat].name, b.name]) match id: "reroll_all": rerolls[seat] = range(host_values[seat].size()) "reroll_one": rerolls[seat] = _merge(rerolls.get(seat, []), [target]) "enemy_reroll": for o in order: if o != seat and host_values[o].size() > 0: var k := rng.randi_range(0, host_values[o].size() - 1) rerolls[o] = _merge(rerolls.get(o, []), [k]) "gag": must_bid[_next_alive(seat)] = true "raise_min": for o in order: if o != seat: min_raise[o] = 2 if not rerolls.is_empty(): _stream = true for seat: int in rerolls: _throw_subset(seat, rerolls[seat]) await _await_settled(rerolls.keys()) _stream = false for seat: int in rerolls: var dl: Array = table.dice_of(seat) for k: int in rerolls[seat]: if k < dl.size(): host_values[seat][k] = dl[k].top_value() table.freeze_dice(seat) for seat in order: var pick: Dictionary = chosen_boost[seat] var id: String = pick.id var vals: Array = host_values[seat] var target: int = clampi(int(pick.get("target", 0)), 0, maxi(0, vals.size() - 1)) match id: "set_six": _set_die(seat, target, 6) "twin": if vals.size() > 1: var others: Array = [] for k in vals.size(): if k != target: others.append(k) _set_die(seat, others[rng.randi_range(0, others.size() - 1)], vals[target]) "enemy_lower": for o in order: if o != seat and host_values[o].size() > 0: var k := rng.randi_range(0, host_values[o].size() - 1) _set_die(o, k, maxi(1, host_values[o][k] - 1)) "peek": var info := {} for o in order: if o != seat and host_values[o].size() > 0: var k := rng.randi_range(0, host_values[o].size() - 1) info[o] = [k, host_values[o][k]] peeked[seat] = info "fog": for o in order: if o != seat and host_values[o].size() > 0: fogged[o] = rng.randi_range(0, host_values[o].size() - 1) func _merge(a: Array, b: Array) -> Array: var out := a.duplicate() for v in b: if not out.has(v): out.append(v) return out 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(): 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 off := Vector3(rng.randf_range(-0.1, 0.1), 0.42, rng.randf_range(-0.22, 0.22)) 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 true: _answered.clear() deadline = 0.0 if seats[turn].ai else _now() + BID_TIMEOUT _push_state() if seats[turn].ai: await _sleep(1.1) var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, min_raise.get(turn, 1), must_bid.get(turn, false), rng) 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, min_raise.get(turn, 1), 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 {} ## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis. func _apply_action(seat: int, a: Dictionary) -> Dictionary: if a.type == "bid" and not _valid_bid(seat, int(a.count), int(a.face)): a = {"type": "challenge"} if not bid.is_empty() else {"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)} must_bid.erase(seat) _log("%s sagt %d x %d an." % [seats[seat].name, bid.count, bid.face]) return {} 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 var mr: int = min_raise.get(seat, 1) if count >= bid.count + mr: return true if mr > 1: return false 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 challenger: int = result.challenger var total := 0 for i in _alive_seats(): for v in host_values[i]: if v == b.face: total += 1 var loser := challenger if total >= b.count else int(b.seat) banner = "%d x %d angesagt - tatsächlich %d. %s verliert einen Würfel." % [ b.count, b.face, total, seats[loser].name] _log(banner) for i in _alive_seats(): table.arrange_and_show(i, host_values[i], rng) table.set_cup_down(i, false) if not multiplayer.get_peers().is_empty(): _reveal.rpc(_all_values(), banner) _push_state() await _sleep(4.5) seats[loser].count -= 1 if seats[loser].count <= 0: seats[loser].alive = false seats[loser].count = 0 host_values[loser] = [] table.set_dice_count(loser, 0) _log("%s scheidet aus." % seats[loser].name) starter = loser if seats[loser].alive else _next_alive(loser) for i in seats.size(): table.set_dice_visible(i, false) table.set_cup_down(i, true) banner = "" _push_state() func _all_values() -> Array: var out: Array = [] for i in seats.size(): out.append(host_values[i] if seats[i].alive else []) return out func _sleep(sec: float) -> void: await get_tree().create_timer(sec).timeout func _wait_until(cond: Callable, timeout: float) -> bool: var t := 0.0 while t < timeout: if cond.call(): return true await get_tree().process_frame t += get_process_delta_time() return 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 multiplayer.is_server() or not _stream: 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 peer <= 1: 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 > 1: _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)) 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].global_transform = 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: for i in seats.size(): if i == local_seat: continue 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: _refresh_labels() for i in seats.size(): var peer: int = seats[i].peer var st := _state_for(i) if peer == multiplayer.get_unique_id(): _apply_state(st) elif peer > 1: _state.rpc_id(peer, st) 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) elif phase == "reveal": vis = host_values[seat].duplicate() # aufgedeckt wird ohne Nebel else: for k in host_values[seat].size(): vis.append(-1 if fogged.get(seat, -1) == k else host_values[seat][k]) 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, "log": log_lines.duplicate(), "banner": banner, "peek": peeked.get(seat, {}), "min_raise": min_raise.get(seat, 1), "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 [], "boost": chosen_boost.get(seat, {}).get("id", ""), } @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(): 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, true) table.set_dice_visible(local_seat, true) _refresh_labels() _apply_fog_to_table() hud.update_state(st, local_seat) # ---------------------------------------------------------------- Aktionen ## Der Nebel-Debuff muss auch den Würfel auf dem Tisch verdecken. func _apply_fog_to_table() -> void: if phase == "roll" or local_seat >= table.dice.size(): return var dl: Array = table.dice_of(local_seat) for i in mini(dl.size(), my_values.size()): dl[i].visible = my_values[i] >= 0 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_booster_chosen(id: String, target: int) -> void: if multiplayer.is_server(): _do_boost(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() func _on_quit() -> void: get_tree().paused = 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_boost(id: String, target: int) -> void: _do_boost(_seat_of(multiplayer.get_remote_sender_id()), id, target) func _seat_of(peer: int) -> int: 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() or must_bid.get(seat, false): return _answered[seat] = _apply_action(seat, {"type": "challenge"}) func _do_boost(seat: int, id: String, target: int) -> void: if seat < 0 or phase != "boost" or chosen_boost.has(seat): return if BoosterDB.get_booster(id).is_empty(): return chosen_boost[seat] = {"id": id, "target": target} _answered[seat] = true @rpc("authority", "reliable") func _offer(ids: Array) -> void: hud.show_booster_offer(ids, my_values)