diff --git a/README.md b/README.md index 2926bf4..dd07908 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Liar's Dice 3D Würfel-Bluffspiel in Godot 4.7 mit echter 3D-Physik, Mehrspieler über Raum-Codes, -KI-Bots und einem Booster-System aus Buffs und Debuffs. +KI-Bots und Münzen als Buffs und Debuffs. ## Starten @@ -150,38 +150,61 @@ Mitspieler den ganzen Tisch dauerhaft blockieren. Wer keine Würfel mehr hat, scheidet aus. Der Letzte gewinnt. -## Karten +## Münzen -Karten ersetzen die frühere Booster-Phase. Die kostete jede Runde eine eigene +Münzen 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 +* Jeder startet mit **einer** Münze. +* Wer danach einen Würfel abgibt, **bekommt eine Münze**. Das ist der einzige + Nachschub, also bekommt der etwas zurück, der gerade hinten liegt. +* Geworfen wird **jederzeit während der Ansagen**, nicht nur am eigenen Zug. +* Mehr als **drei** Münzen passen nicht in den Vorrat. +* Der Vorrat liegt offen unten links; ein Klick wirft die Münze 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 | -| 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 | -| 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 | Wer gerade dran ist, muss bieten und darf nicht anfechten | +### Der Wurf + +Beim Ausspielen wird die Münze geworfen und landet in der Mitte. + +* **Zahl** – die Grundwirkung tritt ein. +* **Wappen** – die Wirkung fällt stärker aus. Die Chance ist die Hälfte. + +Die Grundwirkung kommt immer. Kein Zug ist verschenkt, und die Entscheidung, +wann geworfen wird, bleibt beim Spieler. Der Wurf selbst ist reine Zugabe. + +Der Ausgang wird beim Host ausgewürfelt und mit dem Zustand verschickt. Die +Drehung im HUD ist nur die Ansicht dazu - sie endet auf der Seite, die schon +feststeht. Würfelphysik wäre hier nicht synchron zu bekommen. + +**Der Wurf verstärkt die Würfelzahl nie.** Sie ist die Kernressource; ein +Würfel mehr aus Glück würde die Partie kippen. *Nachschub* gibt einen Würfel - +das ist seine Grundwirkung, und Wappen macht daraus nur eine 6. Verstärkt +werden sonst nur Beträge und Reichweiten. + +| Münze | Art | Zahl | Wappen | +|---|---|---|---| +| Neuwurf | Buff | Alle eigenen Würfel neu werfen | Danach wird einer davon zur 6 | +| Feinschliff | Buff | Einen gewählten eigenen Würfel neu werfen | Ein zweiter, zufälliger kommt mit | +| Glücksgriff | Buff | Ein gewählter Würfel wird zur 6 | Ein zweiter, zufälliger wird auch zur 6 | +| Nachschub | Buff | Du bekommst einen Würfel dazu | Der neue Würfel zeigt sofort eine 6 | +| Widerruf | Buff | Hebt Nebel, Störfeuer und Maulkorb auf, die auf dir liegen | Die Münze bleibt dir erhalten | +| Spähen | Buff | Von jedem Gegner wird ein zufälliger Würfel aufgedeckt | Es sind zwei je Gegner | +| Erhöhen | Debuff | Die laufende Ansage steigt um 1 | Sie steigt um 2 | +| Absenken | Debuff | Bei jedem Gegner sinkt ein zufälliger Würfel um 1 | Er sinkt um 2 | +| Nebel | Debuff | Jeder Gegner sieht einen eigenen Würfel nicht mehr | Es sind zwei Würfel | +| Störfeuer | Debuff | Gegner müssen diese Runde um mindestens 2 erhöhen | Mindestens 3 | +| Maulkorb | Debuff | Wer gerade dran ist, muss bieten und darf nicht anfechten | Alle Gegner müssen bieten | + +Welche Wirkungen es am Ende gibt, steht noch nicht fest. Der Wurf und die +Verstärkung sind der Kern; die Tabelle ist die aktuelle Füllung. ## Aufbau | Datei | Inhalt | |---|---| | `scripts/net.gd` | Autoload: WebSocket-Verbindung, Räume, Lobby, Spielstart | -| `scripts/boosters.gd` | Autoload: Kartenkatalog | +| `scripts/boosters.gd` | Autoload: Münzkatalog mit Grund- und Wappenwirkung | | `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 | diff --git a/scripts/ai.gd b/scripts/ai.gd index 5c21624..4668b74 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -102,7 +102,7 @@ static func _is_higher(count: int, face: int, bid: Dictionary, min_raise: int) - return count == bid.count and face > bid.face -## Welcher eigene Würfel soll dran glauben? Für Karten mit Zielwahl. +## Welcher eigene Würfel soll dran glauben? Für Münzen mit Zielwahl. static func target_for(id: String, values: Array) -> int: if values.is_empty(): return 0 diff --git a/scripts/boosters.gd b/scripts/boosters.gd index 6f58d63..d686340 100644 --- a/scripts/boosters.gd +++ b/scripts/boosters.gd @@ -1,70 +1,91 @@ extends Node -## Katalog aller Karten. +## Katalog aller Münzen. ## -## 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. +## Münzen gibt es nicht rundenweise zur Auswahl. Wer einen Würfel verliert, +## bekommt eine. Gespielt wird jederzeit während der Ansagen - deshalb wirkt +## jede Münze sofort und braucht keine eigene Phase mehr. +## +## Beim Ausspielen wird die Münze geworfen. Wappen oben heißt verstärkt. Die +## Grundwirkung kommt immer, der Wurf legt nur eine Schippe drauf. Deshalb ist +## kein Zug verschenkt, und die Entscheidung bleibt beim Spieler. enum Kind { BUFF, DEBUFF } ## Mehr als das passt nicht auf die Hand; sonst hortet man bis zum Schluss. const HANDLIMIT := 3 +## Eine Münze hat zwei Seiten, also die Hälfte. +const WAPPEN_CHANCE := 0.5 + +## Die Würfelzahl wird bewusst nie verstärkt. Sie ist die Kernressource - +## ein Würfel mehr aus Glück würde die Partie kippen. const ALL := [ { "id": "reroll_all", "kind": Kind.BUFF, "target": false, "name": "Neuwurf", "desc": "Wirf alle deine Würfel noch einmal neu.", + "stark": "Danach wird einer davon zur 6.", }, { "id": "reroll_one", "kind": Kind.BUFF, "target": true, "name": "Feinschliff", "desc": "Wirf einen selbst gewählten Würfel neu.", + "stark": "Ein zweiter, zufälliger Würfel kommt mit.", }, { "id": "set_six", "kind": Kind.BUFF, "target": true, "name": "Glücksgriff", "desc": "Ein gewählter Würfel wird zur 6.", + "stark": "Ein zweiter, zufälliger Würfel wird auch zur 6.", }, { "id": "gain_die", "kind": Kind.BUFF, "target": false, "name": "Nachschub", "desc": "Du bekommst einen Würfel dazu.", + "stark": "Der neue Würfel zeigt sofort eine 6.", }, { "id": "cancel", "kind": Kind.BUFF, "target": false, "name": "Widerruf", "desc": "Hebt Nebel, Störfeuer und Maulkorb auf, die auf dir liegen.", + "stark": "Die Münze bleibt dir erhalten.", + "behalten": true, }, { "id": "peek", "kind": Kind.BUFF, "target": false, "name": "Spähen", "desc": "Von jedem Gegner wird dir ein zufälliger Würfel aufgedeckt.", + "stark": "Es sind zwei Würfel je Gegner.", }, { "id": "bid_up", "kind": Kind.DEBUFF, "target": false, "name": "Erhöhen", "desc": "Die laufende Ansage steigt um 1.", + "stark": "Sie steigt um 2.", }, { "id": "enemy_lower", "kind": Kind.DEBUFF, "target": false, "name": "Absenken", "desc": "Bei jedem Gegner sinkt ein zufälliger Würfel um 1 (1 bleibt 1).", + "stark": "Er sinkt um 2.", }, { "id": "fog", "kind": Kind.DEBUFF, "target": false, "name": "Nebel", "desc": "Jeder Gegner verliert die Sicht auf einen seiner eigenen Würfel.", + "stark": "Es sind zwei Würfel.", }, { "id": "raise_min", "kind": Kind.DEBUFF, "target": false, "name": "Störfeuer", "desc": "Alle Gegner müssen diese Runde um mindestens 2 erhöhen.", + "stark": "Es müssen mindestens 3 sein.", }, { "id": "gag", "kind": Kind.DEBUFF, "target": false, "name": "Maulkorb", "desc": "Wer gerade dran ist, muss bieten und darf nicht anfechten.", + "stark": "Alle Gegner müssen bieten.", }, ] @@ -80,6 +101,11 @@ func needs_target(id: String) -> bool: return get_booster(id).get("target", false) -## Eine zufällige Karte, für den, der gerade einen Würfel verloren hat. +## Münzen, die bei Wappen auf der Hand bleiben, statt in die Mitte zu wandern. +func keeps_on_wappen(id: String) -> bool: + return get_booster(id).get("behalten", false) + + +## Eine zufällige Münze, 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) diff --git a/scripts/game.gd b/scripts/game.gd index 0b6c597..315877f 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -4,6 +4,8 @@ extends Node3D const START_DICE := 5 const BID_TIMEOUT := 30.0 const REVEAL_PAUSE := 5.0 ## Anzeigedauer des Rundenergebnisses +const WURF_ZEIT := 1.1 ## wie lange die geworfene Münze zu sehen ist, + ## bevor ihre Wirkung einsetzt var table: GameTable var hud: CanvasLayer @@ -19,12 +21,13 @@ 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 peeked: Dictionary = {} ## viewer -> {target_seat: Array[[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 hand: Dictionary = {} ## seat -> Array[String], Münzen beim Spieler +var mitte: Dictionary = {} ## seat -> zuletzt in die Mitte gelegte Münze +var _muenze_laeuft := false ## eine Münze wird gerade aufgelöst +var _wurf_nr := 0 ## zählt die Würfe, damit das HUD neue erkennt var last_bids: Dictionary = {} ## seat -> letzte Ansage dieser Runde var log_lines: Array = [] var phase := "lobby" @@ -78,7 +81,9 @@ func setup(cfg: Dictionary) -> void: hud.pause_changed.connect(_on_pause_changed) for i in seats.size(): - hand[i] = [] + # 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, START_DICE) table.set_dice_visible(i, false) table.set_cup_down(i, i != local_seat) @@ -113,6 +118,7 @@ func _refresh_labels() -> void: 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 @@ -199,7 +205,8 @@ func _phase_roll() -> void: peeked.clear() min_raise.clear() must_bid.clear() - letzte_karte.clear() + mitte.clear() + _wurf_nr = 0 banner = "" _log("--- Neue Runde ---") @@ -254,57 +261,81 @@ func _await_settled(list: Array, timeout := 7.0) -> void: return -# ---------------------------------------------------------------- Karten +# ---------------------------------------------------------------- Münzen -## 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) +## 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) - karten.append(id) - hand[seat] = karten - _log("%s zieht %s." % [seats[seat].name, BoosterDB.get_booster(id).name]) + muenzen.append(id) + hand[seat] = muenzen + _log("%s bekommt %s." % [seats[seat].name, BoosterDB.get_booster(id).name]) +## Ausspielen heißt: Münze in die Mitte legen und werfen. Wappen verstärkt, +## Zahl lässt es bei der Grundwirkung. Beides hilft - verschenkt ist nie ein Zug. 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: + # Zwei Münzen gleichzeitig würden sich beim Würfeln in die Quere kommen. + if _muenze_laeuft: return - var karten: Array = hand.get(seat, []) - if not karten.has(id): + var muenzen: Array = hand.get(seat, []) + if not muenzen.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 + muenzen.erase(id) + hand[seat] = muenzen + _muenze_laeuft = true + + var stark := rng.randf() < BoosterDB.WAPPEN_CHANCE + _wurf_nr += 1 + mitte[seat] = {"id": id, "stark": stark, "nr": _wurf_nr} + _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 + # Der Widerruf hat keine eigene Verstärkung; bei Wappen bleibt er liegen. + if stark and BoosterDB.keeps_on_wappen(id): + var zurueck: Array = hand.get(seat, []) + if zurueck.size() < BoosterDB.HANDLIMIT: + zurueck.append(id) + hand[seat] = zurueck _snapshot_all() _push_state() -func _wirkung(seat: int, id: String, target: int) -> void: +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: "reroll_all": await _neu_werfen(seat, range(vals.size())) + if stark and not host_values[seat].is_empty(): + _set_die(seat, rng.randi_range(0, host_values[seat].size() - 1), 6) "reroll_one": - await _neu_werfen(seat, [ziel]) + await _neu_werfen(seat, _ziel_und_zufall(seat, ziel, stark)) "set_six": - _set_die(seat, ziel, 6) + for k: int in _ziel_und_zufall(seat, ziel, stark): + _set_die(seat, k, 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]) + var neu: int = host_values[seat].size() - 1 + await _neu_werfen(seat, [neu]) + if stark: + _set_die(seat, neu, 6) "cancel": fogged.erase(seat) min_raise.erase(seat) @@ -312,33 +343,69 @@ func _wirkung(seat: int, id: String, target: int) -> void: "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]] + if o != seat: + info[o] = _zufalls_paare(o, 2 if stark else 1) peeked[seat] = info "bid_up": if not bid.is_empty(): - bid.count = int(bid.count) + 1 + bid.count = int(bid.count) + (2 if stark else 1) last_bids[int(bid.seat)] = {"count": bid.count, "face": bid.face} "enemy_lower": + var minus := 2 if stark else 1 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)) + if o != seat: + for k: int in _zufalls_idx(o, 1): + _set_die(o, k, maxi(1, int(host_values[o][k]) - minus)) "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 o == seat: + continue + var liste: Array = fogged.get(o, []) + for k: int in _zufalls_idx(o, 2 if stark else 1): if not liste.has(k): liste.append(k) - fogged[o] = liste + fogged[o] = liste "raise_min": for o in _alive_seats(): if o != seat: - min_raise[o] = 2 + min_raise[o] = 3 if stark else 2 "gag": - must_bid[turn] = true + if stark: + for o in _alive_seats(): + if o != seat: + must_bid[o] = true + else: + must_bid[turn] = true + + +## 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. @@ -386,8 +453,8 @@ 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) + # Bots werfen ihre Münzen, bevor sie ansagen. + await _bot_muenze(turn) if not _alive(): return {} var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, @@ -413,16 +480,16 @@ 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(): +## Ein Bot wirft manchmal eine Münze, statt sie bis zum Ausscheiden zu horten. +func _bot_muenze(seat: int) -> void: + var muenzen: Array = hand.get(seat, []) + if muenzen.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 + # Voller Vorrat: raus damit. Sonst nur ab und zu, sonst ist alles sofort weg. + var lust := 1.0 if muenzen.size() >= BoosterDB.HANDLIMIT else 0.35 if rng.randf() > lust: return - var id: String = karten[rng.randi_range(0, karten.size() - 1)] + var id: String = muenzen[rng.randi_range(0, muenzen.size() - 1)] if id == "bid_up" and bid.is_empty(): return await _do_play(seat, id, DiceAI.target_for(id, host_values[seat])) @@ -510,17 +577,20 @@ 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 host_values[loser] = [] table.set_dice_count(loser, 0) _log("%s scheidet aus." % seats[loser].name) + else: + _zieh_muenze(loser) starter = loser if seats[loser].alive else _next_alive(loser) + # 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, true) + table.set_cup_down(i, seats[i].alive) banner = "" _push_state() @@ -645,14 +715,14 @@ func _push_state() -> void: _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. +## Welche Münze liegt von wem in der Mitte? 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, ""))) + out.append(mitte.get(i, {})) return out @@ -688,7 +758,6 @@ func _state_for(seat: int) -> Dictionary: "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(), } @@ -719,7 +788,7 @@ func _apply_state(st: Dictionary) -> void: for i in seats.size(): if i != local_seat: table.set_dice_visible(i, false) - table.set_cup_down(i, true) + table.set_cup_down(i, seats[i].alive) table.set_dice_visible(local_seat, true) _refresh_labels() _apply_fog_to_table() diff --git a/scripts/hud.gd b/scripts/hud.gd index 055b902..0761a39 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -13,6 +13,7 @@ const DIM := Color(0.62, 0.64, 0.70) ## Wette werden so gross gewählt, dass sie dort hineinpassen. const BID_BOX := Vector2(150.0, 108.0) const TIMER_W := 52.0 ## Breite der Zeitanzeige in der Ansage-Leiste +const INFO_W := 280.0 ## Textbreite im Erklärfeld beim Überfahren const GRUEN := Color(0.55, 0.84, 0.55) ## überzähliger Würfel beim Aufdecken const ROT := Color(0.94, 0.55, 0.52) ## fehlender Würfel beim Aufdecken @@ -47,6 +48,14 @@ var pause_hint: Label var boost_panel: PanelContainer var boost_cards: HBoxContainer var boost_title: Label +var turn_label: Label +var info_panel: PanelContainer +var info_name: Label +var info_desc: Label +var info_stark: Label +var wurf_slot: CenterContainer +var wurf_icon: MuenzIcon +var wurf_label: Label var timer_bid: Label var timer_boost: Label var target_row: HBoxContainer @@ -54,24 +63,44 @@ var target_row: HBoxContainer var _face := 1 -## Ein gezeichnetes Würfelbild: Augen 1-6 oder ein Fragezeichen bei unbekanntem -## Wert. Wird überall dort benutzt, wo früher Ziffern oder Sternchen standen. -## Gezeichnetes Sinnbild je Karte. Bilder als Dateien gibt es im Projekt nicht; -## alles wird zur Laufzeit gemalt, so wie die Würfel auch. -class KartenIcon extends Control: - var id := "" - var farbe := Color.WHITE +## Gezeichnete Münze. Bilder als Dateien gibt es im Projekt nicht; alles wird +## zur Laufzeit gemalt, so wie die Würfel auch. Vorn das Sinnbild der Wirkung +## (Wappen), hinten die glatte Zahlseite. +class MuenzIcon extends Control: + const RAND := Color(0.40, 0.29, 0.11) ## Prägekante + const FLAECHE := Color(0.78, 0.60, 0.26) ## Messing + const GLANZ := Color(0.91, 0.75, 0.39) ## Lichtkante + const GRAVUR := Color(0.19, 0.13, 0.04) ## eingeschlagenes Zeichen - func _init(karte: String, col: Color, px: float) -> void: - id = karte - farbe = col + var id := "" + var farbe := GRAVUR ## Zeichenfarbe, von den Zeichenhilfen benutzt + var kind_col := Color.WHITE + var seite := 0 ## 0 = Wappen (Sinnbild), 1 = Zahl + + func _init(muenze: String, col: Color, px: float) -> void: + id = muenze + kind_col = col mouse_filter = Control.MOUSE_FILTER_IGNORE custom_minimum_size = Vector2(px, px) size_flags_vertical = Control.SIZE_SHRINK_CENTER + pivot_offset = Vector2(px, px) * 0.5 func _draw() -> void: var s := minf(size.x, size.y) var m := size * 0.5 + var r := s * 0.5 + draw_circle(m, r, RAND) + draw_circle(m - Vector2(r, r) * 0.05, r * 0.95, GLANZ) + draw_circle(m, r * 0.89, FLAECHE) + # Der Farbring sagt, ob die Münze dir hilft oder den anderen schadet. + draw_arc(m, r * 0.79, 0, TAU, 40, kind_col, maxf(1.0, s * 0.05)) + if seite == 1: + draw_arc(m, r * 0.52, 0, TAU, 32, GRAVUR, maxf(1.0, s * 0.04)) + draw_circle(m, s * 0.09, GRAVUR) + return + _zeichen(m, s * 0.74) + + func _zeichen(m: Vector2, s: float) -> void: match id: "reroll_all": _wuerfel(m + Vector2(-s * 0.16, -s * 0.06), s * 0.42, 5) @@ -115,7 +144,7 @@ class KartenIcon extends Control: var punkt := kante * 0.10 var weg := kante * 0.26 for p: Vector2 in DieIcon.PIPS[augen]: - draw_circle(mitte + p * weg, punkt, Color(0.08, 0.09, 0.12)) + draw_circle(mitte + p * weg, punkt, FLAECHE) ## Offener Kreisbogen mit Spitze - das Zeichen fürs Neuwerfen. func _bogen(mitte: Vector2, r: float) -> void: @@ -152,6 +181,8 @@ class KartenIcon extends Control: draw_circle(mitte, s * 0.11, farbe) +## Ein gezeichnetes Würfelbild: Augen 1-6 oder ein Fragezeichen bei unbekanntem +## Wert. Wird überall dort benutzt, wo früher Ziffern oder Sternchen standen. class DieIcon extends Control: const PIPS := { 1: [Vector2(0, 0)], @@ -199,11 +230,24 @@ var _deadline := 0.0 ## lokaler Zeitstempel, 0 = keine Frist var _phase := "" var _my_turn := false var _pending_boost := "" +var _letzter_wurf := 0 ## Nummer des zuletzt gezeigten Münzwurfs +var _info_ctrl: Control ## worauf die Maus gerade liegt +var _zug_box := StyleBoxFlat.new() ## Hinterlegung der Zeile, die dran ist +var _leer_box := StyleBoxEmpty.new() +var _hand_sig := "" ## Abbild des Vorrats, gegen unnötigen Neuaufbau +var _mitte_sig := "" ## dasselbe für die Liste der gelegten Münzen +var _wurf_tween: Tween var _my_values: Array = [] var _my_seat := 0 func _ready() -> void: + _zug_box.bg_color = Color(0.30, 0.24, 0.07, 0.85) + _zug_box.set_corner_radius_all(6) + _zug_box.border_color = ACCENT + _zug_box.set_border_width_all(1) + _zug_box.set_content_margin_all(5) + _leer_box.set_content_margin_all(5) layer = 1 process_mode = Node.PROCESS_MODE_ALWAYS root = Control.new() @@ -300,6 +344,13 @@ func _build() -> void: _place(banner_label, 0, 0, 1, 0, edge, 20, -edge, 150) root.add_child(banner_label) + # --- Wer ist dran: direkt über der Ansageleiste, wo der Blick ohnehin liegt + turn_label = _label("", 19, ACCENT) + turn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + turn_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + _place(turn_label, 0, 1, 1, 1, 16, -146, -16, -114) + root.add_child(turn_label) + # --- Ansage: kompakte Leiste unten in der Mitte action_panel = _panel() _slot(0, 1, 1, 1, 16, -110, -16, -16).add_child(action_panel) @@ -376,20 +427,56 @@ func _build() -> void: timer_bid.vertical_alignment = VERTICAL_ALIGNMENT_CENTER av.add_child(timer_bid) - # --- Handkarten unten links, über der Ansageleiste + # --- Münzvorrat unten links, über der Ansageleiste hand_panel = _panel() _place(hand_panel, 0, 1, 0, 1, 16, -300, 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)) + hv.add_child(_label("Münzen", 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 + # --- Geworfene Münze, links unter der Liste der gelegten Münzen. Die + # Tischmitte ist besetzt: dort stehen die Becher und das Gebot. + wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 300) + wurf_slot.visible = false + var wv := VBoxContainer.new() + wv.mouse_filter = Control.MOUSE_FILTER_IGNORE + wv.add_theme_constant_override("separation", 6) + wurf_slot.add_child(wv) + wurf_icon = MuenzIcon.new("", ACCENT, 104.0) + wurf_icon.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + wv.add_child(wurf_icon) + wurf_label = _label("", 16, ACCENT) + wurf_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + wurf_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + wv.add_child(wurf_label) + + # --- Erklärung beim Überfahren. Ein eigenes Feld statt Tooltip: das kommt + # sofort und verschwindet nicht, wenn die Runde weiterläuft. + info_panel = _panel(Color(0.05, 0.06, 0.09, 0.97)) + info_panel.visible = false + info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(info_panel) + var iv := VBoxContainer.new() + iv.add_theme_constant_override("separation", 4) + info_panel.add_child(iv) + info_name = _label("", 16, ACCENT) + iv.add_child(info_name) + info_desc = _label("", 14, Color(0.82, 0.84, 0.90)) + info_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + info_desc.custom_minimum_size = Vector2(INFO_W, 0) + iv.add_child(info_desc) + info_stark = _label("", 14, ACCENT) + info_stark.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + info_stark.custom_minimum_size = Vector2(INFO_W, 0) + iv.add_child(info_stark) + boost_slot = _slot(0, 0, 1, 1, 0, 0, 0, 0) boost_slot.visible = false boost_panel = _panel(Color(0.05, 0.06, 0.09, 0.97)) @@ -397,7 +484,7 @@ func _build() -> void: var bvv := VBoxContainer.new() bvv.add_theme_constant_override("separation", 12) boost_panel.add_child(bvv) - boost_title = _label("Wähle einen Booster", 24, ACCENT) + boost_title = _label("Wähle eine Münze", 24, ACCENT) boost_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER bvv.add_child(boost_title) timer_boost = _label("", 16, DIM) @@ -554,15 +641,20 @@ func update_state(st: Dictionary, my_seat: int) -> void: var s: Dictionary = st.seats[i] var turn: bool = st.phase == "bid" and i == st.turn var col := ACCENT if turn else (Color.WHITE if s.alive else DIM) + # Wer dran ist, bekommt eine hinterlegte Zeile. Ein Pfeil und etwas + # Farbe gehen in der Liste unter. + var zeile := PanelContainer.new() + zeile.add_theme_stylebox_override("panel", _zug_box if turn else _leer_box) + players_box.add_child(zeile) var entry := VBoxContainer.new() entry.add_theme_constant_override("separation", 2) - players_box.add_child(entry) + zeile.add_child(entry) var nm := _label(("▸ " if turn else "") + _short(String(s.name)) + (" (KI)" if s.ai else ""), 14, col) nm.clip_text = true nm.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS entry.add_child(nm) if not s.alive: - entry.add_child(_label(" ausgeschieden", 13, DIM)) + entry.add_child(_label("ausgeschieden", 13, DIM)) continue var row := HBoxContainer.new() row.add_theme_constant_override("separation", 4) @@ -595,35 +687,24 @@ func update_state(st: Dictionary, my_seat: int) -> void: _zeichne_hand(st) - _clear(boosts_box) - var boosts: Array = st.get("boosts", []) - var any := false - for i in mini(boosts.size(), st.seats.size()): - if String(boosts[i]) == "": - continue - any = true - var b: Dictionary = BoosterDB.get_booster(String(boosts[i])) - var line := HBoxContainer.new() - line.add_theme_constant_override("separation", 5) - boosts_box.add_child(line) - line.add_child(_label(_short(String(st.seats[i].name)) + ":", 14, - ACCENT if i == my_seat else Color(0.78, 0.80, 0.86))) - var kind := Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5) - line.add_child(_label(String(b.get("name", "")), 14, kind)) - if int(st.min_raise) > 1: - boosts_box.add_child(_label("Störfeuer: mind. %d erhöhen" % st.min_raise, 14, ACCENT)) - any = true - if st.must_bid: - boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT)) - any = true - if not any: - boosts_box.add_child(_label("Keine Karte gespielt", 14, DIM)) + _zeichne_mitte(st, my_seat) banner_label.text = st.banner var my_turn: bool = st.phase == "bid" and st.turn == my_seat and st.seats[my_seat].alive var kam_dran := my_turn and not _my_turn _my_turn = my_turn + if st.phase == "bid" and int(st.turn) < st.seats.size(): + var wer := "%s ist dran" % _short(String(st.seats[st.turn].name)) + if my_turn: + turn_label.text = "Du bist dran" + elif not st.seats[my_seat].alive: + turn_label.text = "Du bist raus · " + wer + else: + turn_label.text = wer + turn_label.add_theme_color_override("font_color", ACCENT if my_turn else DIM) + else: + turn_label.text = "" action_panel.modulate = Color(1, 1, 1, 1.0 if my_turn else 0.45) bid_btn.disabled = not my_turn _count_max = maxi(1, int(st.total)) @@ -665,7 +746,7 @@ func _process(_delta: float) -> void: timer_bid.text = text if wants_bid else "" timer_bid.add_theme_color_override("font_color", col) # Läuft die Ansagezeit ab, während die Würfelauswahl offen steht, wird - # automatisch angesagt - die Karte bleibt dann ungespielt auf der Hand. + # automatisch angesagt - die Münze bleibt dann ungeworfen liegen. timer_boost.text = "Noch %s bis zur Ansage" % text if wants_boost else "" timer_boost.add_theme_color_override("font_color", col) @@ -698,10 +779,10 @@ func _seat_dice(st: Dictionary, seat: int, my_seat: int) -> Array: for _k in int(st.seats[seat].count): out.append(-1) var peek: Dictionary = st.peek - if peek.has(seat): - var idx := int(peek[seat][0]) + for paar: Array in peek.get(seat, []): + var idx := int(paar[0]) if idx >= 0 and idx < out.size(): - out[idx] = int(peek[seat][1]) + out[idx] = int(paar[1]) return out @@ -711,52 +792,215 @@ func set_banner(text: String) -> void: # ---------------------------------------------------------------- Booster -## Die Hand liegt offen unten links. Ein Klick spielt die Karte sofort; -## nur Karten mit eigenem Ziel fragen noch nach dem Würfel. +## Der Vorrat liegt offen unten links. Ein Klick wirft die Münze sofort; nur +## Münzen mit eigenem Ziel fragen noch nach dem Würfel. Die Wirkung steht im +## Tooltip - auf eine Münze passt nur das Sinnbild. 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 muenzen: Array = st.get("hand", []) + # Wer raus ist, schaut nur noch zu. Münzen, die er nie werfen kann, + # stünden nur klickbar im Weg. + var dabei: bool = _my_seat < st.seats.size() and st.seats[_my_seat].alive + hand_panel.visible = dabei and not muenzen.is_empty() var spielbar: bool = st.phase == "bid" - for id: String in karten: + # Jeder Zustand kommt mehrmals an. Ein Neuaufbau würde die Münze unter dem + # Zeiger wegreissen und die Erklärung mitnehmen. + var sig := "%s|%s" % [muenzen, spielbar] + if sig == _hand_sig: + return + _hand_sig = sig + _clear(hand_box) + for id: String in muenzen: var b: Dictionary = BoosterDB.get_booster(id) if b.is_empty(): continue - var buff: bool = b.kind == BoosterDB.Kind.BUFF - var col := Color(0.5, 0.9, 0.6) if buff else Color(0.95, 0.5, 0.5) + var col := _kind_col(b) var btn := Button.new() - btn.custom_minimum_size = Vector2(168, 132) - btn.tooltip_text = String(b.desc) + btn.custom_minimum_size = Vector2(104, 120) btn.disabled = not spielbar + # Ein gesperrter Knopf meldet keine Maus mehr; die Erklärung soll aber + # auch dann kommen, wenn gerade nicht geworfen werden darf. + btn.mouse_filter = Control.MOUSE_FILTER_STOP btn.pressed.connect(_pick.bind(id)) hand_box.add_child(btn) + _info_an(btn, id, false, hand_panel) var v := VBoxContainer.new() v.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - v.offset_left = 9 - v.offset_top = 7 - v.offset_right = -9 - v.offset_bottom = -7 + v.offset_top = 8 + v.offset_bottom = -6 v.mouse_filter = Control.MOUSE_FILTER_IGNORE - v.add_theme_constant_override("separation", 2) + v.add_theme_constant_override("separation", 4) btn.add_child(v) - var kopf := HBoxContainer.new() - kopf.mouse_filter = Control.MOUSE_FILTER_IGNORE - v.add_child(kopf) - kopf.add_child(KartenIcon.new(id, col, 34.0)) + var ic := MuenzIcon.new(id, col, 72.0) + ic.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + v.add_child(ic) - var name_l := _label(String(b.name), 15, col) + var name_l := _label(String(b.name), 14, col) + name_l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER name_l.mouse_filter = Control.MOUSE_FILTER_IGNORE v.add_child(name_l) - var d := _label(String(b.desc), 11, Color(0.78, 0.80, 0.86)) - d.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - d.mouse_filter = Control.MOUSE_FILTER_IGNORE - d.size_flags_vertical = Control.SIZE_EXPAND_FILL - v.add_child(d) + + +## Was liegt in der Mitte, plus die Wirkungen, die gerade auf einem liegen. +## Auch hier gilt: nur neu bauen, wenn sich etwas geändert hat. +func _zeichne_mitte(st: Dictionary, my_seat: int) -> void: + var boosts: Array = st.get("boosts", []) + # Der neueste Wurf muss bei jedem Zustand geprüft werden, auch ohne Neubau. + var neu := {} + for i in mini(boosts.size(), st.seats.size()): + var m: Dictionary = boosts[i] + if not m.is_empty() and int(m.get("nr", 0)) > int(neu.get("nr", 0)): + neu = {"nr": m.get("nr", 0), "id": m.get("id", ""), + "stark": m.get("stark", false), "seat": i} + _pruefe_wurf(neu, st) + + var sig := "%s|%s|%s" % [boosts, st.min_raise, st.must_bid] + if sig == _mitte_sig: + return + _mitte_sig = sig + _clear(boosts_box) + + var any := false + for i in mini(boosts.size(), st.seats.size()): + var m: Dictionary = boosts[i] + if m.is_empty(): + continue + var id := String(m.get("id", "")) + var b: Dictionary = BoosterDB.get_booster(id) + if b.is_empty(): + continue + any = true + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 5) + # Der Container muss die Maus annehmen, sonst gibt es kein Überfahren. + line.mouse_filter = Control.MOUSE_FILTER_STOP + boosts_box.add_child(line) + _info_an(line, id, true) + line.add_child(MuenzIcon.new(id, _kind_col(b), 22.0)) + line.add_child(_label(_short(String(st.seats[i].name)) + ":", 14, + ACCENT if i == my_seat else Color(0.78, 0.80, 0.86))) + line.add_child(_label(String(b.get("name", "")), 14, _kind_col(b))) + if bool(m.get("stark", false)): + line.add_child(_label("· Wappen", 14, ACCENT)) + if int(st.min_raise) > 1: + boosts_box.add_child(_label("Störfeuer: mind. %d erhöhen" % st.min_raise, 14, ACCENT)) + any = true + if st.must_bid: + boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT)) + any = true + if not any: + boosts_box.add_child(_label("Keine Münze in der Mitte", 14, DIM)) + + +## Erklärung zu einer Münze, sobald die Maus daraufliegt. `unter` legt sie +## unter das Feld statt darüber - oben links wäre sonst kein Platz. `anker` +## ist, woran sie sich ausrichtet: beim Vorrat das ganze Feld, sonst läge sie +## halb über dessen Überschrift. +func _info_an(ctrl: Control, id: String, unter: bool, anker: Control = null) -> void: + var an: Control = anker if anker != null else ctrl + ctrl.mouse_entered.connect(_info_zeigen.bind(ctrl, id, unter, an)) + ctrl.mouse_exited.connect(_info_weg.bind(ctrl)) + + +func _info_zeigen(ctrl: Control, id: String, unter: bool, anker: Control) -> void: + var b: Dictionary = BoosterDB.get_booster(id) + if b.is_empty(): + return + _info_ctrl = ctrl + info_panel.visible = false + info_name.text = String(b.name) + info_desc.text = String(b.desc) + info_stark.text = "Wappen: %s" % b.get("stark", "") + # Die Höhe steht erst nach dem Umbruch fest, deshalb ein Bild später + # setzen. Bis dahin bleibt das Feld unsichtbar, sonst springt es. + await get_tree().process_frame + if _info_ctrl != ctrl or not is_instance_valid(ctrl) or not is_instance_valid(anker): + return + info_panel.reset_size() + var gr := info_panel.size + var r := anker.get_global_rect() + var y: float = r.position.y + r.size.y + 8.0 if unter else r.position.y - gr.y - 8.0 + info_panel.position = Vector2( + clampf(r.position.x, 8.0, maxf(8.0, root.size.x - gr.x - 8.0)), + clampf(y, 8.0, maxf(8.0, root.size.y - gr.y - 8.0))) + info_panel.visible = true + + +func _info_weg(ctrl: Control) -> void: + if _info_ctrl != ctrl: + return + _info_ctrl = null + info_panel.visible = false + + +func _kind_col(b: Dictionary) -> Color: + return Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5) + + +## Jeder Wurf hat eine laufende Nummer. Steigt sie, ist eine neue Münze +## gefallen und wird gezeigt. Zu Rundenbeginn ist die Mitte leer, dann faengt +## die Zählung wieder bei null an. +func _pruefe_wurf(neu: Dictionary, st: Dictionary) -> void: + var nr := int(neu.get("nr", 0)) + if nr == 0: + _letzter_wurf = 0 + return + if nr <= _letzter_wurf: + return + _letzter_wurf = nr + var seat := int(neu.get("seat", 0)) + _zeig_wurf(String(neu.get("id", "")), bool(neu.get("stark", false)), + _short(String(st.seats[seat].name))) + + +## Der Ausgang steht schon fest - der Host hat ihn ausgewürfelt. Die Drehung +## ist nur die Ansicht dazu und endet auf der richtigen Seite. +func _zeig_wurf(id: String, stark: bool, wer: String) -> void: + var b: Dictionary = BoosterDB.get_booster(id) + if b.is_empty(): + return + wurf_icon.id = id + wurf_icon.kind_col = _kind_col(b) + wurf_icon.seite = 0 + wurf_icon.scale = Vector2.ONE + wurf_icon.queue_redraw() + wurf_label.text = "%s wirft\n%s" % [wer, b.name] + wurf_label.add_theme_color_override("font_color", DIM) + wurf_slot.visible = true + if _wurf_tween != null and _wurf_tween.is_valid(): + _wurf_tween.kill() + _wurf_tween = create_tween() + _wurf_tween.tween_method(_dreh, 0.0, TAU * 2.5, 0.8) + _wurf_tween.tween_callback(_wurf_ende.bind(stark, b)) + _wurf_tween.tween_interval(1.6) + _wurf_tween.tween_callback(func() -> void: wurf_slot.visible = false) + + +## Die Münze wird waagerecht gestaucht; bei negativer Breite sieht man die +## Rückseite. Das ist eine Drehung, ohne dass etwas in 3D stehen muss. +func _dreh(winkel: float) -> void: + var c := cos(winkel) + wurf_icon.scale.x = maxf(0.06, absf(c)) + var seite := 0 if c >= 0.0 else 1 + if wurf_icon.seite != seite: + wurf_icon.seite = seite + wurf_icon.queue_redraw() + + +func _wurf_ende(stark: bool, b: Dictionary) -> void: + wurf_icon.scale.x = 1.0 + wurf_icon.seite = 0 if stark else 1 + wurf_icon.queue_redraw() + if stark: + wurf_label.text = "%s\nWappen - verstärkt" % b.name + wurf_label.add_theme_color_override("font_color", ACCENT) + else: + wurf_label.text = "%s\nZahl - Grundwirkung" % b.name + wurf_label.add_theme_color_override("font_color", DIM) func _pick(id: String) -> void: diff --git a/scripts/table.gd b/scripts/table.gd index 887c27d..e27eaf7 100644 --- a/scripts/table.gd +++ b/scripts/table.gd @@ -8,6 +8,8 @@ const WALL_SEGS := 20 ## Bandensegmente - Godot kennt keinen hohlen Zylin const WALL_H := 0.55 ## unsichtbare Begrenzung, damit nichts herausspringt const SEAT_ARC := ZONE_RAD * 2.0 + 0.85 ## Bogenlänge, die ein Platz am Tischrand braucht const MIN_ZONE_R := 1.15 +const RIM_RUHE := Color(0.22, 0.15, 0.09) ## Zonenrand, wenn der Platz wartet +const RIM_ZUG := Color(0.85, 0.66, 0.22) ## Zonenrand des Platzes, der dran ist const TILT_DEG := 15.0 ## Neigung der Kamera gegen die Senkrechte const HUD_TOP := 0.13 ## Bildanteil oben für die Gebotsanzeige const HUD_BOTTOM := 0.17 ## Bildanteil unten für die Ansage-Leiste @@ -27,6 +29,7 @@ var bid_counts: Array[Label3D] = [] var bid_icons: Array[Sprite3D] = [] var _bid_anchor: Array[Vector3] = [] var plates: Array[Sprite3D] = [] +var rims: Array[MeshInstance3D] = [] ## Rand der Zone, leuchtet beim Zug var dice: Array = [] ## seat -> Array[Die] var camera: Camera3D @@ -172,8 +175,9 @@ func _build_seat(i: int) -> void: tm.ring_segments = 8 rim.mesh = tm rim.position = Vector3(0, 0.02, 0) - rim.material_override = _mat(Color(0.22, 0.15, 0.09), 0.6) + rim.material_override = _mat(RIM_RUHE, 0.6) zone.add_child(rim) + rims.append(rim) # Becher var cup := Node3D.new() @@ -393,17 +397,26 @@ func _mat(col: Color, rough: float) -> StandardMaterial3D: ## --- Würfel --- +## Nur die neu dazugekommenen Würfel werden hingelegt. Die schon liegenden +## bleiben, wie sie sind - ein Nachschub mitten in der Runde würde ihnen sonst +## die Drehung nehmen und damit auf dem Tisch die Augenzahl. func set_dice_count(seat: int, count: int) -> void: var arr: Array = dice[seat] while arr.size() > count: var d = arr.pop_back() d.queue_free() + # Ein neuer Würfel darf nicht sichtbar sein, wenn die anderen unterm + # Becher liegen - sonst verrät er die Hand des Gegners. + var sichtbar: bool = arr.is_empty() or arr[0].visible + var ab := arr.size() while arr.size() < count: var d := Die.new() add_child(d) d.global_position = zones[seat].global_position + Vector3(0, 0.4, 0) + d.visible = sichtbar arr.append(d) - _layout_idle(seat) + if arr.size() > ab: + _layout_idle(seat, ab) func dice_of(seat: int) -> Array: @@ -426,8 +439,9 @@ func throw_dice(seat: int, rng: RandomNumberGenerator) -> void: arr[i].throw_from(zone.global_transform * off, rng) -## Legt die Würfel ordentlich nebeneinander (für Clients/Reveal). -func _layout_idle(seat: int) -> void: +## Legt die Würfel ordentlich nebeneinander (für Clients/Reveal). Ab `von` +## bleiben die davorliegenden unangetastet. +func _layout_idle(seat: int, von := 0) -> void: var zone: Node3D = zones[seat] var arr: Array = dice[seat] # Bogen auf einem Kreis um die Zonenmitte - passt zur runden Bande. @@ -435,7 +449,7 @@ func _layout_idle(seat: int) -> void: # Würfels plus Luft, sonst überlappen die Bilder beim Aufdecken. const BOGEN_R := 0.48 # etwas weiter aussen, damit das Namensschild frei bleibt var schritt: float = (Die.SIZE * 1.7) / BOGEN_R - for i in arr.size(): + for i in range(von, arr.size()): var ang := PI + (float(i) - (arr.size() - 1) * 0.5) * schritt var t := zone.global_transform t.origin = zone.global_transform * Vector3( @@ -458,6 +472,19 @@ func freeze_dice(seat: int) -> void: d.set_kinematic(true) +## Der Platz, der dran ist, bekommt einen leuchtenden Rand. In der dunklen +## Szene ist das von jeder Ecke aus zu sehen - ein farbiger Name allein nicht. +func set_active_seat(seat: int) -> void: + for i in rims.size(): + var m: StandardMaterial3D = rims[i].material_override + var dran: bool = i == seat + m.albedo_color = RIM_ZUG if dran else RIM_RUHE + m.emission_enabled = dran + if dran: + m.emission = Color(1.0, 0.78, 0.30) + m.emission_energy_multiplier = 1.1 + + func set_dice_visible(seat: int, on: bool) -> void: for d in dice[seat]: d.visible = on