1071 lines
33 KiB
GDScript
1071 lines
33 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
|
|
|
|
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:
|
|
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.
|
|
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 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, "verwaist": false})
|
|
for i in int(cfg.ai):
|
|
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true,
|
|
"count": START_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.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, 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}
|
|
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)
|
|
_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()
|
|
_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()
|
|
|
|
_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
|
|
_topf_ziel = winner
|
|
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),
|
|
"topf_an": _topf_ziel,
|
|
"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 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"})
|
|
|