This commit is contained in:
team3
2026-09-16 12:09:47 +02:00
parent 9b3cd55ba7
commit 7979d5c336
16 changed files with 974 additions and 342 deletions

View File

@@ -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 der Bot-Logik. Ohne dieses Limit würde ein abwesender oder getrennter
Mitspieler den ganzen Tisch dauerhaft blockieren. 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 ## 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. Wartezeit und riss den Ablauf auseinander.
* Jeder startet mit **einer** Münze. * 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. Nachschub, also bekommt der etwas zurück, der gerade hinten liegt.
* Geworfen wird **jederzeit während der Ansagen**, nicht nur am eigenen Zug. * Geworfen wird **nur am eigenen Zug, vor der Ansage**. So ist der Zug eine
* Mehr als **drei** Münzen passen nicht in den Vorrat. 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 * 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 ### Der Wurf
Beim Ausspielen wird die Münze geworfen und landet in der Mitte. Beim Werfen entscheidet die Seite.
* **Zahl** – die Grundwirkung tritt ein. * **Zahl** – die Grundwirkung tritt ein.
* **Wappen** – die Wirkung fällt stärker aus. Die Chance ist die Hälfte. * **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, Die Grundwirkung kommt immer. Kein Zug ist verschenkt. Der Ausgang wird beim
wann geworfen wird, bleibt beim Spieler. Der Wurf selbst ist reine Zugabe. 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 ### Die sechs Münzen
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. 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 **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 - 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 das ist seine Grundwirkung, und Wappen macht daraus nur eine 6.
werden sonst nur Beträge und Reichweiten.
| Münze | Art | Zahl | Wappen | ### Anzeige
|---|---|---|---|
| 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 * Oben rechts steht der **Gebotsverlauf** der Runde. Bluffs liest man daran,
Verstärkung sind der Kern; die Tabelle ist die aktuelle Füllung. 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 ## 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/die.gd` | Physik-Würfel, Ablesen der Augenzahl aus der Ruhelage |
| `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) | | `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) |
| `scripts/hud.gd` | Spiel-UI | | `scripts/hud.gd` | Spiel-UI |
| `scripts/klang.gd` | Autoload: berechnete Klänge, keine Dateien |
| `scripts/menu.gd` | Startmenü und Lobby | | `scripts/menu.gd` | Startmenü und Lobby |
Die gesamte Spiellogik und die Würfelphysik laufen beim Host. Clients erhalten Die gesamte Spiellogik und die Würfelphysik laufen beim Host. Clients erhalten

View File

@@ -11,6 +11,7 @@ config/features=PackedStringArray("4.7", "Mobile")
BoosterDB="*res://scripts/boosters.gd" BoosterDB="*res://scripts/boosters.gd"
Net="*res://scripts/net.gd" Net="*res://scripts/net.gd"
Klang="*res://scripts/klang.gd"
[display] [display]

View File

