Die Boosterwahl kostete jede Runde eine eigene Phase mit 30 Sekunden Frist. Zwischen Wurf und Ansage lag damit eine Wartezeit, die den Ablauf zerriss. Jetzt gibt es Karten statt einer Phase: * Wer einen Wuerfel abgibt, zieht eine Karte. Das ist der einzige Nachschub, also bekommt der etwas zurueck, der gerade hinten liegt. * Gespielt wird jederzeit waehrend der Ansagen, nicht nur am eigenen Zug. * Hoechstens drei Karten auf der Hand. * Die Hand liegt offen unten links, ein Klick spielt. Nur Feinschliff und Gluecksgriff fragen noch nach dem Wuerfel. Der Katalog ist angepasst, weil jede Karte jetzt sofort wirken muss: neu sind Erhoehen (Ansage +1), Widerruf (hebt Nebel, Stoerfeuer und Maulkorb auf dir auf) und Nachschub (ein Wuerfel dazu). Zwilling und Ruetteln sind raus - beide brauchten den festen Zeitpunkt vor dem Wurf. Bots spielen ihre Karten am eigenen Zug, bei voller Hand immer. Zwei Karten gleichzeitig wuerden sich beim Wuerfeln in die Quere kommen, deshalb wird immer nur eine aufgeloest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
838 lines
24 KiB
GDScript
838 lines
24 KiB
GDScript
extends Node3D
|
|
## Host-autoritative Spiellogik + Client-Ansicht.
|
|
|
|
const START_DICE := 5
|
|
const BID_TIMEOUT := 30.0
|
|
const REVEAL_PAUSE := 5.0 ## Anzeigedauer des Rundenergebnisses
|
|
|
|
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 fogged: Dictionary = {} ## seat -> Array[int] verdeckter Würfel
|
|
var peeked: Dictionary = {} ## viewer -> {target_seat: [idx, value]}
|
|
var min_raise: Dictionary = {} ## seat -> int
|
|
var must_bid: Dictionary = {} ## seat -> bool
|
|
var hand: Dictionary = {} ## seat -> Array[String], Karten auf der Hand
|
|
var letzte_karte: Dictionary = {} ## seat -> zuletzt gespielte Karte, nur Anzeige
|
|
var _karte_laeuft := false ## eine Karte wird gerade aufgelöst
|
|
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:
|
|
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.booster_chosen.connect(_on_booster_chosen)
|
|
hud.quit_pressed.connect(_on_quit)
|
|
hud.pause_changed.connect(_on_pause_changed)
|
|
|
|
for i in seats.size():
|
|
hand[i] = []
|
|
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() 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 = {}
|
|
fogged.clear()
|
|
last_bids.clear()
|
|
peeked.clear()
|
|
min_raise.clear()
|
|
must_bid.clear()
|
|
letzte_karte.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
|
|
|
|
|
|
# ---------------------------------------------------------------- Karten
|
|
|
|
## Wer einen Würfel abgibt, zieht eine Karte. Das ist der einzige Nachschub.
|
|
func _zieh_karte(seat: int) -> void:
|
|
var karten: Array = hand.get(seat, [])
|
|
if karten.size() >= BoosterDB.HANDLIMIT:
|
|
_log("%s hat die Hand voll und zieht nicht nach." % seats[seat].name)
|
|
return
|
|
var id := BoosterDB.zieh(rng)
|
|
karten.append(id)
|
|
hand[seat] = karten
|
|
_log("%s zieht %s." % [seats[seat].name, BoosterDB.get_booster(id).name])
|
|
|
|
|
|
func _do_play(seat: int, id: String, target: int) -> void:
|
|
if seat < 0 or phase != "bid" or not seats[seat].alive:
|
|
return
|
|
# Zwei Karten gleichzeitig würden sich beim Würfeln in die Quere kommen.
|
|
if _karte_laeuft:
|
|
return
|
|
var karten: Array = hand.get(seat, [])
|
|
if not karten.has(id):
|
|
return
|
|
karten.erase(id)
|
|
hand[seat] = karten
|
|
letzte_karte[seat] = id
|
|
_log("%s spielt %s." % [seats[seat].name, BoosterDB.get_booster(id).name])
|
|
_karte_laeuft = true
|
|
await _wirkung(seat, id, target)
|
|
_karte_laeuft = false
|
|
if not _alive():
|
|
return
|
|
_snapshot_all()
|
|
_push_state()
|
|
|
|
|
|
func _wirkung(seat: int, id: String, target: int) -> void:
|
|
var vals: Array = host_values[seat]
|
|
var ziel: int = clampi(target, 0, maxi(0, vals.size() - 1))
|
|
match id:
|
|
"reroll_all":
|
|
await _neu_werfen(seat, range(vals.size()))
|
|
"reroll_one":
|
|
await _neu_werfen(seat, [ziel])
|
|
"set_six":
|
|
_set_die(seat, ziel, 6)
|
|
"gain_die":
|
|
seats[seat].count += 1
|
|
table.set_dice_count(seat, seats[seat].count)
|
|
host_values[seat].append(1)
|
|
await _neu_werfen(seat, [host_values[seat].size() - 1])
|
|
"cancel":
|
|
fogged.erase(seat)
|
|
min_raise.erase(seat)
|
|
must_bid.erase(seat)
|
|
"peek":
|
|
var info := {}
|
|
for o in _alive_seats():
|
|
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
|
|
"bid_up":
|
|
if not bid.is_empty():
|
|
bid.count = int(bid.count) + 1
|
|
last_bids[int(bid.seat)] = {"count": bid.count, "face": bid.face}
|
|
"enemy_lower":
|
|
for o in _alive_seats():
|
|
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))
|
|
"fog":
|
|
for o in _alive_seats():
|
|
if o != seat and host_values[o].size() > 0:
|
|
var k := rng.randi_range(0, host_values[o].size() - 1)
|
|
var liste: Array = fogged.get(o, [])
|
|
if not liste.has(k):
|
|
liste.append(k)
|
|
fogged[o] = liste
|
|
"raise_min":
|
|
for o in _alive_seats():
|
|
if o != seat:
|
|
min_raise[o] = 2
|
|
"gag":
|
|
must_bid[turn] = true
|
|
|
|
|
|
## 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)
|
|
# Bots spielen ihre Karten, bevor sie ansagen.
|
|
await _bot_karte(turn)
|
|
if not _alive():
|
|
return {}
|
|
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 {}
|
|
|
|
|
|
## Ein Bot legt manchmal eine Karte, statt sie bis zum Ausscheiden zu horten.
|
|
func _bot_karte(seat: int) -> void:
|
|
var karten: Array = hand.get(seat, [])
|
|
if karten.is_empty():
|
|
return
|
|
# Volle Hand: raus damit. Sonst nur ab und zu, sonst ist sofort alles weg.
|
|
var lust := 1.0 if karten.size() >= BoosterDB.HANDLIMIT else 0.35
|
|
if rng.randf() > lust:
|
|
return
|
|
var id: String = karten[rng.randi_range(0, karten.size() - 1)]
|
|
if id == "bid_up" and bid.is_empty():
|
|
return
|
|
await _do_play(seat, id, DiceAI.target_for(id, host_values[seat]))
|
|
await _sleep(0.6)
|
|
|
|
|
|
## 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)):
|
|
# 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}
|
|
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}
|
|
var mr: int = min_raise.get(seat, 1)
|
|
if mr == 1 and int(bid.face) < 6:
|
|
return {"count": int(bid.count), "face": int(bid.face) + 1}
|
|
var c: int = int(bid.count) + mr
|
|
if c <= _total_dice():
|
|
return {"count": c, "face": 1}
|
|
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
|
|
|
|
reveal_total = total
|
|
var loser := challenger if total >= b.count else int(b.seat)
|
|
_log("%d x %d angesagt - tatsächlich %d. %s verliert einen Würfel." % [
|
|
b.count, b.face, total, seats[loser].name])
|
|
banner = "%s verliert" % seats[loser].name
|
|
|
|
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(REVEAL_PAUSE)
|
|
if not _alive():
|
|
return
|
|
|
|
seats[loser].count -= 1
|
|
_zieh_karte(loser)
|
|
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
|
|
|
|
|
|
## 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))
|
|
|
|
|
|
## Wer hat diese Runde schon eine Karte gespielt? Beim Würfeln steht noch
|
|
## nichts an, danach ist es offen.
|
|
func _round_boosters() -> Array:
|
|
var out: Array = []
|
|
if phase == "roll":
|
|
return out
|
|
for i in seats.size():
|
|
out.append(String(letzte_karte.get(i, "")))
|
|
return out
|
|
|
|
|
|
## 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)
|
|
elif phase == "reveal":
|
|
vis = host_values[seat].duplicate() # aufgedeckt wird ohne Nebel
|
|
else:
|
|
var fg: Array = fogged.get(seat, [])
|
|
for k in host_values[seat].size():
|
|
vis.append(-1 if fg.has(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,
|
|
"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 [],
|
|
"boosts": _round_boosters(),
|
|
"actual": reveal_total if phase == "reveal" else 0,
|
|
"bids": _seat_bids(),
|
|
"boost": String(letzte_karte.get(seat, "")),
|
|
"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, true)
|
|
table.set_dice_visible(local_seat, true)
|
|
_refresh_labels()
|
|
_apply_fog_to_table()
|
|
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
|
|
|
|
## Der Nebel-Debuff muss auch den Würfel auf dem Tisch verdecken.
|
|
func _apply_fog_to_table() -> void:
|
|
if phase == "roll" or local_seat < 0 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_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()
|
|
|
|
|
|
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_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"})
|
|
|