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

@@ -136,44 +136,52 @@ Klassisches Liar's Dice ohne Joker. Jeder startet mit 5 Würfeln und sieht nur
die eigenen.
1. **Würfeln** – alle Würfel fallen physikalisch in den eigenen Becher.
2. **Boost wählen** – jeder bekommt 3 zufällige Booster angeboten und wählt
einen; er wirkt nur für die laufende Runde.
3. **Ansagen** – reihum wird ein Gebot "Anzahl x Augenzahl" über alle Würfel
2. **Ansagen** – reihum wird ein Gebot "Anzahl x Augenzahl" über alle Würfel
am Tisch gemacht. Jedes Gebot muss höher sein: mehr Würfel, oder gleich
viele bei höherer Augenzahl.
4. **Anfechten** – statt zu erhöhen kann man das Vorgebot anzweifeln. Alle
3. **Anfechten** – statt zu erhöhen kann man das Vorgebot anzweifeln. Alle
Becher werden gehoben und gezählt: Stimmt das Gebot, verliert der
Anfechter einen Würfel, sonst der Bieter.
Für Boosterwahl und Ansage hat man jeweils **30 Sekunden**; die Restzeit steht
im Booster-Fenster beziehungsweise neben „Deine Ansage" und wird unter zehn
Sekunden rot. Läuft die Zeit ab, entscheidet der Host automatisch – beim Booster
zufällig, beim Gebot mit der Bot-Logik. Ohne dieses Limit würde ein abwesender
oder getrennter Mitspieler den ganzen Tisch dauerhaft blockieren.
Für die Ansage hat man **30 Sekunden**; die Restzeit steht neben „Deine Ansage"
und wird unter zehn Sekunden rot. Läuft die Zeit ab, entscheidet der Host mit
der Bot-Logik. Ohne dieses Limit würde ein abwesender oder getrennter
Mitspieler den ganzen Tisch dauerhaft blockieren.
Wer keine Würfel mehr hat, scheidet aus. Der Letzte gewinnt.
## Booster
## Karten
| Booster | Art | Wirkung |
Karten ersetzen die frühere Booster-Phase. Die kostete jede Runde eine eigene
Wartezeit und riss den Ablauf auseinander.
* Wer einen Würfel abgibt, **zieht eine Karte**. Das ist der einzige Nachschub,
also bekommt der etwas zurück, der gerade hinten liegt.
* Gespielt wird **jederzeit während der Ansagen**, nicht nur am eigenen Zug.
* Mehr als **drei** Karten passen nicht auf die Hand.
* Die Hand liegt offen unten links; ein Klick spielt die Karte sofort. Nur
*Feinschliff* und *Glücksgriff* fragen noch, welcher Würfel gemeint ist.
| Karte | Art | Wirkung |
|---|---|---|
| Neuwurf | Buff | Alle eigenen Würfel neu werfen |
| Feinschliff | Buff | Einen gewählten eigenen Würfel neu werfen |
| Glücksgriff | Buff | Ein gewählter Würfel wird zur 6 |
| Zwilling | Buff | Ein anderer eigener Würfel nimmt den Wert des gewählten an |
| Nachschub | Buff | Du bekommst einen Würfel dazu |
| Widerruf | Buff | Hebt Nebel, Störfeuer und Maulkorb auf, die auf dir liegen |
| Spähen | Buff | Von jedem Gegner wird ein zufälliger Würfel aufgedeckt |
| Rütteln | Debuff | Jeder Gegner wirft einen zufälligen Würfel neu |
| Erhöhen | Debuff | Die laufende Ansage steigt um 1 |
| Absenken | Debuff | Bei jedem Gegner sinkt ein zufälliger Würfel um 1 |
| Nebel | Debuff | Jeder Gegner sieht einen eigenen Würfel nicht mehr |
| Störfeuer | Debuff | Gegner müssen diese Runde um mindestens 2 erhöhen |
| Maulkorb | Debuff | Der nächste Spieler muss bieten und darf nicht anfechten |
| Maulkorb | Debuff | Wer gerade dran ist, muss bieten und darf nicht anfechten |
## Aufbau
| Datei | Inhalt |
|---|---|
| `scripts/net.gd` | Autoload: WebSocket-Verbindung, Räume, Lobby, Spielstart |
| `scripts/boosters.gd` | Autoload: Booster-Katalog |
| `scripts/boosters.gd` | Autoload: Kartenkatalog |
| `scripts/game.gd` | Spiellogik (host-autoritativ), RPCs, Zustandsabgleich |
| `scripts/table.gd` | Aufbau der 3D-Szene: Tisch, Würfelzonen, Becher, Kamera |
| `scripts/die.gd` | Physik-Würfel, Ablesen der Augenzahl aus der Ruhelage |