@@ -30,13 +30,43 @@ static func _own(values: Array, face: int) -> int:
return c 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. ## Wahrscheinlichkeit, dass ein Gebot (count, face) hält.
static func chance(values: Array, unknown: int, count: int, face: int) -> float: static func chance(values: Array, unknown: int, count: int, face: int) -> float:
return tail(unknown, count - _own(values, face), 1.0 / 6.0) 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, 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 = [] var known: Array = []
for v in values: for v in values:
if v > 0: if v > 0:
@@ -47,26 +77,40 @@ static func choose_action(values: Array, total_dice: int, bid: Dictionary,
var op := _opening(known, unknown, rng) var op := _opening(known, unknown, rng)
return {"type": "bid", "count": op.count, "face": op.face} return {"type": "bid", "count": op.count, "face": op.face}
var r := _regler()
var hold := chance(known, unknown, bid.count, bid.face) 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: 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): if hold < doubt and (raise_.is_empty() or raise_.p < 0.32):
return {"type": "challenge"} return {"type": "challenge"}
if raise_.is_empty(): if raise_.is_empty():
if must_bid: # Kein sicheres Gebot mehr. Anfechten wäre ehrlich - aber wer immer
# Notgebot: minimal erhöhen, auch wenn unwahrscheinlich # ehrlich ist, wird gelesen. Also ab und zu bluffen, solange es noch
var c: int = int(bid.count) + maxi(1, min_raise) # ein gültiges Gebot gibt.
if c <= total_dice: if not low.is_empty() and (must_bid or rng.randf() < float(r.bluff)):
return {"type": "bid", "count": c, "face": bid.face} return {"type": "bid", "count": low.count, "face": low.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.
return {"type": "challenge"} 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} 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: static func _opening(known: Array, unknown: int, rng: RandomNumberGenerator) -> Dictionary:
var best_face := rng.randi_range(1, 6) var best_face := rng.randi_range(1, 6)
var best := -1 var best := -1
@@ -79,14 +123,15 @@ static func _opening(known: Array, unknown: int, rng: RandomNumberGenerator) ->
return {"count": count, "face": best_face} 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 := {} var best := {}
for face in range(1, 7): for face in range(1, 7):
for count in range(1, total + 1): for count in range(1, total + 1):
if not _is_higher(count, face, bid, min_raise): if not _is_higher(count, face, bid):
continue continue
var p := chance(known, unknown, count, face) var p := chance(known, unknown, count, face)
if p < 0.42: if p < sicher:
continue continue
if best.is_empty() or count < best.count or (count == best.count and p > best.p): if best.is_empty() or count < best.count or (count == best.count and p > best.p):
best = {"count": count, "face": face, "p": 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 return best
static func _is_higher(count: int, face: int, bid: Dictionary, min_raise: int) -> bool: static func _is_higher(count: int, face: int, bid: Dictionary) -> bool:
if count >= bid.count + min_raise: if count > bid.count:
return true return true
if min_raise > 1:
return false
return count == bid.count and face > bid.face return count == bid.count and face > bid.face
## Welcher eigene Würfel soll dran glauben? Für Münzen mit Zielwahl. ## Welcher eigene Würfel soll umgedreht werden? Einer, der die Augenzahl
static func target_for(id: String, values: Array) -> int: ## noch nicht zeigt - sonst wäre die Münze verschenkt.
if values.is_empty(): static func target_for(values: Array, face: int) -> int:
return 0
# Der schwächste Würfel: den neu zu werfen oder zur 6 zu machen bringt
# am meisten.
var idx := 0
for i in values.size(): for i in values.size():
if values[i] < values[idx]: if values[i] != face:
idx = i return i
return idx return 0

View File

@@ -1,91 +1,66 @@
extends Node extends Node
## Katalog aller Münzen. ## Katalog aller Münzen.
## ##
## Münzen gibt es nicht rundenweise zur Auswahl. Wer einen Würfel verliert, ## Sechs Stück, jede mit einem klaren Zweck: entweder sie gibt Wissen, oder sie
## bekommt eine. Gespielt wird jederzeit während der Ansagen - deshalb wirkt ## macht Druck, oder sie hilft der eigenen Hand nach. Keine würfelt an den
## jede Münze sofort und braucht keine eigene Phase mehr. ## 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 ## Geworfen wird nur am eigenen Zug, vor der Ansage. Die Münze landet im Topf
## Grundwirkung kommt immer, der Wurf legt nur eine Schippe drauf. Deshalb ist ## in der Mitte. Wer das Aufdecken gewinnt, nimmt den Topf. Damit kostet jede
## kein Zug verschenkt, und die Entscheidung bleibt beim Spieler. ## 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 } enum Kind { BUFF, DEBUFF }
## Mehr als das passt nicht auf die Hand; sonst hortet man bis zum Schluss. ## Mehr als das passt nicht in den Vorrat; sonst hortet man bis zum Schluss.
const HANDLIMIT := 3 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. ## Eine Münze hat zwei Seiten, also die Hälfte.
const WAPPEN_CHANCE := 0.5 const WAPPEN_CHANCE := 0.5
## Die Würfelzahl wird bewusst nie verstärkt. Sie ist die Kernressource - ## `target`: fragt nach einem eigenen Würfel. `gebot`: geht nur, wenn eine
## ein Würfel mehr aus Glück würde die Partie kippen. ## Ansage auf dem Tisch liegt.
const ALL := [ const ALL := [
{ {
"id": "reroll_all", "kind": Kind.BUFF, "target": false, "id": "peek", "kind": Kind.BUFF, "target": false, "gebot": 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", "name": "Spähen",
"desc": "Von jedem Gegner wird dir ein zufälliger Würfel aufgedeckt.", "desc": "Von jedem Gegner wird dir ein zufälliger Würfel aufgedeckt.",
"stark": "Es sind zwei Würfel je Gegner.", "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", "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.", "stark": "Sie steigt um 2.",
}, },
{ {
"id": "enemy_lower", "kind": Kind.DEBUFF, "target": false, "id": "gag", "kind": Kind.DEBUFF, "target": false, "gebot": 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", "name": "Maulkorb",
"desc": "Wer gerade dran ist, muss bieten und darf nicht anfechten.", "desc": "Der nächste Spieler muss diese Runde bieten und darf nicht anfechten.",
"stark": "Alle Gegner müssen bieten.", "stark": "Das gilt für alle Gegner.",
}, },
] ]
@@ -101,9 +76,8 @@ func needs_target(id: String) -> bool:
return get_booster(id).get("target", false) return get_booster(id).get("target", false)
## Münzen, die bei Wappen auf der Hand bleiben, statt in die Mitte zu wandern. func needs_bid(id: String) -> bool:
func keeps_on_wappen(id: String) -> bool: return get_booster(id).get("gebot", false)
return get_booster(id).get("behalten", false)
## Eine zufällige Münze, für den, der gerade einen Würfel verloren hat. ## Eine zufällige Münze, für den, der gerade einen Würfel verloren hat.

View File

@@ -33,6 +33,8 @@ const PIPS := {
# damit ein Würfel nur ein einziges Renderobjekt ist. # damit ein Würfel nur ein einziges Renderobjekt ist.
static var _mesh: ArrayMesh static var _mesh: ArrayMesh
static var _mat: StandardMaterial3D static var _mat: StandardMaterial3D
static var _gold: StandardMaterial3D ## fürs Zählen beim Aufdecken
var _body: MeshInstance3D
func _ready() -> void: func _ready() -> void:
@@ -125,10 +127,22 @@ func _build_visuals() -> void:
# liegt der Glanzpunkt genau auf der Fläche und hellt die Augen auf. # liegt der Glanzpunkt genau auf der Fläche und hellt die Augen auf.
_mat.roughness = 0.9 _mat.roughness = 0.9
_mat.metallic_specular = 0.0 _mat.metallic_specular = 0.0
var body := MeshInstance3D.new() if _gold == null:
body.mesh = _mesh # Vertexfarben bleiben, werden nur gold getönt - die Augen bleiben dunkel.
body.material_override = _mat _gold = _mat.duplicate()
add_child(body) _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 # ------------------------------------------------------------ Zustand

View File

@@ -3,7 +3,11 @@ extends Node3D
const START_DICE := 5 const START_DICE := 5
const BID_TIMEOUT := 30.0 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, const WURF_ZEIT := 1.1 ## wie lange die geworfene Münze zu sehen ist,
## bevor ihre Wirkung einsetzt ## bevor ihre Wirkung einsetzt
@@ -20,14 +24,16 @@ var my_values: Array = [] ## Client-Sicht (-1 = vernebelt)
var bid: Dictionary = {} var bid: Dictionary = {}
var turn := 0 var turn := 0
var starter := 0 var starter := 0
var fogged: Dictionary = {} ## seat -> Array[int] verdeckter Würfel
var peeked: Dictionary = {} ## viewer -> {target_seat: Array[[idx, value]]} 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 must_bid: Dictionary = {} ## seat -> bool
var hand: Dictionary = {} ## seat -> Array[String], Münzen beim Spieler 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 _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 last_bids: Dictionary = {} ## seat -> letzte Ansage dieser Runde
var log_lines: Array = [] var log_lines: Array = []
var phase := "lobby" var phase := "lobby"
@@ -48,6 +54,7 @@ func _ready() -> void:
func setup(cfg: Dictionary) -> void: func setup(cfg: Dictionary) -> void:
DiceAI.stufe = int(cfg.get("stufe", 1))
seats.clear() seats.clear()
var peers: Array = cfg.peers var peers: Array = cfg.peers
for p in peers: for p in peers:
@@ -76,6 +83,8 @@ func setup(cfg: Dictionary) -> void:
add_child(hud) add_child(hud)
hud.bid_pressed.connect(_on_bid_pressed) hud.bid_pressed.connect(_on_bid_pressed)
hud.challenge_pressed.connect(_on_challenge_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.booster_chosen.connect(_on_booster_chosen)
hud.quit_pressed.connect(_on_quit) hud.quit_pressed.connect(_on_quit)
hud.pause_changed.connect(_on_pause_changed) hud.pause_changed.connect(_on_pause_changed)
@@ -200,13 +209,11 @@ func _phase_roll() -> void:
deadline = 0.0 deadline = 0.0
reveal_total = 0 reveal_total = 0
bid = {} bid = {}
fogged.clear()
last_bids.clear() last_bids.clear()
peeked.clear() peeked.clear()
min_raise.clear() gezaehlt.clear()
verlauf.clear()
must_bid.clear() must_bid.clear()
mitte.clear()
_wurf_nr = 0
banner = "" banner = ""
_log("--- Neue Runde ---") _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]) _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, ## Ausspielen heißt: Münze in den Topf werfen. Geht nur am eigenen Zug, bevor
## Zahl lässt es bei der Grundwirkung. Beides hilft - verschenkt ist nie ein Zug. ## 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: 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 return
# Zwei Münzen gleichzeitig würden sich beim Würfeln in die Quere kommen.
if _muenze_laeuft: if _muenze_laeuft:
return return
if BoosterDB.needs_bid(id) and bid.is_empty():
return
var muenzen: Array = hand.get(seat, []) var muenzen: Array = hand.get(seat, [])
if not muenzen.has(id): if not muenzen.has(id):
return return
@@ -292,7 +301,10 @@ func _do_play(seat: int, id: String, target: int) -> void:
var stark := rng.randf() < BoosterDB.WAPPEN_CHANCE var stark := rng.randf() < BoosterDB.WAPPEN_CHANCE
_wurf_nr += 1 _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, _log("%s wirft %s - %s." % [seats[seat].name,
BoosterDB.get_booster(id).name, "Wappen" if stark else "Zahl"]) 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 _muenze_laeuft = false
if not _alive(): if not _alive():
return 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() _snapshot_all()
_push_state() _push_state()
@@ -319,15 +325,21 @@ func _wirkung(seat: int, id: String, target: int, stark: bool) -> void:
var vals: Array = host_values[seat] var vals: Array = host_values[seat]
var ziel: int = clampi(target, 0, maxi(0, vals.size() - 1)) var ziel: int = clampi(target, 0, maxi(0, vals.size() - 1))
match id: match id:
"reroll_all": "peek":
await _neu_werfen(seat, range(vals.size())) var info := {}
if stark and not host_values[seat].is_empty(): for o in _alive_seats():
_set_die(seat, rng.randi_range(0, host_values[seat].size() - 1), 6) if o != seat:
"reroll_one": info[o] = _zufalls_paare(o, 2 if stark else 1)
await _neu_werfen(seat, _ziel_und_zufall(seat, ziel, stark)) peeked[seat] = info
"set_six": "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): for k: int in _ziel_und_zufall(seat, ziel, stark):
_set_die(seat, k, 6) _set_die(seat, k, int(bid.face))
"gain_die": "gain_die":
seats[seat].count += 1 seats[seat].count += 1
table.set_dice_count(seat, seats[seat].count) 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]) await _neu_werfen(seat, [neu])
if stark: if stark:
_set_die(seat, neu, 6) _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": "bid_up":
if not bid.is_empty(): bid.count = int(bid.count) + (2 if stark else 1)
bid.count = int(bid.count) + (2 if stark else 1) last_bids[int(bid.seat)] = {"count": bid.count, "face": bid.face}
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})
"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
"gag": "gag":
if stark: if stark:
for o in _alive_seats(): for o in _alive_seats():
if o != seat: if o != seat:
must_bid[o] = true must_bid[o] = true
else: 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. ## Bis zu `n` verschiedene Würfelplätze eines Sitzes, zufällig gezogen.
@@ -453,12 +445,15 @@ func _phase_bid() -> Dictionary:
_push_state() _push_state()
if seats[turn].ai: if seats[turn].ai:
await _sleep(1.1) await _sleep(1.1)
# Bots werfen ihre Münzen, bevor sie ansagen. # Erst planen, dann passend dazu eine Münze werfen. Die kann die
await _bot_muenze(turn) # 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(): if not _alive():
return {} 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) var res := _apply_action(turn, a)
if not res.is_empty(): if not res.is_empty():
return res return res
@@ -471,7 +466,7 @@ func _phase_bid() -> Dictionary:
else: else:
# Zeit abgelaufen oder getrennt: automatisch entscheiden # Zeit abgelaufen oder getrennt: automatisch entscheiden
var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, 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) _log("%s wird automatisch gespielt." % seats[turn].name)
var res := _apply_action(turn, a) var res := _apply_action(turn, a)
if not res.is_empty(): if not res.is_empty():
@@ -480,24 +475,66 @@ func _phase_bid() -> Dictionary:
return {} return {}
## Ein Bot wirft manchmal eine Münze, statt sie bis zum Ausscheiden zu horten. ## Ein Bot wirft eine Münze, wenn sie zu seinem Vorhaben passt. `a` ist die
func _bot_muenze(seat: int) -> void: ## geplante Handlung. Gibt zurück, ob geworfen wurde.
func _bot_muenze(seat: int, a: Dictionary) -> bool:
var muenzen: Array = hand.get(seat, []) var muenzen: Array = hand.get(seat, [])
if muenzen.is_empty(): if muenzen.is_empty():
return return false
# Voller Vorrat: raus damit. Sonst nur ab und zu, sonst ist alles sofort weg. var vals: Array = host_values[seat]
var lust := 1.0 if muenzen.size() >= BoosterDB.HANDLIMIT else 0.35 var unknown: int = _total_dice() - vals.size()
if rng.randf() > lust: var will_bieten: bool = a.type == "bid"
return # Wie glaubwürdig ist das, was der Bot vorhat bzw. was auf dem Tisch liegt?
var id: String = muenzen[rng.randi_range(0, muenzen.size() - 1)] var eigenes: float = DiceAI.chance(vals, unknown, int(a.count), int(a.face)) if will_bieten else 0.0
if id == "bid_up" and bid.is_empty(): var liegt: float = DiceAI.chance(vals, unknown, int(bid.count), int(bid.face)) if not bid.is_empty() else 0.0
return
await _do_play(seat, id, DiceAI.target_for(id, host_values[seat])) 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) 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. ## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis.
func _apply_action(seat: int, a: Dictionary) -> Dictionary: 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)): 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 # Wer einen Maulkorb hat, darf sich nicht über ein ungültiges Gebot in
# die Anfechtung retten - dann wird auf das kleinste gültige korrigiert. # 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()} return {"challenger": seat, "bid": bid.duplicate()}
bid = {"seat": seat, "count": int(a.count), "face": int(a.face)} bid = {"seat": seat, "count": int(a.count), "face": int(a.face)}
last_bids[seat] = {"count": bid.count, "face": bid.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) must_bid.erase(seat)
_log("%s sagt %d x %d an." % [seats[seat].name, bid.count, bid.face]) _log("%s sagt %d x %d an." % [seats[seat].name, bid.count, bid.face])
return {} return {}
## Kleinstes Gebot, das dieser Platz noch ansagen darf ({} = gar keins mehr). ## 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(): if bid.is_empty():
return {"count": 1, "face": 1} return {"count": 1, "face": 1}
var mr: int = min_raise.get(seat, 1) return DiceAI.lowest_raise(bid, _total_dice())
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 {}
func _valid_bid(seat: int, count: int, face: int) -> bool: 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 return false
if bid.is_empty(): if bid.is_empty():
return true return true
var mr: int = min_raise.get(seat, 1) if count > bid.count:
if count >= bid.count + mr:
return true return true
if mr > 1:
return false
return count == bid.count and face > bid.face return count == bid.count and face > bid.face
@@ -548,19 +577,13 @@ func _phase_reveal(result: Dictionary) -> void:
phase = "reveal" phase = "reveal"
deadline = 0.0 deadline = 0.0
var b: Dictionary = result.bid var b: Dictionary = result.bid
var challenger: int = result.challenger var rufer: int = result.challenger
var total := 0 var genau: bool = result.get("exact", false)
for i in _alive_seats(): var total := _count_face(int(b.face))
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
# 1. Becher hoch. Noch kein Urteil - erst schauen.
reveal_total = -1
banner = "Genau?" if genau else "Aufdecken"
for i in _alive_seats(): for i in _alive_seats():
table.arrange_and_show(i, host_values[i], rng) table.arrange_and_show(i, host_values[i], rng)
table.set_cup_down(i, false) table.set_cup_down(i, false)
@@ -572,20 +595,64 @@ func _phase_reveal(result: Dictionary) -> void:
if _peer_live(peer): if _peer_live(peer):
_reveal.rpc_id(peer, values, banner) _reveal.rpc_id(peer, values, banner)
_push_state() _push_state()
await _sleep(REVEAL_PAUSE) await _sleep(ZEIT_BECHER)
if not _alive(): if not _alive():
return return
seats[loser].count -= 1 # 2. Zählen. Die Ansage in der Mitte färbt sich jetzt ein.
if seats[loser].count <= 0: reveal_total = total
seats[loser].alive = false banner = "%d gefunden, %d angesagt" % [total, b.count]
seats[loser].count = 0 _push_state()
host_values[loser] = [] await _sleep(ZEIT_ZAEHLEN)
table.set_dice_count(loser, 0) if not _alive():
_log("%s scheidet aus." % seats[loser].name) 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: else:
_zieh_muenze(loser) var loser := rufer if total >= int(b.count) else int(b.seat)
starter = loser if seats[loser].alive else _next_alive(loser) 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 # Nur wer noch spielt, bekommt den Becher wieder. Auf einer leeren Zone
# sähe er aus, als lägen dort noch Würfel. # sähe er aus, als lägen dort noch Würfel.
for i in seats.size(): for i in seats.size():
@@ -595,6 +662,22 @@ func _phase_reveal(result: Dictionary) -> void:
_push_state() _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: func _all_values() -> Array:
var out: Array = [] var out: Array = []
for i in seats.size(): for i in seats.size():
@@ -715,15 +798,9 @@ func _push_state() -> void:
_state.rpc_id(peer, _state_for(i)) _state.rpc_id(peer, _state_for(i))
## Welche Münze liegt von wem in der Mitte? Beim Würfeln steht noch nichts ## Was liegt im Topf? Der bleibt auch übers Würfeln hinweg liegen.
## an, danach ist es offen.
func _round_boosters() -> Array: func _round_boosters() -> Array:
var out: Array = [] return topf.duplicate(true)
if phase == "roll":
return out
for i in seats.size():
out.append(mitte.get(i, {}))
return out
## Was jeder Platz in dieser Runde zuletzt angesagt hat. ## Was jeder Platz in dieser Runde zuletzt angesagt hat.
@@ -739,25 +816,22 @@ func _state_for(seat: int) -> Dictionary:
if phase == "roll": if phase == "roll":
for _k in seats[seat].count: # während des Wurfs steht der Wert noch nicht fest for _k in seats[seat].count: # während des Wurfs steht der Wert noch nicht fest
vis.append(-1) vis.append(-1)
elif phase == "reveal":
vis = host_values[seat].duplicate() # aufgedeckt wird ohne Nebel
else: else:
var fg: Array = fogged.get(seat, []) vis = host_values[seat].duplicate()
for k in host_values[seat].size():
vis.append(-1 if fg.has(k) else host_values[seat][k])
var pub: Array = [] var pub: Array = []
for s in seats: for s in seats:
pub.append({"name": s.name, "count": s.count, "alive": s.alive, "ai": s.ai}) pub.append({"name": s.name, "count": s.count, "alive": s.alive, "ai": s.ai})
return { return {
"phase": phase, "turn": turn, "bid": bid, "seats": pub, "values": vis, "phase": phase, "turn": turn, "bid": bid, "seats": pub, "values": vis,
"banner": banner, "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(), "must_bid": must_bid.get(seat, false), "total": _total_dice(),
"time_left": 0.0 if deadline <= 0.0 else maxf(0.0, deadline - _now()), "time_left": 0.0 if deadline <= 0.0 else maxf(0.0, deadline - _now()),
"open": _all_values() if phase == "reveal" else [], "open": _all_values() if phase == "reveal" else [],
"boosts": _round_boosters(), "boosts": _round_boosters(),
"actual": reveal_total if phase == "reveal" else 0, "actual": reveal_total if phase == "reveal" else 0,
"bids": _seat_bids(), "bids": _seat_bids(),
"verlauf": verlauf.duplicate(true),
"hand": (hand.get(seat, []) as Array).duplicate(), "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_cup_down(i, seats[i].alive)
table.set_dice_visible(local_seat, true) table.set_dice_visible(local_seat, true)
_refresh_labels() _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", []) var bids: Array = st.get("bids", [])
for i in mini(seats.size(), bids.size()): for i in mini(seats.size(), bids.size()):
var b: Dictionary = bids[i] var b: Dictionary = bids[i]
@@ -804,15 +894,6 @@ func _apply_state(st: Dictionary) -> void:
# ---------------------------------------------------------------- Aktionen # ---------------------------------------------------------------- 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: func _on_bid_pressed(count: int, face: int) -> void:
if multiplayer.is_server(): if multiplayer.is_server():
_do_bid(local_seat, count, face) _do_bid(local_seat, count, face)
@@ -827,6 +908,13 @@ func _on_challenge_pressed() -> void:
_req_challenge.rpc_id(1) _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: func _on_booster_chosen(id: String, target: int) -> void:
if multiplayer.is_server(): if multiplayer.is_server():
_do_play(local_seat, id, target) _do_play(local_seat, id, target)
@@ -849,6 +937,19 @@ func _on_pause_changed(on: bool) -> void:
_push_state() _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: func _on_quit() -> void:
if _quitting: if _quitting:
return return
@@ -871,6 +972,11 @@ func _req_challenge() -> void:
_do_challenge(_seat_of(multiplayer.get_remote_sender_id())) _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") @rpc("any_peer", "reliable")
func _req_boost(id: String, target: int) -> void: func _req_boost(id: String, target: int) -> void:
_do_play(_seat_of(multiplayer.get_remote_sender_id()), id, target) _do_play(_seat_of(multiplayer.get_remote_sender_id()), id, target)
@@ -904,3 +1010,15 @@ func _do_challenge(seat: int) -> void:
return return
_answered[seat] = _apply_action(seat, {"type": "challenge"}) _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"})

View File

@@ -3,6 +3,8 @@ extends CanvasLayer
signal bid_pressed(count: int, face: int) signal bid_pressed(count: int, face: int)
signal challenge_pressed signal challenge_pressed
signal exact_pressed
signal rematch_pressed
signal booster_chosen(id: String, target: int) signal booster_chosen(id: String, target: int)
signal quit_pressed signal quit_pressed
signal pause_changed(paused: bool) signal pause_changed(paused: bool)
@@ -33,17 +35,22 @@ var _count_min := 1
var _count_max := 30 var _count_max := 30
var _bid_count := 0 ## aktuelles Gebot, 0 = noch keins var _bid_count := 0 ## aktuelles Gebot, 0 = noch keins
var _bid_face := 0 var _bid_face := 0
var _min_raise := 1
var _actual := 0 ## beim Aufdecken: tatsächlich gefundene Würfel var _actual := 0 ## beim Aufdecken: tatsächlich gefundene Würfel
var face_buttons: Array[Button] = [] var face_buttons: Array[Button] = []
var bid_btn: Button var bid_btn: Button
var challenge_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 action_panel: PanelContainer
var hand_panel: PanelContainer var hand_panel: PanelContainer
var hand_box: HBoxContainer var hand_box: HBoxContainer
var boost_slot: CenterContainer var boost_slot: CenterContainer
var pause_slot: Control var pause_slot: Control
var over_slot: Control
var over_title: Label
var over_again: Button
var pause_hint: Label var pause_hint: Label
var boost_panel: PanelContainer var boost_panel: PanelContainer
var boost_cards: HBoxContainer var boost_cards: HBoxContainer
@@ -102,34 +109,17 @@ class MuenzIcon extends Control:
func _zeichen(m: Vector2, s: float) -> void: func _zeichen(m: Vector2, s: float) -> void:
match id: match id:
"reroll_all": "peek":
_wuerfel(m + Vector2(-s * 0.16, -s * 0.06), s * 0.42, 5) _auge(m, s)
_wuerfel(m + Vector2(s * 0.18, s * 0.10), s * 0.42, 2) "count":
"reroll_one": _striche(m, s)
_wuerfel(m + Vector2(0, s * 0.10), s * 0.38, 3) "set_face":
_bogen(m + Vector2(0, s * 0.10), s * 0.34) _schiefer_wuerfel(m, s * 0.50)
"set_six":
_wuerfel(m, s * 0.54, 6)
"gain_die": "gain_die":
_wuerfel(m + Vector2(-s * 0.10, 0), s * 0.46, 4) _wuerfel(m + Vector2(-s * 0.10, 0), s * 0.46, 4)
_plus(m + Vector2(s * 0.26, -s * 0.20), s * 0.20) _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": "bid_up":
_pfeil(m, s * 0.52, true) _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": "gag":
var r := Rect2(m - Vector2(s * 0.28, s * 0.16), Vector2(s * 0.56, s * 0.32)) 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) draw_rect(r, farbe, false, s * 0.07)
@@ -138,6 +128,23 @@ class MuenzIcon extends Control:
_: _:
draw_circle(m, s * 0.26, farbe) 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: func _wuerfel(mitte: Vector2, kante: float, augen: int) -> void:
var r := Rect2(mitte - Vector2(kante, kante) * 0.5, Vector2(kante, kante)) var r := Rect2(mitte - Vector2(kante, kante) * 0.5, Vector2(kante, kante))
draw_rect(r, farbe, true) draw_rect(r, farbe, true)
@@ -146,13 +153,6 @@ class MuenzIcon extends Control:
for p: Vector2 in DieIcon.PIPS[augen]: for p: Vector2 in DieIcon.PIPS[augen]:
draw_circle(mitte + p * weg, punkt, FLAECHE) 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: 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, 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) 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) _place(pl, 1, 0, 1, 0, -16, 16, -16, 16)
pl.grow_horizontal = Control.GROW_DIRECTION_BEGIN pl.grow_horizontal = Control.GROW_DIRECTION_BEGIN
root.add_child(pl) 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 = VBoxContainer.new()
players_box.add_theme_constant_override("separation", 6) 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 # --- Aktive Booster oben links, Breite richtet sich nach dem Inhalt
var bo := _panel() var bo := _panel()
@@ -421,6 +429,13 @@ func _build() -> void:
challenge_btn.pressed.connect(_on_challenge) challenge_btn.pressed.connect(_on_challenge)
av.add_child(challenge_btn) 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 = _label("", 18, DIM)
timer_bid.custom_minimum_size = Vector2(TIMER_W, 0) timer_bid.custom_minimum_size = Vector2(TIMER_W, 0)
timer_bid.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT timer_bid.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
@@ -534,6 +549,34 @@ func _build() -> void:
leave.pressed.connect(_on_quit) leave.pressed.connect(_on_quit)
pv.add_child(leave) 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: func _on_quit() -> void:
quit_pressed.emit() 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 ## 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. ## über eine höhere Augenzahl - die kleineren werden deshalb ausgeblendet.
func _face_allowed(f: int) -> bool: 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 return true
if _min_raise > 1:
return false
return _count == _bid_count and f > _bid_face return _count == _bid_count and f > _bid_face
@@ -568,12 +609,12 @@ func _lowest_bid() -> void:
if _bid_count <= 0: if _bid_count <= 0:
_set_count(1) _set_count(1)
_on_face(1) _on_face(1)
elif _min_raise == 1 and _bid_face < 6: elif _bid_face < 6:
# Gleiche Anzahl reicht, wenn die Augenzahl steigt. # Gleiche Anzahl reicht, wenn die Augenzahl steigt.
_set_count(_bid_count) _set_count(_bid_count)
_on_face(_bid_face + 1) _on_face(_bid_face + 1)
else: else:
_set_count(_bid_count + _min_raise) _set_count(_bid_count + 1)
_on_face(1) _on_face(1)
@@ -597,6 +638,10 @@ func _on_challenge() -> void:
challenge_pressed.emit() challenge_pressed.emit()
func _on_exact() -> void:
exact_pressed.emit()
func _unhandled_key_input(event: InputEvent) -> void: func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo \ if event is InputEventKey and event.pressed and not event.echo \
and event.keycode == KEY_ESCAPE: 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 # Beim Aufdecken bleibt die Ansage stehen, wie sie war: die angesagten
# Würfel normal, die fehlenden davon rot. Gab es mehr als angesagt, # Würfel normal, die fehlenden davon rot. Gab es mehr als angesagt,
# kommen die überzähligen grün dahinter. # 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 angesagt := int(st.bid.count)
var gezeigt := maxi(angesagt, _actual) if faerben else angesagt var gezeigt := maxi(angesagt, _actual) if faerben else angesagt
var px := _fit_dice(gezeigt) 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)) bid_row.add_child(DieIcon.new(int(st.bid.face), px, bg))
_zeichne_hand(st) _zeichne_hand(st)
_zeichne_mitte(st, my_seat) _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 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 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) action_panel.modulate = Color(1, 1, 1, 1.0 if my_turn else 0.45)
bid_btn.disabled = not my_turn bid_btn.disabled = not my_turn
_count_max = maxi(1, int(st.total)) _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_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) _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 # Ein Maulkorb sperrt das Anfechten - ausser es gäbe gar kein gültiges
# Gebot mehr, dann bliebe sonst kein einziger Knopf übrig. # Gebot mehr, dann bliebe sonst kein einziger Knopf übrig.
var gebot_moeglich: bool = _bid_count + _min_raise <= _count_max \ var gebot_moeglich: bool = _bid_count + 1 <= _count_max or _bid_face < 6
or (_min_raise == 1 and _bid_face < 6)
challenge_btn.disabled = not my_turn or st.bid.is_empty() \ challenge_btn.disabled = not my_turn or st.bid.is_empty() \
or (st.must_bid and gebot_moeglich) or (st.must_bid and gebot_moeglich)
var low := 1 exact_btn.disabled = challenge_btn.disabled
if _bid_count > 0: _count_min = clampi(maxi(1, _bid_count), 1, _count_max)
low = _bid_count + (_min_raise if _min_raise > 1 else 0)
_count_min = clampi(low, 1, _count_max)
_set_count(_count) _set_count(_count)
if kam_dran: if kam_dran:
_lowest_bid() _lowest_bid()
@@ -803,10 +851,12 @@ func _zeichne_hand(st: Dictionary) -> void:
# stünden nur klickbar im Weg. # stünden nur klickbar im Weg.
var dabei: bool = _my_seat < st.seats.size() and st.seats[_my_seat].alive var dabei: bool = _my_seat < st.seats.size() and st.seats[_my_seat].alive
hand_panel.visible = dabei and not muenzen.is_empty() 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 # Jeder Zustand kommt mehrmals an. Ein Neuaufbau würde die Münze unter dem
# Zeiger wegreissen und die Erklärung mitnehmen. # 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: if sig == _hand_sig:
return return
_hand_sig = sig _hand_sig = sig
@@ -819,7 +869,10 @@ func _zeichne_hand(st: Dictionary) -> void:
var btn := Button.new() var btn := Button.new()
btn.custom_minimum_size = Vector2(104, 120) 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 # Ein gesperrter Knopf meldet keine Maus mehr; die Erklärung soll aber
# auch dann kommen, wenn gerade nicht geworfen werden darf. # auch dann kommen, wenn gerade nicht geworfen werden darf.
btn.mouse_filter = Control.MOUSE_FILTER_STOP btn.mouse_filter = Control.MOUSE_FILTER_STOP
@@ -845,35 +898,32 @@ func _zeichne_hand(st: Dictionary) -> void:
v.add_child(name_l) 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. ## Auch hier gilt: nur neu bauen, wenn sich etwas geändert hat.
func _zeichne_mitte(st: Dictionary, my_seat: int) -> void: func _zeichne_mitte(st: Dictionary, my_seat: int) -> void:
var boosts: Array = st.get("boosts", []) var boosts: Array = st.get("boosts", [])
# Der neueste Wurf muss bei jedem Zustand geprüft werden, auch ohne Neubau. # Der neueste Wurf muss bei jedem Zustand geprüft werden, auch ohne Neubau.
var neu := {} var neu := {}
for i in mini(boosts.size(), st.seats.size()): for m in boosts:
var m: Dictionary = boosts[i] if int(m.get("nr", 0)) > int(neu.get("nr", 0)):
if not m.is_empty() and int(m.get("nr", 0)) > int(neu.get("nr", 0)): neu = m
neu = {"nr": m.get("nr", 0), "id": m.get("id", ""),
"stark": m.get("stark", false), "seat": i}
_pruefe_wurf(neu, st) _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: if sig == _mitte_sig:
return return
_mitte_sig = sig _mitte_sig = sig
_clear(boosts_box) _clear(boosts_box)
var any := false boosts_box.add_child(_label("Topf" if not boosts.is_empty() else "Topf: leer", 13, DIM))
for i in mini(boosts.size(), st.seats.size()): for m in boosts:
var m: Dictionary = boosts[i]
if m.is_empty():
continue
var id := String(m.get("id", "")) var id := String(m.get("id", ""))
var b: Dictionary = BoosterDB.get_booster(id) var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty(): if b.is_empty():
continue continue
any = true var wer := int(m.get("seat", 0))
var line := HBoxContainer.new() var line := HBoxContainer.new()
line.add_theme_constant_override("separation", 5) line.add_theme_constant_override("separation", 5)
# Der Container muss die Maus annehmen, sonst gibt es kein Überfahren. # 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) boosts_box.add_child(line)
_info_an(line, id, true) _info_an(line, id, true)
line.add_child(MuenzIcon.new(id, _kind_col(b), 22.0)) 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))) line.add_child(_label(String(b.get("name", "")), 14, _kind_col(b)))
if bool(m.get("stark", false)): if bool(m.get("stark", false)):
line.add_child(_label("· Wappen", 14, ACCENT)) line.add_child(_label("· Wappen", 14, ACCENT))
if int(st.min_raise) > 1: if wer < st.seats.size():
boosts_box.add_child(_label("Störfeuer: mind. %d erhöhen" % st.min_raise, 14, ACCENT)) line.add_child(_label("von " + _short(String(st.seats[wer].name)), 13,
any = true 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: if st.must_bid:
boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT)) 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 ## 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 return
_letzter_wurf = nr _letzter_wurf = nr
var seat := int(neu.get("seat", 0)) var seat := int(neu.get("seat", 0))
if seat >= st.seats.size():
return
_zeig_wurf(String(neu.get("id", "")), bool(neu.get("stark", false)), _zeig_wurf(String(neu.get("id", "")), bool(neu.get("stark", false)),
_short(String(st.seats[seat].name))) _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.text = "%s wirft\n%s" % [wer, b.name]
wurf_label.add_theme_color_override("font_color", DIM) wurf_label.add_theme_color_override("font_color", DIM)
wurf_slot.visible = true wurf_slot.visible = true
Klang.muenze()
if _wurf_tween != null and _wurf_tween.is_valid(): if _wurf_tween != null and _wurf_tween.is_valid():
_wurf_tween.kill() _wurf_tween.kill()
_wurf_tween = create_tween() _wurf_tween = create_tween()

85
scripts/klang.gd Normal file
View File

@@ -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)

