From 7979d5c336c052b0e7bfb94097c62a42c3dae6fa Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 16 Sep 2026 12:09:47 +0200 Subject: [PATCH] update --- README.md | 99 +++++++---- project.godot | 1 + scripts/ai.gd | 95 +++++++---- scripts/boosters.gd | 110 +++++-------- scripts/die.gd | 22 ++- scripts/game.gd | 388 +++++++++++++++++++++++++++++--------------- scripts/hud.gd | 231 ++++++++++++++++++-------- scripts/klang.gd | 85 ++++++++++ scripts/main.gd | 6 + scripts/menu.gd | 56 ++++++- scripts/net.gd | 16 +- scripts/table.gd | 82 ++++++++++ test_cli.gd | 81 +++++++++ test_cli.tscn | 6 + test_srv.gd | 32 ++++ test_srv.tscn | 6 + 16 files changed, 974 insertions(+), 342 deletions(-) create mode 100644 scripts/klang.gd create mode 100644 test_cli.gd create mode 100644 test_cli.tscn create mode 100644 test_srv.gd create mode 100644 test_srv.tscn diff --git a/README.md b/README.md index dd07908..b75d753 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,20 @@ 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. +**Genau:** Wer glaubt, die Ansage trifft exakt, ruft „Genau" statt +anzufechten. Trifft es, verliert der Ansager einen Würfel, der Rufer bekommt +einen dazu und nimmt den Topf. Daneben verliert der Rufer einen Würfel. Der +Schwung ist damit immer zwei - doppelt so viel wie eine Anfechtung, aber +unabhängig von der Spielerzahl. + +Aufgedeckt wird in drei Etappen: Becher hoch, dann zählen, dann das Urteil. +Beim Zählen leuchten die passenden Würfel nacheinander auf. So baut sich die +Spannung auf, statt dass alles auf einmal dasteht. + +Nach dem Ende gibt es solo ein **Nochmal** mit denselben Bots. Im Netz führt +der Weg zurück ins Menü. + +Wer keine Würfel mehr hat, scheidet aus und schaut zu. Der Letzte gewinnt. ## Münzen @@ -156,48 +169,77 @@ Münzen ersetzen die frühere Booster-Phase. Die kostete jede Runde eine eigene Wartezeit und riss den Ablauf auseinander. * Jeder startet mit **einer** Münze. -* Wer danach einen Würfel abgibt, **bekommt eine Münze**. Das ist der einzige +* Wer einen Würfel abgibt, **bekommt eine Münze**. Das ist der einzige frische 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. +* Geworfen wird **nur am eigenen Zug, vor der Ansage**. So ist der Zug eine + Entscheidung, und zwei Würfe kommen sich nie in die Quere. +* Mehr als **vier** 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. + *Falschspiel* fragt noch, welcher Würfel gemeint ist. + +### Der Topf + +Eine geworfene Münze landet als Messingstück in der Tischmitte. **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. Mehr als sechs liegen nie +dort; die älteste fällt vom Tisch, sonst entstünde ein Haufen, den keiner +mehr abholt. Damit kostet jede Münze +etwas: wer wirft und dann verliert, schenkt sie dem Gegner. Und das Aufdecken +hat einen Einsatz, der über den einen Würfel hinausgeht. ### Der Wurf -Beim Ausspielen wird die Münze geworfen und landet in der Mitte. +Beim Werfen entscheidet die Seite. * **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. +Die Grundwirkung kommt immer. Kein Zug ist verschenkt. Der Ausgang wird beim +Host ausgewürfelt und mit dem Zustand verschickt; die Drehung im HUD ist nur +die Ansicht dazu. Würfelphysik wäre hier nicht synchron zu bekommen. -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. +### Die sechs Münzen + +Jede Münze tut genau eins: Wissen geben, Druck machen oder der eigenen Hand +nachhelfen. **Keine würfelt an den Gegnern herum.** Nebel, Absenken und +Neuwurf sind deshalb raus - sie nahmen dem Lesen der anderen den Sinn. + +| Münze | Art | Zahl | Wappen | Braucht Ansage | +|---|---|---|---|---| +| Spähen | Wissen | Von jedem Gegner wird ein zufälliger Würfel aufgedeckt | Zwei je Gegner | nein | +| Zählen | Wissen | Ein Fenster von drei, in dem die Zahl der angesagten Augenzahl liegt | Die genaue Zahl | ja | +| Falschspiel | Hand | Ein gewählter Würfel zeigt die angesagte Augenzahl | Ein zweiter, zufälliger auch | ja | +| Nachschub | Hand | Du bekommst einen Würfel dazu | Der neue zeigt sofort eine 6 | nein | +| Erhöhen | Druck | Die laufende Ansage steigt um 1; der Ansager steht dafür gerade | Um 2 | ja | +| Maulkorb | Druck | Der nächste Spieler muss diese Runde bieten, darf nicht anfechten | Alle Gegner | nein | **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. +das ist seine Grundwirkung, und Wappen macht daraus nur eine 6. -| 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 | +### Anzeige -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. +* Oben rechts steht der **Gebotsverlauf** der Runde. Bluffs liest man daran, + wer wie schnell hochgegangen ist - nicht am letzten Gebot allein. +* Wer dran ist, hat einen leuchtenden Zonenrand, eine hinterlegte Zeile in + der Spielerliste und eine Zeile über der Ansageleiste. +* Über einer Münze erscheint sofort ihre Erklärung. Sie bleibt, solange die + Maus darauf liegt - auch wenn die Runde weiterläuft. +* Klänge (Würfel, Münze, Becher, Gewinn) werden beim Start berechnet. Es gibt + keine Tondateien, so wie es auch keine Bilddateien gibt. +* Die Regeln stehen im Menü unter **Regeln**. + +### Bots + +Drei Stufen, im Menü wählbar: **leicht** ficht voreilig an und bietet +riskant, **schwer** bluffet öfter und zweifelt nur, wenn es sicher ist. + +Bots rechnen mit der Binomialverteilung, ob eine Ansage hält. Aber sie +bluffen auch: fehlt ein sicheres Gebot, setzen sie in 40 % der Fälle das +kleinste gültige, statt anzufechten. Und sie werfen Münzen mit Absicht - +Nachschub, wenn sie hinten liegen; Zählen oder Spähen, wenn es knapp ist; +Erhöhen vor dem Anfechten; Maulkorb vor dem eigenen Bluff. Liegt die Ansage +vermutlich haarscharf, rufen sie „Genau". ## Aufbau @@ -210,6 +252,7 @@ Verstärkung sind der Kern; die Tabelle ist die aktuelle Füllung. | `scripts/die.gd` | Physik-Würfel, Ablesen der Augenzahl aus der Ruhelage | | `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) | | `scripts/hud.gd` | Spiel-UI | +| `scripts/klang.gd` | Autoload: berechnete Klänge, keine Dateien | | `scripts/menu.gd` | Startmenü und Lobby | Die gesamte Spiellogik und die Würfelphysik laufen beim Host. Clients erhalten diff --git a/project.godot b/project.godot index 51eaa30..6673812 100644 --- a/project.godot +++ b/project.godot @@ -11,6 +11,7 @@ config/features=PackedStringArray("4.7", "Mobile") BoosterDB="*res://scripts/boosters.gd" Net="*res://scripts/net.gd" +Klang="*res://scripts/klang.gd" [display] diff --git a/scripts/ai.gd b/scripts/ai.gd index 4668b74..9973bfa 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -30,13 +30,43 @@ static func _own(values: Array, face: int) -> int: return c +## Wahrscheinlichkeit, dass ein Gebot (count, face) genau stimmt. +static func exact_chance(values: Array, unknown: int, count: int, face: int) -> float: + var k := count - _own(values, face) + if k < 0 or k > unknown: + return 0.0 + return _pmf(unknown, k, 1.0 / 6.0) + + ## Wahrscheinlichkeit, dass ein Gebot (count, face) hält. static func chance(values: Array, unknown: int, count: int, face: int) -> float: return tail(unknown, count - _own(values, face), 1.0 / 6.0) +## 0 = leicht, 1 = normal, 2 = schwer. Das Menü setzt es vor dem Start. +static var stufe := 1 + +## Je Stufe vier Regler: +## bluff - wie oft er blufft, statt ein glaubwürdiges Gebot anzufechten. +## Ohne das wäre er ein reiner Rechner, und den liest man. +## genau - ab welcher Trefferchance er "Genau" ruft. +## zweifel - unter welcher Haltechance er anficht. Hoch heißt: voreilig. +## sicher - welche Chance ein eigenes Gebot mindestens haben muss. +const STUFEN := [ + {"bluff": 0.15, "genau": 0.35, "zweifel": 0.40, "sicher": 0.30}, + {"bluff": 0.40, "genau": 0.22, "zweifel": 0.30, "sicher": 0.42}, + {"bluff": 0.50, "genau": 0.18, "zweifel": 0.24, "sicher": 0.50}, +] +## Wie oft er ein sicheres Gebot einen Schritt höher setzt, als nötig wäre. +const FRECH := 0.12 + + +static func _regler() -> Dictionary: + return STUFEN[clampi(stufe, 0, STUFEN.size() - 1)] + + static func choose_action(values: Array, total_dice: int, bid: Dictionary, - min_raise: int, must_bid: bool, rng: RandomNumberGenerator) -> Dictionary: + must_bid: bool, rng: RandomNumberGenerator) -> Dictionary: var known: Array = [] for v in values: if v > 0: @@ -47,26 +77,40 @@ static func choose_action(values: Array, total_dice: int, bid: Dictionary, var op := _opening(known, unknown, rng) return {"type": "bid", "count": op.count, "face": op.face} + var r := _regler() var hold := chance(known, unknown, bid.count, bid.face) - var raise_ = _best_raise(known, unknown, total_dice, bid, min_raise) + var raise_ = _best_raise(known, unknown, total_dice, bid, float(r.sicher)) + var low := lowest_raise(bid, total_dice) if not must_bid: - var doubt := 0.30 + rng.randf_range(-0.07, 0.07) + # Liegt die Ansage vermutlich haarscharf, lohnt "Genau" mehr als die + # Anfechtung: der Ansager verliert, und man bekommt selbst einen dazu. + if exact_chance(known, unknown, bid.count, bid.face) > float(r.genau) and hold < 0.6: + return {"type": "exact"} + var doubt := float(r.zweifel) + rng.randf_range(-0.07, 0.07) if hold < doubt and (raise_.is_empty() or raise_.p < 0.32): return {"type": "challenge"} if raise_.is_empty(): - if must_bid: - # Notgebot: minimal erhöhen, auch wenn unwahrscheinlich - var c: int = int(bid.count) + maxi(1, min_raise) - if c <= total_dice: - return {"type": "bid", "count": c, "face": bid.face} - if min_raise <= 1 and int(bid.face) < 6: - return {"type": "bid", "count": int(bid.count), "face": int(bid.face) + 1} - # Es gibt kein gültiges Gebot mehr - dann bleibt nur die Anfechtung. + # Kein sicheres Gebot mehr. Anfechten wäre ehrlich - aber wer immer + # ehrlich ist, wird gelesen. Also ab und zu bluffen, solange es noch + # ein gültiges Gebot gibt. + if not low.is_empty() and (must_bid or rng.randf() < float(r.bluff)): + return {"type": "bid", "count": low.count, "face": low.face} return {"type": "challenge"} + if rng.randf() < FRECH and int(raise_.count) + 1 <= total_dice: + return {"type": "bid", "count": int(raise_.count) + 1, "face": raise_.face} return {"type": "bid", "count": raise_.count, "face": raise_.face} +## Das kleinste Gebot, das noch über dem liegenden steht ({} = keins mehr). +static func lowest_raise(bid: Dictionary, total: int) -> Dictionary: + if int(bid.face) < 6: + return {"count": int(bid.count), "face": int(bid.face) + 1} + if int(bid.count) + 1 <= total: + return {"count": int(bid.count) + 1, "face": 1} + return {} + + static func _opening(known: Array, unknown: int, rng: RandomNumberGenerator) -> Dictionary: var best_face := rng.randi_range(1, 6) var best := -1 @@ -79,14 +123,15 @@ static func _opening(known: Array, unknown: int, rng: RandomNumberGenerator) -> return {"count": count, "face": best_face} -static func _best_raise(known: Array, unknown: int, total: int, bid: Dictionary, min_raise: int) -> Dictionary: +static func _best_raise(known: Array, unknown: int, total: int, bid: Dictionary, + sicher: float) -> Dictionary: var best := {} for face in range(1, 7): for count in range(1, total + 1): - if not _is_higher(count, face, bid, min_raise): + if not _is_higher(count, face, bid): continue var p := chance(known, unknown, count, face) - if p < 0.42: + if p < sicher: continue if best.is_empty() or count < best.count or (count == best.count and p > best.p): best = {"count": count, "face": face, "p": p} @@ -94,22 +139,16 @@ static func _best_raise(known: Array, unknown: int, total: int, bid: Dictionary, return best -static func _is_higher(count: int, face: int, bid: Dictionary, min_raise: int) -> bool: - if count >= bid.count + min_raise: +static func _is_higher(count: int, face: int, bid: Dictionary) -> bool: + if count > bid.count: return true - if min_raise > 1: - return false return count == bid.count and face > bid.face -## 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 - # Der schwächste Würfel: den neu zu werfen oder zur 6 zu machen bringt - # am meisten. - var idx := 0 +## Welcher eigene Würfel soll umgedreht werden? Einer, der die Augenzahl +## noch nicht zeigt - sonst wäre die Münze verschenkt. +static func target_for(values: Array, face: int) -> int: for i in values.size(): - if values[i] < values[idx]: - idx = i - return idx + if values[i] != face: + return i + return 0 diff --git a/scripts/boosters.gd b/scripts/boosters.gd index d686340..98d053c 100644 --- a/scripts/boosters.gd +++ b/scripts/boosters.gd @@ -1,91 +1,66 @@ extends Node ## Katalog aller Münzen. ## -## 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. +## Sechs Stück, jede mit einem klaren Zweck: entweder sie gibt Wissen, oder sie +## macht Druck, oder sie hilft der eigenen Hand nach. Keine würfelt an den +## Gegnern herum - das Lesen der anderen bleibt der Kern des Spiels. ## -## 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. +## Geworfen wird nur am eigenen Zug, vor der Ansage. Die Münze landet im Topf +## in der Mitte. Wer das Aufdecken gewinnt, nimmt den Topf. Damit kostet jede +## Münze etwas, und das Aufdecken hat einen Einsatz. +## +## Beim Werfen entscheidet die Seite: Wappen verstärkt, Zahl lässt es bei der +## Grundwirkung. Die Grundwirkung kommt immer - kein Zug ist verschenkt. enum Kind { BUFF, DEBUFF } -## Mehr als das passt nicht auf die Hand; sonst hortet man bis zum Schluss. -const HANDLIMIT := 3 +## Mehr als das passt nicht in den Vorrat; sonst hortet man bis zum Schluss. +const HANDLIMIT := 4 +## Mehr liegt nie im Topf. Die älteste Münze fällt vom Tisch, sonst wächst ein +## Haufen, den keiner mehr abholen kann. +const TOPF_MAX := 6 ## 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. +## `target`: fragt nach einem eigenen Würfel. `gebot`: geht nur, wenn eine +## Ansage auf dem Tisch liegt. 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, + "id": "peek", "kind": Kind.BUFF, "target": false, "gebot": 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, + "id": "count", "kind": Kind.BUFF, "target": false, "gebot": true, + "name": "Zählen", + "desc": "Du erfährst ungefähr, wie oft die angesagte Augenzahl am Tisch liegt.", + "stark": "Du erfährst die genaue Zahl.", + }, + { + "id": "set_face", "kind": Kind.BUFF, "target": true, "gebot": true, + "name": "Falschspiel", + "desc": "Ein gewählter Würfel zeigt die angesagte Augenzahl.", + "stark": "Ein zweiter, zufälliger Würfel auch.", + }, + { + "id": "gain_die", "kind": Kind.BUFF, "target": false, "gebot": false, + "name": "Nachschub", + "desc": "Du bekommst einen Würfel dazu.", + "stark": "Der neue Würfel zeigt sofort eine 6.", + }, + { + "id": "bid_up", "kind": Kind.DEBUFF, "target": false, "gebot": true, "name": "Erhöhen", - "desc": "Die laufende Ansage steigt um 1.", + "desc": "Die laufende Ansage steigt um 1. Der Ansager muss dafür geradestehen.", "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, + "id": "gag", "kind": Kind.DEBUFF, "target": false, "gebot": false, "name": "Maulkorb", - "desc": "Wer gerade dran ist, muss bieten und darf nicht anfechten.", - "stark": "Alle Gegner müssen bieten.", + "desc": "Der nächste Spieler muss diese Runde bieten und darf nicht anfechten.", + "stark": "Das gilt für alle Gegner.", }, ] @@ -101,9 +76,8 @@ func needs_target(id: String) -> bool: return get_booster(id).get("target", false) -## 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) +func needs_bid(id: String) -> bool: + return get_booster(id).get("gebot", false) ## Eine zufällige Münze, für den, der gerade einen Würfel verloren hat. diff --git a/scripts/die.gd b/scripts/die.gd index bdc2f93..1a633d1 100644 --- a/scripts/die.gd +++ b/scripts/die.gd @@ -33,6 +33,8 @@ const PIPS := { # damit ein Würfel nur ein einziges Renderobjekt ist. static var _mesh: ArrayMesh static var _mat: StandardMaterial3D +static var _gold: StandardMaterial3D ## fürs Zählen beim Aufdecken +var _body: MeshInstance3D func _ready() -> void: @@ -125,10 +127,22 @@ func _build_visuals() -> void: # liegt der Glanzpunkt genau auf der Fläche und hellt die Augen auf. _mat.roughness = 0.9 _mat.metallic_specular = 0.0 - var body := MeshInstance3D.new() - body.mesh = _mesh - body.material_override = _mat - add_child(body) + if _gold == null: + # Vertexfarben bleiben, werden nur gold getönt - die Augen bleiben dunkel. + _gold = _mat.duplicate() + _gold.albedo_color = Color(1.0, 0.82, 0.35) + _gold.emission_enabled = true + _gold.emission = Color(0.9, 0.65, 0.2) + _gold.emission_energy_multiplier = 0.5 + _body = MeshInstance3D.new() + _body.mesh = _mesh + _body.material_override = _mat + add_child(_body) + + +## Beim Zählen leuchtet jeder passende Würfel kurz auf. +func hervorheben(on: bool) -> void: + _body.material_override = _gold if on else _mat # ------------------------------------------------------------ Zustand diff --git a/scripts/game.gd b/scripts/game.gd index 315877f..3a5ff2b 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -3,7 +3,11 @@ extends Node3D const START_DICE := 5 const BID_TIMEOUT := 30.0 -const REVEAL_PAUSE := 5.0 ## Anzeigedauer des Rundenergebnisses +## 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 @@ -20,14 +24,16 @@ var my_values: Array = [] ## Client-Sicht (-1 = vernebelt) var bid: Dictionary = {} var turn := 0 var starter := 0 -var fogged: Dictionary = {} ## seat -> Array[int] verdeckter Würfel var peeked: Dictionary = {} ## viewer -> {target_seat: Array[[idx, value]]} -var min_raise: Dictionary = {} ## seat -> int +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 mitte: Dictionary = {} ## seat -> zuletzt in die Mitte gelegte Münze +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, damit das HUD neue erkennt +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 last_bids: Dictionary = {} ## seat -> letzte Ansage dieser Runde var log_lines: Array = [] var phase := "lobby" @@ -48,6 +54,7 @@ func _ready() -> void: func setup(cfg: Dictionary) -> void: + DiceAI.stufe = int(cfg.get("stufe", 1)) seats.clear() var peers: Array = cfg.peers for p in peers: @@ -76,6 +83,8 @@ func setup(cfg: Dictionary) -> void: 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) @@ -200,13 +209,11 @@ func _phase_roll() -> void: deadline = 0.0 reveal_total = 0 bid = {} - fogged.clear() last_bids.clear() peeked.clear() - min_raise.clear() + gezaehlt.clear() + verlauf.clear() must_bid.clear() - mitte.clear() - _wurf_nr = 0 banner = "" _log("--- Neue Runde ---") @@ -275,14 +282,16 @@ func _zieh_muenze(seat: int) -> void: _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. +## 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 not seats[seat].alive: + if seat < 0 or phase != "bid" or seat != turn or _answered.has(seat): return - # Zwei Münzen gleichzeitig würden sich beim Würfeln in die Quere kommen. 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 @@ -292,7 +301,10 @@ func _do_play(seat: int, id: String, target: int) -> void: var stark := rng.randf() < BoosterDB.WAPPEN_CHANCE _wurf_nr += 1 - mitte[seat] = {"id": id, "stark": stark, "nr": _wurf_nr} + 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"]) @@ -305,12 +317,6 @@ func _do_play(seat: int, id: String, target: int) -> void: _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() @@ -319,15 +325,21 @@ 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_und_zufall(seat, ziel, stark)) - "set_six": + "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, 6) + _set_die(seat, k, int(bid.face)) "gain_die": seats[seat].count += 1 table.set_dice_count(seat, seats[seat].count) @@ -336,46 +348,26 @@ func _wirkung(seat: int, id: String, target: int, stark: bool) -> void: await _neu_werfen(seat, [neu]) if stark: _set_die(seat, neu, 6) - "cancel": - fogged.erase(seat) - min_raise.erase(seat) - must_bid.erase(seat) - "peek": - var info := {} - for o in _alive_seats(): - 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) + (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: - 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: - 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 - "raise_min": - for o in _alive_seats(): - if o != seat: - min_raise[o] = 3 if stark else 2 + 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[turn] = true + 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. @@ -453,12 +445,15 @@ func _phase_bid() -> Dictionary: _push_state() if seats[turn].ai: await _sleep(1.1) - # Bots werfen ihre Münzen, bevor sie ansagen. - await _bot_muenze(turn) + # 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 a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, - min_raise.get(turn, 1), must_bid.get(turn, false), rng) var res := _apply_action(turn, a) if not res.is_empty(): return res @@ -471,7 +466,7 @@ func _phase_bid() -> Dictionary: else: # Zeit abgelaufen oder getrennt: automatisch entscheiden var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, - min_raise.get(turn, 1), must_bid.get(turn, false), rng) + 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(): @@ -480,24 +475,66 @@ func _phase_bid() -> Dictionary: return {} -## Ein Bot wirft manchmal eine Münze, statt sie bis zum Ausscheiden zu horten. -func _bot_muenze(seat: int) -> void: +## 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 - # 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 = 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])) + 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. @@ -513,22 +550,17 @@ func _apply_action(seat: int, a: Dictionary) -> Dictionary: 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: +func _lowest_legal_bid(_seat: int) -> Dictionary: if bid.is_empty(): return {"count": 1, "face": 1} - var mr: int = min_raise.get(seat, 1) - if mr == 1 and int(bid.face) < 6: - return {"count": int(bid.count), "face": int(bid.face) + 1} - var c: int = int(bid.count) + mr - if c <= _total_dice(): - return {"count": c, "face": 1} - return {} + return DiceAI.lowest_raise(bid, _total_dice()) func _valid_bid(seat: int, count: int, face: int) -> bool: @@ -536,11 +568,8 @@ func _valid_bid(seat: int, count: int, face: int) -> bool: return false if bid.is_empty(): return true - var mr: int = min_raise.get(seat, 1) - if count >= bid.count + mr: + if count > bid.count: return true - if mr > 1: - return false return count == bid.count and face > bid.face @@ -548,19 +577,13 @@ func _phase_reveal(result: Dictionary) -> void: phase = "reveal" deadline = 0.0 var b: Dictionary = result.bid - var challenger: int = result.challenger - var total := 0 - for i in _alive_seats(): - for v in host_values[i]: - if v == b.face: - total += 1 - - reveal_total = total - var loser := challenger if total >= b.count else int(b.seat) - _log("%d x %d angesagt - tatsächlich %d. %s verliert einen Würfel." % [ - b.count, b.face, total, seats[loser].name]) - banner = "%s verliert" % seats[loser].name + 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) @@ -572,20 +595,64 @@ func _phase_reveal(result: Dictionary) -> void: if _peer_live(peer): _reveal.rpc_id(peer, values, banner) _push_state() - await _sleep(REVEAL_PAUSE) + await _sleep(ZEIT_BECHER) if not _alive(): return - seats[loser].count -= 1 - 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) + # 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: - _zieh_muenze(loser) - starter = loser if seats[loser].alive else _next_alive(loser) + 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(): @@ -595,6 +662,22 @@ func _phase_reveal(result: Dictionary) -> void: _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 + 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(): @@ -715,15 +798,9 @@ func _push_state() -> void: _state.rpc_id(peer, _state_for(i)) -## Welche Münze liegt von wem in der Mitte? Beim Würfeln steht noch nichts -## an, danach ist es offen. +## Was liegt im Topf? Der bleibt auch übers Würfeln hinweg liegen. func _round_boosters() -> Array: - var out: Array = [] - if phase == "roll": - return out - for i in seats.size(): - out.append(mitte.get(i, {})) - return out + return topf.duplicate(true) ## Was jeder Platz in dieser Runde zuletzt angesagt hat. @@ -739,25 +816,22 @@ func _state_for(seat: int) -> Dictionary: if phase == "roll": for _k in seats[seat].count: # während des Wurfs steht der Wert noch nicht fest vis.append(-1) - elif phase == "reveal": - vis = host_values[seat].duplicate() # aufgedeckt wird ohne Nebel else: - var fg: Array = fogged.get(seat, []) - for k in host_values[seat].size(): - vis.append(-1 if fg.has(k) else host_values[seat][k]) + 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, {}), "min_raise": min_raise.get(seat, 1), + "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), "hand": (hand.get(seat, []) as Array).duplicate(), } @@ -791,7 +865,23 @@ func _apply_state(st: Dictionary) -> void: table.set_cup_down(i, seats[i].alive) table.set_dice_visible(local_seat, true) _refresh_labels() - _apply_fog_to_table() + table.set_topf(st.get("boosts", [])) + 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] @@ -804,15 +894,6 @@ func _apply_state(st: Dictionary) -> void: # ---------------------------------------------------------------- Aktionen -## Der Nebel-Debuff muss auch den Würfel auf dem Tisch verdecken. -func _apply_fog_to_table() -> void: - if phase == "roll" or local_seat < 0 or local_seat >= table.dice.size(): - return - var dl: Array = table.dice_of(local_seat) - for i in mini(dl.size(), my_values.size()): - dl[i].visible = my_values[i] >= 0 - - func _on_bid_pressed(count: int, face: int) -> void: if multiplayer.is_server(): _do_bid(local_seat, count, face) @@ -827,6 +908,13 @@ func _on_challenge_pressed() -> void: _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) @@ -849,6 +937,19 @@ func _on_pause_changed(on: bool) -> void: _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 @@ -871,6 +972,11 @@ 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) @@ -904,3 +1010,15 @@ func _do_challenge(seat: int) -> void: 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"}) + diff --git a/scripts/hud.gd b/scripts/hud.gd index 0761a39..ca0af51 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -3,6 +3,8 @@ extends CanvasLayer signal bid_pressed(count: int, face: int) signal challenge_pressed +signal exact_pressed +signal rematch_pressed signal booster_chosen(id: String, target: int) signal quit_pressed signal pause_changed(paused: bool) @@ -33,17 +35,22 @@ var _count_min := 1 var _count_max := 30 var _bid_count := 0 ## aktuelles Gebot, 0 = noch keins var _bid_face := 0 -var _min_raise := 1 var _actual := 0 ## beim Aufdecken: tatsächlich gefundene Würfel var face_buttons: Array[Button] = [] var bid_btn: Button var challenge_btn: Button +var exact_btn: Button +var verlauf_box: VBoxContainer +var _verlauf_sig := "" ## Abbild des Gebotsverlaufs, gegen unnötigen Neuaufbau var action_panel: PanelContainer var hand_panel: PanelContainer var hand_box: HBoxContainer var boost_slot: CenterContainer var pause_slot: Control +var over_slot: Control +var over_title: Label +var over_again: Button var pause_hint: Label var boost_panel: PanelContainer var boost_cards: HBoxContainer @@ -102,34 +109,17 @@ class MuenzIcon extends Control: func _zeichen(m: Vector2, s: float) -> void: match id: - "reroll_all": - _wuerfel(m + Vector2(-s * 0.16, -s * 0.06), s * 0.42, 5) - _wuerfel(m + Vector2(s * 0.18, s * 0.10), s * 0.42, 2) - "reroll_one": - _wuerfel(m + Vector2(0, s * 0.10), s * 0.38, 3) - _bogen(m + Vector2(0, s * 0.10), s * 0.34) - "set_six": - _wuerfel(m, s * 0.54, 6) + "peek": + _auge(m, s) + "count": + _striche(m, s) + "set_face": + _schiefer_wuerfel(m, s * 0.50) "gain_die": _wuerfel(m + Vector2(-s * 0.10, 0), s * 0.46, 4) _plus(m + Vector2(s * 0.26, -s * 0.20), s * 0.20) - "cancel": - draw_arc(m, s * 0.30, 0, TAU, 32, farbe, s * 0.08) - draw_line(m + Vector2(-s * 0.21, -s * 0.21), m + Vector2(s * 0.21, s * 0.21), - farbe, s * 0.08) - "peek": - _auge(m, s) "bid_up": _pfeil(m, s * 0.52, true) - "raise_min": - _pfeil(m + Vector2(-s * 0.14, 0), s * 0.44, true) - _pfeil(m + Vector2(s * 0.14, 0), s * 0.44, true) - "enemy_lower": - _pfeil(m, s * 0.52, false) - "fog": - draw_circle(m + Vector2(-s * 0.16, s * 0.06), s * 0.16, farbe) - draw_circle(m + Vector2(s * 0.16, s * 0.06), s * 0.16, farbe) - draw_circle(m + Vector2(0, -s * 0.06), s * 0.20, farbe) "gag": var r := Rect2(m - Vector2(s * 0.28, s * 0.16), Vector2(s * 0.56, s * 0.32)) draw_rect(r, farbe, false, s * 0.07) @@ -138,6 +128,23 @@ class MuenzIcon extends Control: _: draw_circle(m, s * 0.26, farbe) + ## Vier Striche und einer quer - eine Strichliste, das Zeichen fürs Zählen. + func _striche(mitte: Vector2, s: float) -> void: + var w := maxf(1.5, s * 0.07) + for i in 4: + var x: float = mitte.x + (float(i) - 1.5) * s * 0.16 + draw_line(Vector2(x, mitte.y - s * 0.26), Vector2(x, mitte.y + s * 0.26), farbe, w) + draw_line(mitte + Vector2(-s * 0.32, s * 0.20), mitte + Vector2(s * 0.32, -s * 0.20), farbe, w) + + ## Ein gekippter Würfel mit einem Auge - der wird gerade umgedreht. + func _schiefer_wuerfel(mitte: Vector2, kante: float) -> void: + var h := kante * 0.5 + var ecken := PackedVector2Array() + for e: Vector2 in [Vector2(-h, -h), Vector2(h, -h), Vector2(h, h), Vector2(-h, h)]: + ecken.append(mitte + e.rotated(0.35)) + draw_colored_polygon(ecken, farbe) + draw_circle(mitte, kante * 0.11, FLAECHE) + func _wuerfel(mitte: Vector2, kante: float, augen: int) -> void: var r := Rect2(mitte - Vector2(kante, kante) * 0.5, Vector2(kante, kante)) draw_rect(r, farbe, true) @@ -146,13 +153,6 @@ class MuenzIcon extends Control: for p: Vector2 in DieIcon.PIPS[augen]: draw_circle(mitte + p * weg, punkt, FLAECHE) - ## Offener Kreisbogen mit Spitze - das Zeichen fürs Neuwerfen. - func _bogen(mitte: Vector2, r: float) -> void: - draw_arc(mitte, r, PI * 0.85, TAU * 0.92, 28, farbe, maxf(1.5, r * 0.20)) - var spitze := mitte + Vector2(cos(TAU * 0.92), sin(TAU * 0.92)) * r - draw_colored_polygon([spitze + Vector2(-r * 0.30, -r * 0.10), - spitze + Vector2(r * 0.24, -r * 0.16), spitze + Vector2(r * 0.02, r * 0.28)], farbe) - func _plus(mitte: Vector2, r: float) -> void: draw_rect(Rect2(mitte - Vector2(r, r * 0.28), Vector2(r * 2, r * 0.56)), farbe, true) draw_rect(Rect2(mitte - Vector2(r * 0.28, r), Vector2(r * 0.56, r * 2)), farbe, true) @@ -304,9 +304,17 @@ func _build() -> void: _place(pl, 1, 0, 1, 0, -16, 16, -16, 16) pl.grow_horizontal = Control.GROW_DIRECTION_BEGIN root.add_child(pl) + var rechts := VBoxContainer.new() + rechts.add_theme_constant_override("separation", 10) + pl.add_child(rechts) players_box = VBoxContainer.new() players_box.add_theme_constant_override("separation", 6) - pl.add_child(players_box) + rechts.add_child(players_box) + # Der Gebotsverlauf der Runde. Bluffs liest man daran, wer wie schnell + # hochgegangen ist - nicht am letzten Gebot allein. + verlauf_box = VBoxContainer.new() + verlauf_box.add_theme_constant_override("separation", 2) + rechts.add_child(verlauf_box) # --- Aktive Booster oben links, Breite richtet sich nach dem Inhalt var bo := _panel() @@ -421,6 +429,13 @@ func _build() -> void: challenge_btn.pressed.connect(_on_challenge) av.add_child(challenge_btn) + exact_btn = Button.new() + exact_btn.text = "Genau" + exact_btn.tooltip_text = "Die Ansage trifft exakt. Stimmt es, verlieren alle anderen einen Würfel." + exact_btn.custom_minimum_size = Vector2(96, 42) + exact_btn.pressed.connect(_on_exact) + av.add_child(exact_btn) + timer_bid = _label("", 18, DIM) timer_bid.custom_minimum_size = Vector2(TIMER_W, 0) timer_bid.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT @@ -534,6 +549,34 @@ func _build() -> void: leave.pressed.connect(_on_quit) pv.add_child(leave) + # --- Spielende: wer gewonnen hat, und wie es weitergeht + over_slot = Control.new() + over_slot.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + over_slot.visible = false + root.add_child(over_slot) + var oc := CenterContainer.new() + oc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + over_slot.add_child(oc) + var op := _panel(Color(0.05, 0.06, 0.09, 0.98)) + oc.add_child(op) + var ov := VBoxContainer.new() + ov.custom_minimum_size = Vector2(300, 0) + ov.add_theme_constant_override("separation", 10) + op.add_child(ov) + over_title = _label("", 26, ACCENT) + over_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + ov.add_child(over_title) + over_again = Button.new() + over_again.text = "Nochmal" + over_again.custom_minimum_size = Vector2(0, 42) + over_again.pressed.connect(func() -> void: rematch_pressed.emit()) + ov.add_child(over_again) + var menue := Button.new() + menue.text = "Zum Menü" + menue.custom_minimum_size = Vector2(0, 42) + menue.pressed.connect(_on_quit) + ov.add_child(menue) + func _on_quit() -> void: quit_pressed.emit() @@ -542,10 +585,8 @@ func _on_quit() -> void: ## Ein Gebot muss höher sein als das vorige. Bei gleicher Anzahl geht das nur ## über eine höhere Augenzahl - die kleineren werden deshalb ausgeblendet. func _face_allowed(f: int) -> bool: - if _bid_count <= 0 or _count >= _bid_count + _min_raise: + if _bid_count <= 0 or _count > _bid_count: return true - if _min_raise > 1: - return false return _count == _bid_count and f > _bid_face @@ -568,12 +609,12 @@ func _lowest_bid() -> void: if _bid_count <= 0: _set_count(1) _on_face(1) - elif _min_raise == 1 and _bid_face < 6: + elif _bid_face < 6: # Gleiche Anzahl reicht, wenn die Augenzahl steigt. _set_count(_bid_count) _on_face(_bid_face + 1) else: - _set_count(_bid_count + _min_raise) + _set_count(_bid_count + 1) _on_face(1) @@ -597,6 +638,10 @@ func _on_challenge() -> void: challenge_pressed.emit() +func _on_exact() -> void: + exact_pressed.emit() + + func _unhandled_key_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo \ and event.keycode == KEY_ESCAPE: @@ -672,7 +717,7 @@ func update_state(st: Dictionary, my_seat: int) -> void: # Beim Aufdecken bleibt die Ansage stehen, wie sie war: die angesagten # Würfel normal, die fehlenden davon rot. Gab es mehr als angesagt, # kommen die überzähligen grün dahinter. - var faerben: bool = st.phase == "reveal" + var faerben: bool = st.phase == "reveal" and _actual >= 0 var angesagt := int(st.bid.count) var gezeigt := maxi(angesagt, _actual) if faerben else angesagt var px := _fit_dice(gezeigt) @@ -686,10 +731,17 @@ func update_state(st: Dictionary, my_seat: int) -> void: bid_row.add_child(DieIcon.new(int(st.bid.face), px, bg)) _zeichne_hand(st) - _zeichne_mitte(st, my_seat) + _zeichne_verlauf(st, my_seat) - banner_label.text = st.banner + # Am Ende steht der Sieger im Endbildschirm; das Banner darüber wäre doppelt. + banner_label.text = "" if st.phase == "over" else st.banner + if st.phase == "over" and not over_slot.visible: + over_title.text = String(st.banner) + # Nochmal gibt es nur solo. Im Netz müsste der Server den Raum neu + # aufsetzen; bis dahin geht es zurück ins Menü. + over_again.visible = Net.room_code == Net.SOLO + over_slot.visible = true 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 @@ -708,19 +760,15 @@ func update_state(st: Dictionary, my_seat: int) -> void: 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)) - _min_raise = maxi(1, int(st.min_raise)) _bid_count = 0 if st.bid.is_empty() else int(st.bid.count) _bid_face = 0 if st.bid.is_empty() else int(st.bid.face) # Ein Maulkorb sperrt das Anfechten - ausser es gäbe gar kein gültiges # Gebot mehr, dann bliebe sonst kein einziger Knopf übrig. - var gebot_moeglich: bool = _bid_count + _min_raise <= _count_max \ - or (_min_raise == 1 and _bid_face < 6) + var gebot_moeglich: bool = _bid_count + 1 <= _count_max or _bid_face < 6 challenge_btn.disabled = not my_turn or st.bid.is_empty() \ or (st.must_bid and gebot_moeglich) - var low := 1 - if _bid_count > 0: - low = _bid_count + (_min_raise if _min_raise > 1 else 0) - _count_min = clampi(low, 1, _count_max) + exact_btn.disabled = challenge_btn.disabled + _count_min = clampi(maxi(1, _bid_count), 1, _count_max) _set_count(_count) if kam_dran: _lowest_bid() @@ -803,10 +851,12 @@ func _zeichne_hand(st: Dictionary) -> void: # 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" + # Geworfen wird nur am eigenen Zug. Was eine Ansage braucht, wartet auf eine. + var spielbar: bool = st.phase == "bid" and int(st.turn) == _my_seat + var gebot: bool = not st.bid.is_empty() # 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] + var sig := "%s|%s|%s" % [muenzen, spielbar, gebot] if sig == _hand_sig: return _hand_sig = sig @@ -819,7 +869,10 @@ func _zeichne_hand(st: Dictionary) -> void: var btn := Button.new() btn.custom_minimum_size = Vector2(104, 120) - btn.disabled = not spielbar + btn.disabled = not spielbar or (BoosterDB.needs_bid(id) and not gebot) + # Der gesperrte Knopf dunkelt nur seinen Rand ab; Münze und Name darauf + # blieben sonst so hell wie bei einer werfbaren. + btn.modulate = Color(1, 1, 1, 1.0 if not btn.disabled else 0.4) # 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 @@ -845,35 +898,32 @@ func _zeichne_hand(st: Dictionary) -> void: v.add_child(name_l) -## Was liegt in der Mitte, plus die Wirkungen, die gerade auf einem liegen. +## Der Topf: was diese Runde geworfen wurde und wem es gehört, wenn er +## das Aufdecken gewinnt. Darunter, was gerade auf einem selbst liegt. ## 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} + for m in boosts: + if int(m.get("nr", 0)) > int(neu.get("nr", 0)): + neu = m _pruefe_wurf(neu, st) - var sig := "%s|%s|%s" % [boosts, st.min_raise, st.must_bid] + var zaehlung: Dictionary = st.get("zaehlung", {}) + var sig := "%s|%s|%s" % [boosts, zaehlung, 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 + boosts_box.add_child(_label("Topf" if not boosts.is_empty() else "Topf: leer", 13, DIM)) + for m in boosts: var id := String(m.get("id", "")) var b: Dictionary = BoosterDB.get_booster(id) if b.is_empty(): continue - any = true + var wer := int(m.get("seat", 0)) var line := HBoxContainer.new() line.add_theme_constant_override("separation", 5) # Der Container muss die Maus annehmen, sonst gibt es kein Überfahren. @@ -881,19 +931,55 @@ func _zeichne_mitte(st: Dictionary, my_seat: int) -> void: 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 wer < st.seats.size(): + line.add_child(_label("von " + _short(String(st.seats[wer].name)), 13, + ACCENT if wer == my_seat else DIM)) + # Ergebnis vom Zählen: so oft liegt die Augenzahl am Tisch. + for f in zaehlung: + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 5) + boosts_box.add_child(line) + var von := int(zaehlung[f][0]) + var bis := int(zaehlung[f][1]) + var text := "Am Tisch: %d ×" % von if von == bis else "Am Tisch: %d–%d ×" % [von, bis] + line.add_child(_label(text, 14, ACCENT)) + line.add_child(DieIcon.new(int(f), 18.0)) 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)) + + +## Die letzten Ansagen der Runde, die jüngste unten und hervorgehoben. +func _zeichne_verlauf(st: Dictionary, my_seat: int) -> void: + var verlauf: Array = st.get("verlauf", []) + var sig := str(verlauf) + if sig == _verlauf_sig: + return + _verlauf_sig = sig + _clear(verlauf_box) + if verlauf.is_empty(): + return + verlauf_box.add_child(_label("Verlauf", 13, DIM)) + var ab := maxi(0, verlauf.size() - 6) + for i in range(ab, verlauf.size()): + var e: Dictionary = verlauf[i] + var wer := int(e.get("seat", 0)) + if wer >= st.seats.size(): + continue + var letzte: bool = i == verlauf.size() - 1 + var col := ACCENT if letzte else (Color(0.85, 0.87, 0.92) if wer != my_seat else Color(0.95, 0.85, 0.55)) + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 5) + verlauf_box.add_child(line) + var nm := _label(_short(String(st.seats[wer].name)), 13, col) + nm.custom_minimum_size = Vector2(64, 0) + line.add_child(nm) + line.add_child(_label("%d ×" % int(e.get("count", 0)), 13, col)) + line.add_child(DieIcon.new(int(e.get("face", 1)), 16.0)) + if bool(e.get("erhoeht", false)): + line.add_child(_label("erhöht", 12, DIM)) ## Erklärung zu einer Münze, sobald die Maus daraufliegt. `unter` legt sie @@ -953,6 +1039,8 @@ func _pruefe_wurf(neu: Dictionary, st: Dictionary) -> void: return _letzter_wurf = nr var seat := int(neu.get("seat", 0)) + if seat >= st.seats.size(): + return _zeig_wurf(String(neu.get("id", "")), bool(neu.get("stark", false)), _short(String(st.seats[seat].name))) @@ -971,6 +1059,7 @@ func _zeig_wurf(id: String, stark: bool, wer: String) -> void: wurf_label.text = "%s wirft\n%s" % [wer, b.name] wurf_label.add_theme_color_override("font_color", DIM) wurf_slot.visible = true + Klang.muenze() if _wurf_tween != null and _wurf_tween.is_valid(): _wurf_tween.kill() _wurf_tween = create_tween() diff --git a/scripts/klang.gd b/scripts/klang.gd new file mode 100644 index 0000000..451cf89 --- /dev/null +++ b/scripts/klang.gd @@ -0,0 +1,85 @@ +extends Node +## Klänge ohne Dateien. Die kurzen Wellen werden beim Start berechnet - so wie +## Würfel und Münzen gemalt statt geladen werden. Auf dem Spielserver ohne +## Tongerät spielt das ins Leere, stört aber nicht. + +const RATE := 22050 + +var _wuerfel: AudioStreamWAV +var _muenze: AudioStreamWAV +var _becher: AudioStreamWAV +var _chime: AudioStreamWAV +var _rng := RandomNumberGenerator.new() + + +func _ready() -> void: + _rng.randomize() + # Klack: Rauschen, das sofort wieder weg ist. + _wuerfel = _wav(0.07, func(t: float) -> float: + return _rausch() * exp(-t * 60.0)) + # Klirren: drei unharmonische Töne, die langsam ausklingen. + _muenze = _wav(0.35, func(t: float) -> float: + return (sin(TAU * 2600.0 * t) + 0.5 * sin(TAU * 4100.0 * t) + + 0.3 * sin(TAU * 6300.0 * t)) / 1.8 * exp(-t * 9.0)) + # Dumpfer Schlag: tiefer Ton plus kurzes Rauschen. + _becher = _wav(0.14, func(t: float) -> float: + return sin(TAU * 110.0 * t) * exp(-t * 22.0) + _rausch() * 0.3 * exp(-t * 80.0)) + # Zwei Töne aufwärts - für den Gewinn. + _chime = _wav(0.55, func(t: float) -> float: + var a := sin(TAU * 880.0 * t) * exp(-t * 4.0) + var b := 0.0 + if t > 0.14: + b = sin(TAU * 1320.0 * (t - 0.14)) * exp(-(t - 0.14) * 4.0) + return (a + b) * 0.5) + + +func _rausch() -> float: + return _rng.randf_range(-1.0, 1.0) + + +func _wav(sek: float, f: Callable) -> AudioStreamWAV: + var n := int(RATE * sek) + var data := PackedByteArray() + data.resize(n * 2) + for i in n: + var v: float = clampf(f.call(float(i) / RATE), -1.0, 1.0) + data.encode_s16(i * 2, int(v * 32000.0)) + var w := AudioStreamWAV.new() + w.format = AudioStreamWAV.FORMAT_16_BITS + w.mix_rate = RATE + w.data = data + return w + + +func _spiel(w: AudioStreamWAV, db: float, pitch := 1.0) -> void: + var p := AudioStreamPlayer.new() + p.stream = w + p.volume_db = db + p.pitch_scale = pitch + add_child(p) + p.finished.connect(p.queue_free) + p.play() + + +## Mehrere Würfel fallen: ein paar Klacker kurz hintereinander, verschieden hoch. +func wuerfel(anzahl := 5) -> void: + for i in mini(anzahl, 8): + await get_tree().create_timer(_rng.randf_range(0.0, 0.35)).timeout + _spiel(_wuerfel, -8.0, _rng.randf_range(0.8, 1.3)) + + +## Ein kurzer heller Klick - je gezähltem Würfel einer. +func tick() -> void: + _spiel(_wuerfel, -14.0, 2.2) + + +func muenze() -> void: + _spiel(_muenze, -6.0, _rng.randf_range(0.95, 1.05)) + + +func becher() -> void: + _spiel(_becher, -3.0) + + +func chime() -> void: + _spiel(_chime, -8.0) diff --git a/scripts/main.gd b/scripts/main.gd index ded9d7f..b088671 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -30,6 +30,12 @@ func _ready() -> void: menu = preload("res://scripts/menu.gd").new() add_child(menu) + # "Nochmal" nach einer Solopartie: das Menü wird übersprungen. + if not Net.rematch.is_empty(): + var r: Dictionary = Net.rematch + Net.rematch = {} + Net.start_singleplayer(String(r.name), int(r.ai), int(r.stufe)) + ## Das Spiel startet im Vollbild; F11 schaltet um. func _unhandled_key_input(event: InputEvent) -> void: diff --git a/scripts/menu.gd b/scripts/menu.gd index 1b356c4..ccfd385 100644 --- a/scripts/menu.gd +++ b/scripts/menu.gd @@ -10,6 +10,8 @@ var current := "main" # Einzelspieler var solo_bots: NumberRow +var solo_stufe: OptionButton +var lobby_stufe: OptionButton # Mehrspieler var name_edit: LineEdit @@ -37,6 +39,7 @@ func _ready() -> void: screens["multi"] = _screen(root, _build_multi()) screens["join"] = _screen(root, _build_join()) screens["lobby"] = _screen(root, _build_lobby()) + screens["regeln"] = _screen(root, _build_regeln()) Net.lobby_changed.connect(_refresh) Net.connection_failed.connect(_on_failed) @@ -85,6 +88,17 @@ func _text(t: String, size: int, col := Color.WHITE) -> Label: return l +## Leicht, normal, schwer - stellt Bluff-Quote, Genau-Schwelle und Vorsicht +## der Bots ein. +func _stufen_wahl() -> OptionButton: + var o := OptionButton.new() + for name in ["leicht", "normal", "schwer"]: + o.add_item(name) + o.selected = 1 + o.add_theme_font_size_override("font_size", 16) + return o + + func _row(caption: String, ctrl: Control) -> HBoxContainer: var h := HBoxContainer.new() var l := Label.new() @@ -158,10 +172,43 @@ func _build_main() -> Control: _title(box) _button("Einzelspieler", box, _show.bind("single")) _button("Mehrspieler", box, _show.bind("multi")) + _button("Regeln", box, _show.bind("regeln")) _button("Beenden", box, func() -> void: get_tree().quit()) return box +## Die Regeln auf einer Seite. Wer die Münzen nicht kennt, wüsste sonst im +## Spiel nur, was der Tooltip verrät. +func _build_regeln() -> Control: + var box := _box(680) + box.add_child(_text("Regeln", 26, Color(1.0, 0.82, 0.32))) + var absaetze := [ + ["Würfeln", "Jeder hat fünf Würfel unter dem Becher. Nur die eigenen sieht man."], + ["Ansagen", "Reihum sagt man, wie viele Würfel einer Augenzahl auf dem ganzen Tisch liegen - zum Beispiel „vier Dreien\". Wer dran ist, muss höher gehen: mehr Würfel, oder gleich viele mit höherer Augenzahl. Oder er zweifelt."], + ["Anfechten", "Stimmt die Ansage nicht, verliert der Ansager einen Würfel. Stimmt sie, verliert der Zweifler einen. Mehr als angesagt zählt als richtig."], + ["Genau", "Wer glaubt, die Ansage trifft exakt, ruft „Genau\". Trifft es, verliert der Ansager einen Würfel und der Rufer bekommt einen dazu. Daneben verliert der Rufer einen."], + ["Münzen", "Jeder startet mit einer Münze; wer einen Würfel verliert, bekommt eine. Geworfen wird nur am eigenen Zug, vor der Ansage. Wappen verstärkt die Wirkung, Zahl lässt es bei der Grundwirkung - die kommt immer."], + ["Topf", "Geworfene Münzen liegen in der Mitte. Wer das Aufdecken gewinnt, nimmt sie. Mehr als vier passen nicht in den Vorrat; der Rest bleibt liegen. Im Topf liegen höchstens sechs - die älteste fällt vom Tisch."], + ["Ende", "Wer keine Würfel mehr hat, schaut zu. Der Letzte gewinnt."], + ] + for a: Array in absaetze: + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 12) + var k := _text(String(a[0]), 15, Color(1.0, 0.82, 0.32)) + k.custom_minimum_size = Vector2(110, 0) + k.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + k.vertical_alignment = VERTICAL_ALIGNMENT_TOP + h.add_child(k) + var t := _text(String(a[1]), 15, Color(0.85, 0.87, 0.92)) + t.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + t.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + t.size_flags_horizontal = Control.SIZE_EXPAND_FILL + h.add_child(t) + box.add_child(h) + _button("Zurück", box, _show.bind("main")) + return box + + func _build_single() -> Control: var box := _box(400) _title(box) @@ -169,6 +216,8 @@ func _build_single() -> Control: box.add_child(_text("Bots", 15)) solo_bots = NumberRow.new(1, BOT_NAMES_MAX, 3) box.add_child(solo_bots) + solo_stufe = _stufen_wahl() + box.add_child(_row("Bots spielen", solo_stufe)) box.add_child(HSeparator.new()) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) @@ -244,6 +293,8 @@ func _build_lobby() -> Control: box.add_child(_text("Bots", 15)) lobby_bots = NumberRow.new(0, BOT_NAMES_MAX, 1) box.add_child(lobby_bots) + lobby_stufe = _stufen_wahl() + box.add_child(_row("Bots spielen", lobby_stufe)) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) @@ -257,7 +308,7 @@ func _build_lobby() -> Control: # ------------------------------------------------------------ Aktionen func _on_solo_start() -> void: - Net.start_singleplayer("Spieler", solo_bots.value) + Net.start_singleplayer("Spieler", solo_bots.value, solo_stufe.selected) ## Ein leeres Namensfeld ergäbe einen namenlosen Platz am Tisch. @@ -287,7 +338,7 @@ func _on_start() -> void: if humans + ai < 2: _sagen("Mindestens 2 Spieler nötig (Mitspieler oder Bots).") return - Net.request_start(ai) + Net.request_start(ai, lobby_stufe.selected) func _on_failed(msg: String) -> void: @@ -314,6 +365,7 @@ func _refresh() -> void: code_label.text = Net.room_code start_btn.disabled = not Net.is_owner lobby_bots.anpassen(Net.MAX_HUMANS - Net.lobby.size(), Net.is_owner) + lobby_stufe.disabled = not Net.is_owner var names: Array = [] for id: int in Net.lobby: diff --git a/scripts/net.gd b/scripts/net.gd index dee0b95..864048a 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -30,6 +30,8 @@ var owner_id := 0 var is_owner := false var room_code := "" var in_game := false +## Wunsch für eine neue Solopartie; das frisch geladene Menü löst ihn ein. +var rematch: Dictionary = {} # ── Server ─────────────────────────────────────────────────────────────────── var dedicated := false @@ -228,7 +230,7 @@ func _push_lobby(code: String, data: Dictionary, besitzer: int) -> void: # ── Start ──────────────────────────────────────────────────────────────────── ## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots. -func start_singleplayer(pname: String, ai_count: int) -> void: +func start_singleplayer(pname: String, ai_count: int, stufe := 1) -> void: player_name = pname multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() room_code = SOLO @@ -237,16 +239,17 @@ func start_singleplayer(pname: String, ai_count: int) -> void: is_owner = true in_game = true lobby_changed.emit() - game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count}) + game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count, + "stufe": stufe}) ## Der Host bittet den Server um den Start. -func request_start(ai_count: int) -> void: - _req_start.rpc_id(1, ai_count) +func request_start(ai_count: int, stufe := 1) -> void: + _req_start.rpc_id(1, ai_count, stufe) @rpc("any_peer", "reliable") -func _req_start(ai_count: int) -> void: +func _req_start(ai_count: int, stufe: int) -> void: if not multiplayer.is_server(): return var id := multiplayer.get_remote_sender_id() @@ -259,7 +262,8 @@ func _req_start(ai_count: int) -> void: if order.size() + ai_count < 2: return raum.in_game = true - var cfg := {"peers": order, "names": raum.lobby.duplicate(), "ai": ai_count} + var cfg := {"peers": order, "names": raum.lobby.duplicate(), "ai": ai_count, + "stufe": clampi(stufe, 0, 2)} _start_zustellen(code, cfg) game_started.emit(code, cfg) diff --git a/scripts/table.gd b/scripts/table.gd index e27eaf7..f360a2b 100644 --- a/scripts/table.gd +++ b/scripts/table.gd @@ -30,6 +30,9 @@ 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 topf_muenzen: Dictionary = {} ## Wurfnummer -> Münze in der Tischmitte +static var _muenz_mesh: CylinderMesh +static var _muenz_mat: StandardMaterial3D var dice: Array = [] ## seat -> Array[Die] var camera: Camera3D @@ -472,6 +475,85 @@ func freeze_dice(seat: int) -> void: d.set_kinematic(true) +## Der Topf liegt als Häufchen Messing in der Tischmitte. Jede Münze trägt +## ihre Wurfnummer; was nicht mehr in der Liste steht, ist beim Gewinner. +## Lage und Drehung kommen aus der Nummer, damit Host und Clients dasselbe +## Häufchen sehen. +func set_topf(liste: Array) -> void: + var bleibt := {} + for m in liste: + var nr := int(m.get("nr", 0)) + bleibt[nr] = true + if topf_muenzen.has(nr): + continue + var r := RandomNumberGenerator.new() + r.seed = nr * 7919 + var ang := r.randf() * TAU + var rad := r.randf_range(0.30, 0.55) + var ziel := Vector3(cos(ang) * rad, TABLE_H + 0.006, sin(ang) * rad) + var mi := MeshInstance3D.new() + mi.mesh = _muenz_form() + mi.material_override = _muenz_stoff() + mi.position = ziel + Vector3(0, 0.35, 0) + mi.rotation.y = r.randf() * TAU + add_child(mi) + topf_muenzen[nr] = mi + # Fällt aus der Hand auf den Tisch. + create_tween().tween_property(mi, "position", ziel, 0.35) \ + .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + for nr in topf_muenzen.keys(): + if not bleibt.has(nr): + topf_muenzen[nr].queue_free() + topf_muenzen.erase(nr) + + +static func _muenz_form() -> CylinderMesh: + if _muenz_mesh == null: + _muenz_mesh = CylinderMesh.new() + # Aus Kamerahöhe muss eine Münze fast so gross sein wie ein Würfel, + # sonst ist sie ein Punkt. + _muenz_mesh.top_radius = 0.11 + _muenz_mesh.bottom_radius = 0.11 + _muenz_mesh.height = 0.014 + _muenz_mesh.radial_segments = 24 + return _muenz_mesh + + +static func _muenz_stoff() -> StandardMaterial3D: + if _muenz_mat == null: + _muenz_mat = StandardMaterial3D.new() + _muenz_mat.albedo_color = Color(0.80, 0.62, 0.26) + _muenz_mat.metallic = 0.85 + _muenz_mat.roughness = 0.35 + _muenz_mat.emission_enabled = true + _muenz_mat.emission = Color(0.55, 0.40, 0.12) + _muenz_mat.emission_energy_multiplier = 0.35 + return _muenz_mat + + +## Beim Zählen leuchten die passenden Würfel nacheinander auf, mit einem +## Tick je Würfel. So sieht man, woher die Zahl im Banner kommt. +func zaehlen_zeigen(face: int, werte: Array) -> void: + var k := 0 + for seat in mini(werte.size(), dice.size()): + var dl: Array = dice[seat] + for i in mini(dl.size(), werte[seat].size()): + if int(werte[seat][i]) != face: + continue + var d = dl[i] + get_tree().create_timer(0.12 * k).timeout.connect(func() -> void: + if is_instance_valid(d): + d.hervorheben(true) + Klang.tick()) + k += 1 + + +func hervorhebung_weg() -> void: + for dl in dice: + for d in dl: + d.hervorheben(false) + + ## 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: diff --git a/test_cli.gd b/test_cli.gd new file mode 100644 index 0000000..ebdaf4c --- /dev/null +++ b/test_cli.gd @@ -0,0 +1,81 @@ +extends Node +const ORT := "/tmp/claude-1000/-home-arbeit-projects-pirate-dice/00de2cf5-7327-4fce-b543-2b4db6d3b775/scratchpad" +var game: Node +var rolle := "" +var runden := 0 +var wuerfe := 0 +var genau := 0 +var _letzte_phase := "" +var _rng := RandomNumberGenerator.new() +func _ready() -> void: + Engine.time_scale = 8.0 + _rng.randomize() + rolle = OS.get_cmdline_user_args()[0] + add_child(preload("res://scenes/main.tscn").instantiate()) + await get_tree().create_timer(0.3).timeout + _lauf() +func _find(n: Node, w: String) -> Node: + if n.name == w: return n + for c in n.get_children(): + var r := _find(c, w) + if r != null: return r + return null +func _warte(cond: Callable, sek: float) -> bool: + var t := 0.0 + while t < sek: + if cond.call(): return true + await get_tree().create_timer(0.1).timeout + t += 0.1 + return false +func _lauf() -> void: + if rolle == "host": + Net.create_room("Host") + await _warte(func(): return Net.room_code != "", 40.0) + var f := FileAccess.open(ORT + "/code.txt", FileAccess.WRITE) + f.store_string(Net.room_code); f.close() + await _warte(func(): return Net.lobby.size() >= 2, 80.0) + Net.request_start(1, 1) + else: + await _warte(func(): return FileAccess.file_exists(ORT + "/code.txt"), 80.0) + await get_tree().create_timer(0.5).timeout + Net.join_room(FileAccess.get_file_as_string(ORT + "/code.txt").strip_edges(), "Gast") + await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 120.0) + if game == null: + print("%s: kein Spiel" % rolle); get_tree().quit(1); return + var t := 0.0 + var gewartet := 0.0 + while t < 4400.0 and is_instance_valid(game) and game.phase != "over": + await get_tree().create_timer(0.2).timeout; t += 0.2 + if not is_instance_valid(game): break + if game.phase != _letzte_phase: + _letzte_phase = game.phase + if game.phase == "roll": runden += 1 + var me: int = game.local_seat + if game.phase != "bid" or game.turn != me or not game.seats[me].alive: + gewartet = 0.0; continue + gewartet += 0.2 + if gewartet < 0.6: continue + var hand: Array = game.hand.get(me, []) + if not hand.is_empty() and (not BoosterDB.needs_bid(hand[0]) or not game.bid.is_empty()) and _rng.randf() < 0.7: + game._on_booster_chosen(hand[0], 0) + wuerfe += 1 + await get_tree().create_timer(2.5).timeout + if not is_instance_valid(game) or game.turn != me or game.phase != "bid": continue + var total := 0 + for s in game.seats: total += int(s.count) + var a: Dictionary = DiceAI.choose_action(game.my_values, total, game.bid, false, _rng) + if a.type == "challenge" and not game.bid.is_empty() and _rng.randf() < 0.3: + a = {"type": "exact"} + match a.type: + "bid": game._on_bid_pressed(int(a.count), int(a.face)) + "challenge": game._on_challenge_pressed() + "exact": + game._on_exact_pressed(); genau += 1 + gewartet = -2.0 + if is_instance_valid(game): + print("%s: Ende %s '%s' Platz=%d Runden=%d Wuerfe=%d Genau=%d" % [rolle, game.phase, game.banner, game.local_seat, runden, wuerfe, genau]) + for i in game.seats.size(): + print("%s: Platz %d %s count=%d alive=%s" % [rolle, i, game.seats[i].name, game.seats[i].count, game.seats[i].alive]) + else: + print("%s: Spiel weg, Runden=%d" % [rolle, runden]) + get_tree().quit() diff --git a/test_cli.tscn b/test_cli.tscn new file mode 100644 index 0000000..7d870b3 --- /dev/null +++ b/test_cli.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://test_cli.gd" id="1"] + +[node name="Cli" type="Node"] +script = ExtResource("1") diff --git a/test_srv.gd b/test_srv.gd new file mode 100644 index 0000000..0f847c4 --- /dev/null +++ b/test_srv.gd @@ -0,0 +1,32 @@ +extends Node +const ORT := "/tmp/claude-1000/-home-arbeit-projects-pirate-dice/00de2cf5-7327-4fce-b543-2b4db6d3b775/scratchpad" +var game: Node +func _ready() -> void: + Engine.time_scale = 8.0 + add_child(preload("res://scenes/main.tscn").instantiate()) + _lauf() +func _find(n: Node, w: String) -> Node: + if n.name == w: return n + for c in n.get_children(): + var r := _find(c, w) + if r != null: return r + return null +func _lauf() -> void: + var t := 0.0 + var runden := 0 + var letzte := "" + while t < 4500.0: + await get_tree().create_timer(0.5).timeout; t += 0.5 + if game == null: + game = _find(get_tree().root, "Game"); continue + if not is_instance_valid(game) or game.phase == "over": break + if game.phase != letzte: + letzte = game.phase + if letzte == "roll": runden += 1 + if is_instance_valid(game): + print("SERVER: Ende %s '%s' nach %.0fs, Runden=%d" % [game.phase, game.banner, t, runden]) + for i in game.seats.size(): + print("SERVER: Platz %d %s count=%d alive=%s" % [i, game.seats[i].name, game.seats[i].count, game.seats[i].alive]) + # Den Clients Zeit lassen, das Ende zu sehen und selbst zu gehen. + await get_tree().create_timer(80.0).timeout + get_tree().quit() diff --git a/test_srv.tscn b/test_srv.tscn new file mode 100644 index 0000000..5fac189 --- /dev/null +++ b/test_srv.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://test_srv.gd" id="1"] + +[node name="Srv" type="Node"] +script = ExtResource("1")