View File

@@ -102,38 +102,12 @@ static func _is_higher(count: int, face: int, bid: Dictionary, min_raise: int) -
return count == bid.count and face > bid.face
static func choose_booster(offer: Array, values: Array, dice_left: int, rng: RandomNumberGenerator) -> Dictionary:
var scores: Array = []
for id in offer:
var b: Dictionary = BoosterDB.get_booster(id)
var s := rng.randf()
if b.kind == BoosterDB.Kind.BUFF:
s += 0.35
if dice_left <= 2 and b.kind == BoosterDB.Kind.BUFF:
s += 0.35
scores.append(s)
var pick := 0
for i in scores.size():
if scores[i] > scores[pick]:
pick = i
var id: String = offer[pick]
return {"id": id, "target": _target_for(id, values)}
static func _target_for(id: String, values: Array) -> int:
## Welcher eigene Würfel soll dran glauben? Für Karten mit Zielwahl.
static func target_for(id: String, values: Array) -> int:
if values.is_empty():
return 0
if id == "twin":
# Würfel mit dem häufigsten Wert
var counts := {}
for v in values:
counts[v] = counts.get(v, 0) + 1
var best_v: int = values[0]
for v: int in counts:
if counts[v] > counts.get(best_v, 0):
best_v = v
return values.find(best_v)
# sonst: schwächster Würfel
# Der schwächste Würfel: den neu zu werfen oder zur 6 zu machen bringt
# am meisten.
var idx := 0
for i in values.size():
if values[i] < values[idx]:

View File

