Booster-Phase raus, Karten auf die Hand

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>
This commit is contained in:
team3
2026-09-08 16:11:07 +02:00
parent 318f9f3159
commit 3f4532ad59
5 changed files with 219 additions and 240 deletions

View File

@@ -2,7 +2,6 @@ extends Node3D
## Host-autoritative Spiellogik + Client-Ansicht.
const START_DICE := 5
const BOOST_TIMEOUT := 30.0
const BID_TIMEOUT := 30.0
const REVEAL_PAUSE := 5.0 ## Anzeigedauer des Rundenergebnisses
@@ -23,8 +22,9 @@ 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 chosen_boost: Dictionary = {}
var offers: Dictionary = {} ## seat -> die drei angebotenen Booster
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"
@@ -78,6 +78,7 @@ func setup(cfg: Dictionary) -> void:
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)
@@ -123,9 +124,6 @@ func _host_loop() -> void:
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
await _phase_boost()
if not _alive():
return
var result: Dictionary = await _phase_bid()
@@ -201,7 +199,7 @@ func _phase_roll() -> void:
peeked.clear()
min_raise.clear()
must_bid.clear()
chosen_boost.clear()
letzte_karte.clear()
banner = ""
_log("--- Neue Runde ---")
@@ -256,137 +254,106 @@ func _await_settled(list: Array, timeout := 7.0) -> void:
return
func _phase_boost() -> void:
phase = "boost"
_answered.clear()
offers.clear()
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()
# ---------------------------------------------------------------- Karten
await _wait_until(_all_boosts_in, BOOST_TIMEOUT)
if not _alive():
## 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])
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()
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 _all_boosts_in() -> bool:
for i in _alive_seats():
if not _answered.has(i) and not seats[i].ai:
return false
return true
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
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
if not _alive():
return
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:
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
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
## 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:
@@ -419,6 +386,10 @@ func _phase_bid() -> Dictionary:
_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)
@@ -442,6 +413,22 @@ func _phase_bid() -> Dictionary:
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)):
@@ -523,6 +510,7 @@ func _phase_reveal(result: Dictionary) -> void:
return
seats[loser].count -= 1
_zieh_karte(loser)
if seats[loser].count <= 0:
seats[loser].alive = false
seats[loser].count = 0
@@ -657,14 +645,14 @@ func _push_state() -> void:
_state.rpc_id(peer, _state_for(i))
## Erst nach der Wahl offenlegen, sonst verrät die Anzeige während der
## Boosterphase, wer schon gewählt hat.
## 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 == "boost" or phase == "roll":
if phase == "roll":
return out
for i in seats.size():
out.append(String(chosen_boost.get(i, {}).get("id", "")))
out.append(String(letzte_karte.get(i, "")))
return out
@@ -700,7 +688,8 @@ func _state_for(seat: int) -> Dictionary:
"boosts": _round_boosters(),
"actual": reveal_total if phase == "reveal" else 0,
"bids": _seat_bids(),
"boost": chosen_boost.get(seat, {}).get("id", ""),
"boost": String(letzte_karte.get(seat, "")),
"hand": (hand.get(seat, []) as Array).duplicate(),
}
@@ -716,6 +705,9 @@ func _apply_state(st: Dictionary) -> void:
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:
@@ -768,7 +760,7 @@ func _on_challenge_pressed() -> void:
func _on_booster_chosen(id: String, target: int) -> void:
if multiplayer.is_server():
_do_boost(local_seat, id, target)
_do_play(local_seat, id, target)
else:
_req_boost.rpc_id(1, id, target)
@@ -812,7 +804,7 @@ func _req_challenge() -> void:
@rpc("any_peer", "reliable")
func _req_boost(id: String, target: int) -> void:
_do_boost(_seat_of(multiplayer.get_remote_sender_id()), id, target)
_do_play(_seat_of(multiplayer.get_remote_sender_id()), id, target)
func _seat_of(peer: int) -> int:
@@ -843,18 +835,3 @@ func _do_challenge(seat: int) -> void:
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) or not seats[seat].alive:
return
# Nur aus dem eigenen Angebot: sonst könnte ein veränderter Client sich
# jede Runde den stärksten Booster aus dem Katalog nehmen.
if not (offers.get(seat, []) as Array).has(id):
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)