View File

@@ -30,6 +30,12 @@ func _ready() -> void:
menu = preload("res://scripts/menu.gd").new() menu = preload("res://scripts/menu.gd").new()
add_child(menu) 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. ## Das Spiel startet im Vollbild; F11 schaltet um.
func _unhandled_key_input(event: InputEvent) -> void: func _unhandled_key_input(event: InputEvent) -> void:

View File

@@ -10,6 +10,8 @@ var current := "main"
# Einzelspieler # Einzelspieler
var solo_bots: NumberRow var solo_bots: NumberRow
var solo_stufe: OptionButton
var lobby_stufe: OptionButton
# Mehrspieler # Mehrspieler
var name_edit: LineEdit var name_edit: LineEdit
@@ -37,6 +39,7 @@ func _ready() -> void:
screens["multi"] = _screen(root, _build_multi()) screens["multi"] = _screen(root, _build_multi())
screens["join"] = _screen(root, _build_join()) screens["join"] = _screen(root, _build_join())
screens["lobby"] = _screen(root, _build_lobby()) screens["lobby"] = _screen(root, _build_lobby())
screens["regeln"] = _screen(root, _build_regeln())
Net.lobby_changed.connect(_refresh) Net.lobby_changed.connect(_refresh)
Net.connection_failed.connect(_on_failed) Net.connection_failed.connect(_on_failed)
@@ -85,6 +88,17 @@ func _text(t: String, size: int, col := Color.WHITE) -> Label:
return l 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: func _row(caption: String, ctrl: Control) -> HBoxContainer:
var h := HBoxContainer.new() var h := HBoxContainer.new()
var l := Label.new() var l := Label.new()
@@ -158,10 +172,43 @@ func _build_main() -> Control:
_title(box) _title(box)
_button("Einzelspieler", box, _show.bind("single")) _button("Einzelspieler", box, _show.bind("single"))
_button("Mehrspieler", box, _show.bind("multi")) _button("Mehrspieler", box, _show.bind("multi"))
_button("Regeln", box, _show.bind("regeln"))
_button("Beenden", box, func() -> void: get_tree().quit()) _button("Beenden", box, func() -> void: get_tree().quit())
return box 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: func _build_single() -> Control:
var box := _box(400) var box := _box(400)
_title(box) _title(box)
@@ -169,6 +216,8 @@ func _build_single() -> Control:
box.add_child(_text("Bots", 15)) box.add_child(_text("Bots", 15))
solo_bots = NumberRow.new(1, BOT_NAMES_MAX, 3) solo_bots = NumberRow.new(1, BOT_NAMES_MAX, 3)
box.add_child(solo_bots) box.add_child(solo_bots)
solo_stufe = _stufen_wahl()
box.add_child(_row("Bots spielen", solo_stufe))
box.add_child(HSeparator.new()) box.add_child(HSeparator.new())
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8) row.add_theme_constant_override("separation", 8)
@@ -244,6 +293,8 @@ func _build_lobby() -> Control:
box.add_child(_text("Bots", 15)) box.add_child(_text("Bots", 15))
lobby_bots = NumberRow.new(0, BOT_NAMES_MAX, 1) lobby_bots = NumberRow.new(0, BOT_NAMES_MAX, 1)
box.add_child(lobby_bots) box.add_child(lobby_bots)
lobby_stufe = _stufen_wahl()
box.add_child(_row("Bots spielen", lobby_stufe))
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8) row.add_theme_constant_override("separation", 8)
@@ -257,7 +308,7 @@ func _build_lobby() -> Control:
# ------------------------------------------------------------ Aktionen # ------------------------------------------------------------ Aktionen
func _on_solo_start() -> void: 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. ## Ein leeres Namensfeld ergäbe einen namenlosen Platz am Tisch.
@@ -287,7 +338,7 @@ func _on_start() -> void:
if humans + ai < 2: if humans + ai < 2:
_sagen("Mindestens 2 Spieler nötig (Mitspieler oder Bots).") _sagen("Mindestens 2 Spieler nötig (Mitspieler oder Bots).")
return return
Net.request_start(ai) Net.request_start(ai, lobby_stufe.selected)
func _on_failed(msg: String) -> void: func _on_failed(msg: String) -> void:
@@ -314,6 +365,7 @@ func _refresh() -> void:
code_label.text = Net.room_code code_label.text = Net.room_code
start_btn.disabled = not Net.is_owner start_btn.disabled = not Net.is_owner
lobby_bots.anpassen(Net.MAX_HUMANS - Net.lobby.size(), 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 = [] var names: Array = []
for id: int in Net.lobby: for id: int in Net.lobby:

View File

@@ -30,6 +30,8 @@ var owner_id := 0
var is_owner := false var is_owner := false
var room_code := "" var room_code := ""
var in_game := false var in_game := false
## Wunsch für eine neue Solopartie; das frisch geladene Menü löst ihn ein.
var rematch: Dictionary = {}
# ── Server ─────────────────────────────────────────────────────────────────── # ── Server ───────────────────────────────────────────────────────────────────
var dedicated := false var dedicated := false
@@ -228,7 +230,7 @@ func _push_lobby(code: String, data: Dictionary, besitzer: int) -> void:
# ── Start ──────────────────────────────────────────────────────────────────── # ── Start ────────────────────────────────────────────────────────────────────
## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots. ## 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 player_name = pname
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
room_code = SOLO room_code = SOLO
@@ -237,16 +239,17 @@ func start_singleplayer(pname: String, ai_count: int) -> void:
is_owner = true is_owner = true
in_game = true in_game = true
lobby_changed.emit() 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. ## Der Host bittet den Server um den Start.
func request_start(ai_count: int) -> void: func request_start(ai_count: int, stufe := 1) -> void:
_req_start.rpc_id(1, ai_count) _req_start.rpc_id(1, ai_count, stufe)
@rpc("any_peer", "reliable") @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(): if not multiplayer.is_server():
return return
var id := multiplayer.get_remote_sender_id() var id := multiplayer.get_remote_sender_id()
@@ -259,7 +262,8 @@ func _req_start(ai_count: int) -> void:
if order.size() + ai_count < 2: if order.size() + ai_count < 2:
return return
raum.in_game = true 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) _start_zustellen(code, cfg)
game_started.emit(code, cfg) game_started.emit(code, cfg)

View File

@@ -30,6 +30,9 @@ var bid_icons: Array[Sprite3D] = []
var _bid_anchor: Array[Vector3] = [] var _bid_anchor: Array[Vector3] = []
var plates: Array[Sprite3D] = [] var plates: Array[Sprite3D] = []
var rims: Array[MeshInstance3D] = [] ## Rand der Zone, leuchtet beim Zug 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 dice: Array = [] ## seat -> Array[Die]
var camera: Camera3D var camera: Camera3D
@@ -472,6 +475,85 @@ func freeze_dice(seat: int) -> void:
d.set_kinematic(true) 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 ## 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. ## Szene ist das von jeder Ecke aus zu sehen - ein farbiger Name allein nicht.
func set_active_seat(seat: int) -> void: func set_active_seat(seat: int) -> void:

81
test_cli.gd Normal file
View File

@@ -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()

6
test_cli.tscn Normal file
View File

@@ -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")

32
test_srv.gd Normal file
View File

@@ -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()

6
test_srv.tscn Normal file
View File

@@ -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")