@@ -1,9 +1,15 @@
extends Node
## Katalog aller Booster. Jede Runde bekommt jeder Spieler 3 davon
## zufällig angeboten und wählt genau einen aus.
## Katalog aller Karten.
##
## Karten gibt es nicht mehr rundenweise zur Auswahl. Wer einen Würfel
## verliert, zieht eine. Gespielt wird jederzeit während der Ansagen - deshalb
## wirkt jede Karte sofort und braucht keine eigene Phase mehr.
enum Kind { BUFF, DEBUFF }
## Mehr als das passt nicht auf die Hand; sonst hortet man bis zum Schluss.
const HANDLIMIT := 3
const ALL := [
{
"id": "reroll_all", "kind": Kind.BUFF, "target": false,
@@ -21,9 +27,14 @@ const ALL := [
"desc": "Ein gewählter Würfel wird zur 6.",
},
{
"id": "twin", "kind": Kind.BUFF, "target": true,
"name": "Zwilling",
"desc": "Ein anderer deiner Würfel nimmt den Wert des gewählten an.",
"id": "gain_die", "kind": Kind.BUFF, "target": false,
"name": "Nachschub",
"desc": "Du bekommst einen Würfel dazu.",
},
{
"id": "cancel", "kind": Kind.BUFF, "target": false,
"name": "Widerruf",
"desc": "Hebt Nebel, Störfeuer und Maulkorb auf, die auf dir liegen.",
},
{
"id": "peek", "kind": Kind.BUFF, "target": false,
@@ -31,9 +42,9 @@ const ALL := [
"desc": "Von jedem Gegner wird dir ein zufälliger Würfel aufgedeckt.",
},
{
"id": "enemy_reroll", "kind": Kind.DEBUFF, "target": false,
"name": "Rütteln",
"desc": "Jeder Gegner muss einen zufälligen Würfel neu werfen.",
"id": "bid_up", "kind": Kind.DEBUFF, "target": false,
"name": "Erhöhen",
"desc": "Die laufende Ansage steigt um 1.",
},
{
"id": "enemy_lower", "kind": Kind.DEBUFF, "target": false,
@@ -53,7 +64,7 @@ const ALL := [
{
"id": "gag", "kind": Kind.DEBUFF, "target": false,
"name": "Maulkorb",
"desc": "Der nächste Spieler muss bieten und darf nicht anfechten.",
"desc": "Wer gerade dran ist, muss bieten und darf nicht anfechten.",
},
]
@@ -66,17 +77,9 @@ func get_booster(id: String) -> Dictionary:
func needs_target(id: String) -> bool:
var b := get_booster(id)
return b.get("target", false)
return get_booster(id).get("target", false)
func draw_offer(rng: RandomNumberGenerator, amount: int = 3) -> Array:
var pool := ALL.duplicate()
var out: Array = []
for i in amount:
if pool.is_empty():
break
var k := rng.randi_range(0, pool.size() - 1)
out.append(pool[k].id)
pool.remove_at(k)
return out
## Eine zufällige Karte, für den, der gerade einen Würfel verloren hat.
func zieh(rng: RandomNumberGenerator) -> String:
return String(ALL[rng.randi_range(0, ALL.size() - 1)].id)

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)

View File

@@ -39,6 +39,8 @@ var bid_btn: Button
var challenge_btn: Button
var action_panel: PanelContainer
var hand_panel: PanelContainer
var hand_box: HBoxContainer
var boost_slot: CenterContainer
var pause_slot: Control
var pause_hint: Label
@@ -278,6 +280,19 @@ func _build() -> void:
timer_bid.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
av.add_child(timer_bid)
# --- Handkarten unten links, über der Ansageleiste
hand_panel = _panel()
_place(hand_panel, 0, 1, 0, 1, 16, -190, 16, -124)
root.add_child(hand_panel)
var hv := VBoxContainer.new()
hv.add_theme_constant_override("separation", 4)
hand_panel.add_child(hv)
hv.add_child(_label("Karten", 13, DIM))
hand_box = HBoxContainer.new()
hand_box.add_theme_constant_override("separation", 6)
hv.add_child(hand_box)
hand_panel.visible = false
# --- Booster-Overlay
boost_slot = _slot(0, 0, 1, 1, 0, 0, 0, 0)
boost_slot.visible = false
@@ -482,6 +497,8 @@ func update_state(st: Dictionary, my_seat: int) -> void:
bg = ROT
bid_row.add_child(DieIcon.new(int(st.bid.face), px, bg))
_zeichne_hand(st)
_clear(boosts_box)
var boosts: Array = st.get("boosts", [])
var any := false
@@ -504,7 +521,7 @@ func update_state(st: Dictionary, my_seat: int) -> void:
boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT))
any = true
if not any:
boosts_box.add_child(_label("Keine Booster aktiv", 14, DIM))
boosts_box.add_child(_label("Keine Karte gespielt", 14, DIM))
banner_label.text = st.banner
@@ -551,7 +568,9 @@ func _process(_delta: float) -> void:
var col := Color(0.95, 0.45, 0.4) if left <= 10.0 else DIM
timer_bid.text = text if wants_bid else ""
timer_bid.add_theme_color_override("font_color", col)
timer_boost.text = "Noch %s - danach wird zufällig gewählt" % text if wants_boost else ""
# Läuft die Ansagezeit ab, während die Würfelauswahl offen steht, wird
# automatisch angesagt - die Karte bleibt dann ungespielt auf der Hand.
timer_boost.text = "Noch %s bis zur Ansage" % text if wants_boost else ""
timer_boost.add_theme_color_override("font_color", col)
@@ -596,32 +615,29 @@ func set_banner(text: String) -> void:
# ---------------------------------------------------------------- Booster
func show_booster_offer(ids: Array, values: Array) -> void:
_my_values = values
_pending_boost = ""
target_row.visible = false
boost_title.text = "Wähle einen Booster"
_clear(boost_cards)
for id: String in ids:
## Die Hand liegt offen unten links. Ein Klick spielt die Karte sofort;
## nur Karten mit eigenem Ziel fragen noch nach dem Würfel.
func _zeichne_hand(st: Dictionary) -> void:
if hand_box == null:
return
var karten: Array = st.get("hand", [])
hand_panel.visible = not karten.is_empty()
_clear(hand_box)
var spielbar: bool = st.phase == "bid"
for id: String in karten:
var b: Dictionary = BoosterDB.get_booster(id)
var card := _panel(Color(0.10, 0.12, 0.17, 0.98))
card.custom_minimum_size = Vector2(210, 170)
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 6)
card.add_child(v)
var kind_col := Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5)
v.add_child(_label("BUFF" if b.kind == BoosterDB.Kind.BUFF else "DEBUFF", 12, kind_col))
v.add_child(_label(b.name, 20, ACCENT))
var d := _label(b.desc, 14, Color(0.8, 0.82, 0.88))
d.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
d.custom_minimum_size = Vector2(190, 60)
v.add_child(d)
if b.is_empty():
continue
var btn := Button.new()
btn.text = "Wählen"
btn.text = String(b.name)
btn.tooltip_text = String(b.desc)
btn.custom_minimum_size = Vector2(0, 40)
btn.disabled = not spielbar
var col := Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5)
btn.add_theme_color_override("font_color", col)
btn.add_theme_color_override("font_hover_color", Color.WHITE)
btn.pressed.connect(_pick.bind(id))
v.add_child(btn)
boost_cards.add_child(card)
boost_slot.visible = true
hand_box.add_child(btn)
func _pick(id: String) -> void:
@@ -633,6 +649,7 @@ func _pick(id: String) -> void:
boost_title.text = "%s - welchen Würfel?" % BoosterDB.get_booster(id).name
_clear(boost_cards)
_clear(target_row)
boost_slot.visible = true
for i in _my_values.size():
var b := Button.new()
b.custom_minimum_size = Vector2(56, 56)