This commit is contained in:
team3
2026-09-16 01:37:30 +02:00
parent ae75ed8fa3
commit 9b3cd55ba7
6 changed files with 549 additions and 160 deletions

View File

@@ -1,7 +1,7 @@
# Liar's Dice 3D
Würfel-Bluffspiel in Godot 4.7 mit echter 3D-Physik, Mehrspieler über Raum-Codes,
KI-Bots und einem Booster-System aus Buffs und Debuffs.
KI-Bots und Münzen als Buffs und Debuffs.
## Starten
@@ -150,38 +150,61 @@ Mitspieler den ganzen Tisch dauerhaft blockieren.
Wer keine Würfel mehr hat, scheidet aus. Der Letzte gewinnt.
## Karten
## Münzen
Karten ersetzen die frühere Booster-Phase. Die kostete jede Runde eine eigene
Münzen ersetzen die frühere Booster-Phase. Die kostete jede Runde eine eigene
Wartezeit und riss den Ablauf auseinander.
* Wer einen Würfel abgibt, **zieht eine Karte**. Das ist der einzige Nachschub,
also bekommt der etwas zurück, der gerade hinten liegt.
* Gespielt wird **jederzeit während der Ansagen**, nicht nur am eigenen Zug.
* Mehr als **drei** Karten passen nicht auf die Hand.
* Die Hand liegt offen unten links; ein Klick spielt die Karte sofort. Nur
* Jeder startet mit **einer** Münze.
* Wer danach einen Würfel abgibt, **bekommt eine Münze**. Das ist der einzige
Nachschub, also bekommt der etwas zurück, der gerade hinten liegt.
* Geworfen wird **jederzeit während der Ansagen**, nicht nur am eigenen Zug.
* Mehr als **drei** Münzen passen nicht in den Vorrat.
* Der Vorrat liegt offen unten links; ein Klick wirft die Münze sofort. Nur
*Feinschliff* und *Glücksgriff* fragen noch, welcher Würfel gemeint ist.
| Karte | Art | Wirkung |
|---|---|---|
| Neuwurf | Buff | Alle eigenen Würfel neu werfen |
| Feinschliff | Buff | Einen gewählten eigenen Würfel neu werfen |
| Glücksgriff | Buff | Ein gewählter Würfel wird zur 6 |
| Nachschub | Buff | Du bekommst einen Würfel dazu |
| Widerruf | Buff | Hebt Nebel, Störfeuer und Maulkorb auf, die auf dir liegen |
| Spähen | Buff | Von jedem Gegner wird ein zufälliger Würfel aufgedeckt |
| Erhöhen | Debuff | Die laufende Ansage steigt um 1 |
| Absenken | Debuff | Bei jedem Gegner sinkt ein zufälliger Würfel um 1 |
| Nebel | Debuff | Jeder Gegner sieht einen eigenen Würfel nicht mehr |
| Störfeuer | Debuff | Gegner müssen diese Runde um mindestens 2 erhöhen |
| Maulkorb | Debuff | Wer gerade dran ist, muss bieten und darf nicht anfechten |
### Der Wurf
Beim Ausspielen wird die Münze geworfen und landet in der Mitte.
* **Zahl** – die Grundwirkung tritt ein.
* **Wappen** – die Wirkung fällt stärker aus. Die Chance ist die Hälfte.
Die Grundwirkung kommt immer. Kein Zug ist verschenkt, und die Entscheidung,
wann geworfen wird, bleibt beim Spieler. Der Wurf selbst ist reine Zugabe.
Der Ausgang wird beim Host ausgewürfelt und mit dem Zustand verschickt. Die
Drehung im HUD ist nur die Ansicht dazu - sie endet auf der Seite, die schon
feststeht. Würfelphysik wäre hier nicht synchron zu bekommen.
**Der Wurf verstärkt die Würfelzahl nie.** Sie ist die Kernressource; ein
Würfel mehr aus Glück würde die Partie kippen. *Nachschub* gibt einen Würfel -
das ist seine Grundwirkung, und Wappen macht daraus nur eine 6. Verstärkt
werden sonst nur Beträge und Reichweiten.
| Münze | Art | Zahl | Wappen |
|---|---|---|---|
| Neuwurf | Buff | Alle eigenen Würfel neu werfen | Danach wird einer davon zur 6 |
| Feinschliff | Buff | Einen gewählten eigenen Würfel neu werfen | Ein zweiter, zufälliger kommt mit |
| Glücksgriff | Buff | Ein gewählter Würfel wird zur 6 | Ein zweiter, zufälliger wird auch zur 6 |
| Nachschub | Buff | Du bekommst einen Würfel dazu | Der neue Würfel zeigt sofort eine 6 |
| Widerruf | Buff | Hebt Nebel, Störfeuer und Maulkorb auf, die auf dir liegen | Die Münze bleibt dir erhalten |
| Spähen | Buff | Von jedem Gegner wird ein zufälliger Würfel aufgedeckt | Es sind zwei je Gegner |
| Erhöhen | Debuff | Die laufende Ansage steigt um 1 | Sie steigt um 2 |
| Absenken | Debuff | Bei jedem Gegner sinkt ein zufälliger Würfel um 1 | Er sinkt um 2 |
| Nebel | Debuff | Jeder Gegner sieht einen eigenen Würfel nicht mehr | Es sind zwei Würfel |
| Störfeuer | Debuff | Gegner müssen diese Runde um mindestens 2 erhöhen | Mindestens 3 |
| Maulkorb | Debuff | Wer gerade dran ist, muss bieten und darf nicht anfechten | Alle Gegner müssen bieten |
Welche Wirkungen es am Ende gibt, steht noch nicht fest. Der Wurf und die
Verstärkung sind der Kern; die Tabelle ist die aktuelle Füllung.
## Aufbau
| Datei | Inhalt |
|---|---|
| `scripts/net.gd` | Autoload: WebSocket-Verbindung, Räume, Lobby, Spielstart |
| `scripts/boosters.gd` | Autoload: Kartenkatalog |
| `scripts/boosters.gd` | Autoload: Münzkatalog mit Grund- und Wappenwirkung |
| `scripts/game.gd` | Spiellogik (host-autoritativ), RPCs, Zustandsabgleich |
| `scripts/table.gd` | Aufbau der 3D-Szene: Tisch, Würfelzonen, Becher, Kamera |
| `scripts/die.gd` | Physik-Würfel, Ablesen der Augenzahl aus der Ruhelage |

View File

@@ -102,7 +102,7 @@ static func _is_higher(count: int, face: int, bid: Dictionary, min_raise: int) -
return count == bid.count and face > bid.face
## Welcher eigene Würfel soll dran glauben? Für Karten mit Zielwahl.
## Welcher eigene Würfel soll dran glauben? Für Münzen mit Zielwahl.
static func target_for(id: String, values: Array) -> int:
if values.is_empty():
return 0

View File

@@ -1,70 +1,91 @@
extends Node
## Katalog aller Karten.
## Katalog aller Münzen.
##
## Karten gibt es nicht mehr rundenweise zur Auswahl. Wer einen Würfel
## verliert, zieht eine. Gespielt wird jederzeit während der Ansagen - deshalb
## wirkt jede Karte sofort und braucht keine eigene Phase mehr.
## Münzen gibt es nicht rundenweise zur Auswahl. Wer einen Würfel verliert,
## bekommt eine. Gespielt wird jederzeit während der Ansagen - deshalb wirkt
## jede Münze sofort und braucht keine eigene Phase mehr.
##
## Beim Ausspielen wird die Münze geworfen. Wappen oben heißt verstärkt. Die
## Grundwirkung kommt immer, der Wurf legt nur eine Schippe drauf. Deshalb ist
## kein Zug verschenkt, und die Entscheidung bleibt beim Spieler.
enum Kind { BUFF, DEBUFF }
## Mehr als das passt nicht auf die Hand; sonst hortet man bis zum Schluss.
const HANDLIMIT := 3
## Eine Münze hat zwei Seiten, also die Hälfte.
const WAPPEN_CHANCE := 0.5
## Die Würfelzahl wird bewusst nie verstärkt. Sie ist die Kernressource -
## ein Würfel mehr aus Glück würde die Partie kippen.
const ALL := [
{
"id": "reroll_all", "kind": Kind.BUFF, "target": false,
"name": "Neuwurf",
"desc": "Wirf alle deine Würfel noch einmal neu.",
"stark": "Danach wird einer davon zur 6.",
},
{
"id": "reroll_one", "kind": Kind.BUFF, "target": true,
"name": "Feinschliff",
"desc": "Wirf einen selbst gewählten Würfel neu.",
"stark": "Ein zweiter, zufälliger Würfel kommt mit.",
},
{
"id": "set_six", "kind": Kind.BUFF, "target": true,
"name": "Glücksgriff",
"desc": "Ein gewählter Würfel wird zur 6.",
"stark": "Ein zweiter, zufälliger Würfel wird auch zur 6.",
},
{
"id": "gain_die", "kind": Kind.BUFF, "target": false,
"name": "Nachschub",
"desc": "Du bekommst einen Würfel dazu.",
"stark": "Der neue Würfel zeigt sofort eine 6.",
},
{
"id": "cancel", "kind": Kind.BUFF, "target": false,
"name": "Widerruf",
"desc": "Hebt Nebel, Störfeuer und Maulkorb auf, die auf dir liegen.",
"stark": "Die Münze bleibt dir erhalten.",
"behalten": true,
},
{
"id": "peek", "kind": Kind.BUFF, "target": false,
"name": "Spähen",
"desc": "Von jedem Gegner wird dir ein zufälliger Würfel aufgedeckt.",
"stark": "Es sind zwei Würfel je Gegner.",
},
{
"id": "bid_up", "kind": Kind.DEBUFF, "target": false,
"name": "Erhöhen",
"desc": "Die laufende Ansage steigt um 1.",
"stark": "Sie steigt um 2.",
},
{
"id": "enemy_lower", "kind": Kind.DEBUFF, "target": false,
"name": "Absenken",
"desc": "Bei jedem Gegner sinkt ein zufälliger Würfel um 1 (1 bleibt 1).",
"stark": "Er sinkt um 2.",
},
{
"id": "fog", "kind": Kind.DEBUFF, "target": false,
"name": "Nebel",
"desc": "Jeder Gegner verliert die Sicht auf einen seiner eigenen Würfel.",
"stark": "Es sind zwei Würfel.",
},
{
"id": "raise_min", "kind": Kind.DEBUFF, "target": false,
"name": "Störfeuer",
"desc": "Alle Gegner müssen diese Runde um mindestens 2 erhöhen.",
"stark": "Es müssen mindestens 3 sein.",
},
{
"id": "gag", "kind": Kind.DEBUFF, "target": false,
"name": "Maulkorb",
"desc": "Wer gerade dran ist, muss bieten und darf nicht anfechten.",
"stark": "Alle Gegner müssen bieten.",
},
]
@@ -80,6 +101,11 @@ func needs_target(id: String) -> bool:
return get_booster(id).get("target", false)
## Eine zufällige Karte, für den, der gerade einen Würfel verloren hat.
## Münzen, die bei Wappen auf der Hand bleiben, statt in die Mitte zu wandern.
func keeps_on_wappen(id: String) -> bool:
return get_booster(id).get("behalten", false)
## Eine zufällige Münze, für den, der gerade einen Würfel verloren hat.
func zieh(rng: RandomNumberGenerator) -> String:
return String(ALL[rng.randi_range(0, ALL.size() - 1)].id)

View File

@@ -4,6 +4,8 @@ extends Node3D
const START_DICE := 5
const BID_TIMEOUT := 30.0
const REVEAL_PAUSE := 5.0 ## Anzeigedauer des Rundenergebnisses
const WURF_ZEIT := 1.1 ## wie lange die geworfene Münze zu sehen ist,
## bevor ihre Wirkung einsetzt
var table: GameTable
var hud: CanvasLayer
@@ -19,12 +21,13 @@ var bid: Dictionary = {}
var turn := 0
var starter := 0
var fogged: Dictionary = {} ## seat -> Array[int] verdeckter Würfel
var peeked: Dictionary = {} ## viewer -> {target_seat: [idx, value]}
var peeked: Dictionary = {} ## viewer -> {target_seat: Array[[idx, value]]}
var min_raise: Dictionary = {} ## seat -> int
var must_bid: Dictionary = {} ## seat -> bool
var hand: Dictionary = {} ## seat -> Array[String], Karten auf der Hand
var letzte_karte: Dictionary = {} ## seat -> zuletzt gespielte Karte, nur Anzeige
var _karte_laeuft := false ## eine Karte wird gerade aufgelöst
var hand: Dictionary = {} ## seat -> Array[String], Münzen beim Spieler
var mitte: Dictionary = {} ## seat -> zuletzt in die Mitte gelegte Münze
var _muenze_laeuft := false ## eine Münze wird gerade aufgelöst
var _wurf_nr := 0 ## zählt die Würfe, damit das HUD neue erkennt
var last_bids: Dictionary = {} ## seat -> letzte Ansage dieser Runde
var log_lines: Array = []
var phase := "lobby"
@@ -78,7 +81,9 @@ func setup(cfg: Dictionary) -> void:
hud.pause_changed.connect(_on_pause_changed)
for i in seats.size():
hand[i] = []
# Die Startmünze zieht nur der Host. Beim Client kommt sie mit dem
# ersten Zustand, sonst läge dort kurz eine andere.
hand[i] = [BoosterDB.zieh(rng)] if multiplayer.is_server() else []
table.set_dice_count(i, START_DICE)
table.set_dice_visible(i, false)
table.set_cup_down(i, i != local_seat)
@@ -113,6 +118,7 @@ func _refresh_labels() -> void:
elif phase == "bid" and i == turn:
col = Color(1.0, 0.85, 0.3)
table.set_label(i, txt, col)
table.set_active_seat(turn if phase == "bid" else -1)
# ---------------------------------------------------------------- Host-Ablauf
@@ -199,7 +205,8 @@ func _phase_roll() -> void:
peeked.clear()
min_raise.clear()
must_bid.clear()
letzte_karte.clear()
mitte.clear()
_wurf_nr = 0
banner = ""
_log("--- Neue Runde ---")
@@ -254,57 +261,81 @@ func _await_settled(list: Array, timeout := 7.0) -> void:
return
# ---------------------------------------------------------------- Karten
# ---------------------------------------------------------------- Münzen
## Wer einen Würfel abgibt, zieht eine Karte. Das ist der einzige Nachschub.
func _zieh_karte(seat: int) -> void:
var karten: Array = hand.get(seat, [])
if karten.size() >= BoosterDB.HANDLIMIT:
_log("%s hat die Hand voll und zieht nicht nach." % seats[seat].name)
## Wer einen Würfel abgibt, bekommt eine Münze. Das ist der einzige Nachschub.
func _zieh_muenze(seat: int) -> void:
var muenzen: Array = hand.get(seat, [])
if muenzen.size() >= BoosterDB.HANDLIMIT:
_log("%s hat schon drei Münzen und bekommt keine." % seats[seat].name)
return
var id := BoosterDB.zieh(rng)
karten.append(id)
hand[seat] = karten
_log("%s zieht %s." % [seats[seat].name, BoosterDB.get_booster(id).name])
muenzen.append(id)
hand[seat] = muenzen
_log("%s bekommt %s." % [seats[seat].name, BoosterDB.get_booster(id).name])
## Ausspielen heißt: Münze in die Mitte legen und werfen. Wappen verstärkt,
## Zahl lässt es bei der Grundwirkung. Beides hilft - verschenkt ist nie ein Zug.
func _do_play(seat: int, id: String, target: int) -> void:
if seat < 0 or phase != "bid" or not seats[seat].alive:
return
# Zwei Karten gleichzeitig würden sich beim Würfeln in die Quere kommen.
if _karte_laeuft:
# Zwei Münzen gleichzeitig würden sich beim Würfeln in die Quere kommen.
if _muenze_laeuft:
return
var karten: Array = hand.get(seat, [])
if not karten.has(id):
var muenzen: Array = hand.get(seat, [])
if not muenzen.has(id):
return
karten.erase(id)
hand[seat] = karten
letzte_karte[seat] = id
_log("%s spielt %s." % [seats[seat].name, BoosterDB.get_booster(id).name])
_karte_laeuft = true
await _wirkung(seat, id, target)
_karte_laeuft = false
muenzen.erase(id)
hand[seat] = muenzen
_muenze_laeuft = true
var stark := rng.randf() < BoosterDB.WAPPEN_CHANCE
_wurf_nr += 1
mitte[seat] = {"id": id, "stark": stark, "nr": _wurf_nr}
_log("%s wirft %s - %s." % [seats[seat].name,
BoosterDB.get_booster(id).name, "Wappen" if stark else "Zahl"])
# Erst die Münze zeigen, dann wirken lassen. Andersherum sähe man die
# Folge vor der Ursache.
_push_state()
await _sleep(WURF_ZEIT)
if _alive():
await _wirkung(seat, id, target, stark)
_muenze_laeuft = false
if not _alive():
return
# Der Widerruf hat keine eigene Verstärkung; bei Wappen bleibt er liegen.
if stark and BoosterDB.keeps_on_wappen(id):
var zurueck: Array = hand.get(seat, [])
if zurueck.size() < BoosterDB.HANDLIMIT:
zurueck.append(id)
hand[seat] = zurueck
_snapshot_all()
_push_state()
func _wirkung(seat: int, id: String, target: int) -> void:
func _wirkung(seat: int, id: String, target: int, stark: bool) -> void:
var vals: Array = host_values[seat]
var ziel: int = clampi(target, 0, maxi(0, vals.size() - 1))
match id:
"reroll_all":
await _neu_werfen(seat, range(vals.size()))
if stark and not host_values[seat].is_empty():
_set_die(seat, rng.randi_range(0, host_values[seat].size() - 1), 6)
"reroll_one":
await _neu_werfen(seat, [ziel])
await _neu_werfen(seat, _ziel_und_zufall(seat, ziel, stark))
"set_six":
_set_die(seat, ziel, 6)
for k: int in _ziel_und_zufall(seat, ziel, stark):
_set_die(seat, k, 6)
"gain_die":
seats[seat].count += 1
table.set_dice_count(seat, seats[seat].count)
host_values[seat].append(1)
await _neu_werfen(seat, [host_values[seat].size() - 1])
var neu: int = host_values[seat].size() - 1
await _neu_werfen(seat, [neu])
if stark:
_set_die(seat, neu, 6)
"cancel":
fogged.erase(seat)
min_raise.erase(seat)
@@ -312,33 +343,69 @@ func _wirkung(seat: int, id: String, target: int) -> void:
"peek":
var info := {}
for o in _alive_seats():
if o != seat and host_values[o].size() > 0:
var k := rng.randi_range(0, host_values[o].size() - 1)
info[o] = [k, host_values[o][k]]
if o != seat:
info[o] = _zufalls_paare(o, 2 if stark else 1)
peeked[seat] = info
"bid_up":
if not bid.is_empty():
bid.count = int(bid.count) + 1
bid.count = int(bid.count) + (2 if stark else 1)
last_bids[int(bid.seat)] = {"count": bid.count, "face": bid.face}
"enemy_lower":
var minus := 2 if stark else 1
for o in _alive_seats():
if o != seat and host_values[o].size() > 0:
var k := rng.randi_range(0, host_values[o].size() - 1)
_set_die(o, k, maxi(1, host_values[o][k] - 1))
if o != seat:
for k: int in _zufalls_idx(o, 1):
_set_die(o, k, maxi(1, int(host_values[o][k]) - minus))
"fog":
for o in _alive_seats():
if o != seat and host_values[o].size() > 0:
var k := rng.randi_range(0, host_values[o].size() - 1)
var liste: Array = fogged.get(o, [])
if o == seat:
continue
var liste: Array = fogged.get(o, [])
for k: int in _zufalls_idx(o, 2 if stark else 1):
if not liste.has(k):
liste.append(k)
fogged[o] = liste
fogged[o] = liste
"raise_min":
for o in _alive_seats():
if o != seat:
min_raise[o] = 2
min_raise[o] = 3 if stark else 2
"gag":
must_bid[turn] = true
if stark:
for o in _alive_seats():
if o != seat:
must_bid[o] = true
else:
must_bid[turn] = true
## Bis zu `n` verschiedene Würfelplätze eines Sitzes, zufällig gezogen.
func _zufalls_idx(seat: int, n: int) -> Array:
var frei: Array = range(host_values[seat].size())
var out: Array = []
while out.size() < n and not frei.is_empty():
out.append(frei.pop_at(rng.randi_range(0, frei.size() - 1)))
return out
func _zufalls_paare(seat: int, n: int) -> Array:
var out: Array = []
for k: int in _zufalls_idx(seat, n):
out.append([k, host_values[seat][k]])
return out
## Der selbst gewählte Würfel, bei Wappen zusätzlich ein anderer zufälliger.
func _ziel_und_zufall(seat: int, ziel: int, stark: bool) -> Array:
var out: Array = [ziel]
if not stark:
return out
var frei: Array = []
for k in host_values[seat].size():
if k != ziel:
frei.append(k)
if not frei.is_empty():
out.append(frei[rng.randi_range(0, frei.size() - 1)])
return out
## Würfel neu werfen und die Augenzahl danach übernehmen.
@@ -386,8 +453,8 @@ func _phase_bid() -> Dictionary:
_push_state()
if seats[turn].ai:
await _sleep(1.1)
# Bots spielen ihre Karten, bevor sie ansagen.
await _bot_karte(turn)
# Bots werfen ihre Münzen, bevor sie ansagen.
await _bot_muenze(turn)
if not _alive():
return {}
var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid,
@@ -413,16 +480,16 @@ func _phase_bid() -> Dictionary:
return {}
## Ein Bot legt manchmal eine Karte, statt sie bis zum Ausscheiden zu horten.
func _bot_karte(seat: int) -> void:
var karten: Array = hand.get(seat, [])
if karten.is_empty():
## Ein Bot wirft manchmal eine Münze, statt sie bis zum Ausscheiden zu horten.
func _bot_muenze(seat: int) -> void:
var muenzen: Array = hand.get(seat, [])
if muenzen.is_empty():
return
# Volle Hand: raus damit. Sonst nur ab und zu, sonst ist sofort alles weg.
var lust := 1.0 if karten.size() >= BoosterDB.HANDLIMIT else 0.35
# Voller Vorrat: raus damit. Sonst nur ab und zu, sonst ist alles sofort weg.
var lust := 1.0 if muenzen.size() >= BoosterDB.HANDLIMIT else 0.35
if rng.randf() > lust:
return
var id: String = karten[rng.randi_range(0, karten.size() - 1)]
var id: String = muenzen[rng.randi_range(0, muenzen.size() - 1)]
if id == "bid_up" and bid.is_empty():
return
await _do_play(seat, id, DiceAI.target_for(id, host_values[seat]))
@@ -510,17 +577,20 @@ func _phase_reveal(result: Dictionary) -> void:
return
seats[loser].count -= 1
_zieh_karte(loser)
if seats[loser].count <= 0:
seats[loser].alive = false
seats[loser].count = 0
host_values[loser] = []
table.set_dice_count(loser, 0)
_log("%s scheidet aus." % seats[loser].name)
else:
_zieh_muenze(loser)
starter = loser if seats[loser].alive else _next_alive(loser)
# Nur wer noch spielt, bekommt den Becher wieder. Auf einer leeren Zone
# sähe er aus, als lägen dort noch Würfel.
for i in seats.size():
table.set_dice_visible(i, false)
table.set_cup_down(i, true)
table.set_cup_down(i, seats[i].alive)
banner = ""
_push_state()
@@ -645,14 +715,14 @@ func _push_state() -> void:
_state.rpc_id(peer, _state_for(i))
## Wer hat diese Runde schon eine Karte gespielt? Beim Würfeln steht noch
## nichts an, danach ist es offen.
## Welche Münze liegt von wem in der Mitte? Beim Würfeln steht noch nichts
## an, danach ist es offen.
func _round_boosters() -> Array:
var out: Array = []
if phase == "roll":
return out
for i in seats.size():
out.append(String(letzte_karte.get(i, "")))
out.append(mitte.get(i, {}))
return out
@@ -688,7 +758,6 @@ func _state_for(seat: int) -> Dictionary:
"boosts": _round_boosters(),
"actual": reveal_total if phase == "reveal" else 0,
"bids": _seat_bids(),
"boost": String(letzte_karte.get(seat, "")),
"hand": (hand.get(seat, []) as Array).duplicate(),
}
@@ -719,7 +788,7 @@ func _apply_state(st: Dictionary) -> void:
for i in seats.size():
if i != local_seat:
table.set_dice_visible(i, false)
table.set_cup_down(i, true)
table.set_cup_down(i, seats[i].alive)
table.set_dice_visible(local_seat, true)
_refresh_labels()
_apply_fog_to_table()

View File

@@ -13,6 +13,7 @@ const DIM := Color(0.62, 0.64, 0.70)
## Wette werden so gross gewählt, dass sie dort hineinpassen.
const BID_BOX := Vector2(150.0, 108.0)
const TIMER_W := 52.0 ## Breite der Zeitanzeige in der Ansage-Leiste
const INFO_W := 280.0 ## Textbreite im Erklärfeld beim Überfahren
const GRUEN := Color(0.55, 0.84, 0.55) ## überzähliger Würfel beim Aufdecken
const ROT := Color(0.94, 0.55, 0.52) ## fehlender Würfel beim Aufdecken
@@ -47,6 +48,14 @@ var pause_hint: Label
var boost_panel: PanelContainer
var boost_cards: HBoxContainer
var boost_title: Label
var turn_label: Label
var info_panel: PanelContainer
var info_name: Label
var info_desc: Label
var info_stark: Label
var wurf_slot: CenterContainer
var wurf_icon: MuenzIcon
var wurf_label: Label
var timer_bid: Label
var timer_boost: Label
var target_row: HBoxContainer
@@ -54,24 +63,44 @@ var target_row: HBoxContainer
var _face := 1
## Ein gezeichnetes Würfelbild: Augen 1-6 oder ein Fragezeichen bei unbekanntem
## Wert. Wird überall dort benutzt, wo früher Ziffern oder Sternchen standen.
## Gezeichnetes Sinnbild je Karte. Bilder als Dateien gibt es im Projekt nicht;
## alles wird zur Laufzeit gemalt, so wie die Würfel auch.
class KartenIcon extends Control:
var id := ""
var farbe := Color.WHITE
## Gezeichnete Münze. Bilder als Dateien gibt es im Projekt nicht; alles wird
## zur Laufzeit gemalt, so wie die Würfel auch. Vorn das Sinnbild der Wirkung
## (Wappen), hinten die glatte Zahlseite.
class MuenzIcon extends Control:
const RAND := Color(0.40, 0.29, 0.11) ## Prägekante
const FLAECHE := Color(0.78, 0.60, 0.26) ## Messing
const GLANZ := Color(0.91, 0.75, 0.39) ## Lichtkante
const GRAVUR := Color(0.19, 0.13, 0.04) ## eingeschlagenes Zeichen
func _init(karte: String, col: Color, px: float) -> void:
id = karte
farbe = col
var id := ""
var farbe := GRAVUR ## Zeichenfarbe, von den Zeichenhilfen benutzt
var kind_col := Color.WHITE
var seite := 0 ## 0 = Wappen (Sinnbild), 1 = Zahl
func _init(muenze: String, col: Color, px: float) -> void:
id = muenze
kind_col = col
mouse_filter = Control.MOUSE_FILTER_IGNORE
custom_minimum_size = Vector2(px, px)
size_flags_vertical = Control.SIZE_SHRINK_CENTER
pivot_offset = Vector2(px, px) * 0.5
func _draw() -> void:
var s := minf(size.x, size.y)
var m := size * 0.5
var r := s * 0.5
draw_circle(m, r, RAND)
draw_circle(m - Vector2(r, r) * 0.05, r * 0.95, GLANZ)
draw_circle(m, r * 0.89, FLAECHE)
# Der Farbring sagt, ob die Münze dir hilft oder den anderen schadet.
draw_arc(m, r * 0.79, 0, TAU, 40, kind_col, maxf(1.0, s * 0.05))
if seite == 1:
draw_arc(m, r * 0.52, 0, TAU, 32, GRAVUR, maxf(1.0, s * 0.04))
draw_circle(m, s * 0.09, GRAVUR)
return
_zeichen(m, s * 0.74)
func _zeichen(m: Vector2, s: float) -> void:
match id:
"reroll_all":
_wuerfel(m + Vector2(-s * 0.16, -s * 0.06), s * 0.42, 5)
@@ -115,7 +144,7 @@ class KartenIcon extends Control:
var punkt := kante * 0.10
var weg := kante * 0.26
for p: Vector2 in DieIcon.PIPS[augen]:
draw_circle(mitte + p * weg, punkt, Color(0.08, 0.09, 0.12))
draw_circle(mitte + p * weg, punkt, FLAECHE)
## Offener Kreisbogen mit Spitze - das Zeichen fürs Neuwerfen.
func _bogen(mitte: Vector2, r: float) -> void:
@@ -152,6 +181,8 @@ class KartenIcon extends Control:
draw_circle(mitte, s * 0.11, farbe)
## Ein gezeichnetes Würfelbild: Augen 1-6 oder ein Fragezeichen bei unbekanntem
## Wert. Wird überall dort benutzt, wo früher Ziffern oder Sternchen standen.
class DieIcon extends Control:
const PIPS := {
1: [Vector2(0, 0)],
@@ -199,11 +230,24 @@ var _deadline := 0.0 ## lokaler Zeitstempel, 0 = keine Frist
var _phase := ""
var _my_turn := false
var _pending_boost := ""
var _letzter_wurf := 0 ## Nummer des zuletzt gezeigten Münzwurfs
var _info_ctrl: Control ## worauf die Maus gerade liegt
var _zug_box := StyleBoxFlat.new() ## Hinterlegung der Zeile, die dran ist
var _leer_box := StyleBoxEmpty.new()
var _hand_sig := "" ## Abbild des Vorrats, gegen unnötigen Neuaufbau
var _mitte_sig := "" ## dasselbe für die Liste der gelegten Münzen
var _wurf_tween: Tween
var _my_values: Array = []
var _my_seat := 0
func _ready() -> void:
_zug_box.bg_color = Color(0.30, 0.24, 0.07, 0.85)
_zug_box.set_corner_radius_all(6)
_zug_box.border_color = ACCENT
_zug_box.set_border_width_all(1)
_zug_box.set_content_margin_all(5)
_leer_box.set_content_margin_all(5)
layer = 1
process_mode = Node.PROCESS_MODE_ALWAYS
root = Control.new()
@@ -300,6 +344,13 @@ func _build() -> void:
_place(banner_label, 0, 0, 1, 0, edge, 20, -edge, 150)
root.add_child(banner_label)
# --- Wer ist dran: direkt über der Ansageleiste, wo der Blick ohnehin liegt
turn_label = _label("", 19, ACCENT)
turn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
turn_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_place(turn_label, 0, 1, 1, 1, 16, -146, -16, -114)
root.add_child(turn_label)
# --- Ansage: kompakte Leiste unten in der Mitte
action_panel = _panel()
_slot(0, 1, 1, 1, 16, -110, -16, -16).add_child(action_panel)
@@ -376,20 +427,56 @@ func _build() -> void:
timer_bid.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
av.add_child(timer_bid)
# --- Handkarten unten links, über der Ansageleiste
# --- Münzvorrat unten links, über der Ansageleiste
hand_panel = _panel()
_place(hand_panel, 0, 1, 0, 1, 16, -300, 16, -124)
root.add_child(hand_panel)
var hv := VBoxContainer.new()
hv.add_theme_constant_override("separation", 4)
hand_panel.add_child(hv)
hv.add_child(_label("Karten", 13, DIM))
hv.add_child(_label("Münzen", 13, DIM))
hand_box = HBoxContainer.new()
hand_box.add_theme_constant_override("separation", 6)
hv.add_child(hand_box)
hand_panel.visible = false
# --- Booster-Overlay
# --- Geworfene Münze, links unter der Liste der gelegten Münzen. Die
# Tischmitte ist besetzt: dort stehen die Becher und das Gebot.
wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 300)
wurf_slot.visible = false
var wv := VBoxContainer.new()
wv.mouse_filter = Control.MOUSE_FILTER_IGNORE
wv.add_theme_constant_override("separation", 6)
wurf_slot.add_child(wv)
wurf_icon = MuenzIcon.new("", ACCENT, 104.0)
wurf_icon.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
wv.add_child(wurf_icon)
wurf_label = _label("", 16, ACCENT)
wurf_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
wurf_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
wv.add_child(wurf_label)
# --- Erklärung beim Überfahren. Ein eigenes Feld statt Tooltip: das kommt
# sofort und verschwindet nicht, wenn die Runde weiterläuft.
info_panel = _panel(Color(0.05, 0.06, 0.09, 0.97))
info_panel.visible = false
info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(info_panel)
var iv := VBoxContainer.new()
iv.add_theme_constant_override("separation", 4)
info_panel.add_child(iv)
info_name = _label("", 16, ACCENT)
iv.add_child(info_name)
info_desc = _label("", 14, Color(0.82, 0.84, 0.90))
info_desc.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
info_desc.custom_minimum_size = Vector2(INFO_W, 0)
iv.add_child(info_desc)
info_stark = _label("", 14, ACCENT)
info_stark.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
info_stark.custom_minimum_size = Vector2(INFO_W, 0)
iv.add_child(info_stark)
boost_slot = _slot(0, 0, 1, 1, 0, 0, 0, 0)
boost_slot.visible = false
boost_panel = _panel(Color(0.05, 0.06, 0.09, 0.97))
@@ -397,7 +484,7 @@ func _build() -> void:
var bvv := VBoxContainer.new()
bvv.add_theme_constant_override("separation", 12)
boost_panel.add_child(bvv)
boost_title = _label("Wähle einen Booster", 24, ACCENT)
boost_title = _label("Wähle eine Münze", 24, ACCENT)
boost_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
bvv.add_child(boost_title)
timer_boost = _label("", 16, DIM)
@@ -554,15 +641,20 @@ func update_state(st: Dictionary, my_seat: int) -> void:
var s: Dictionary = st.seats[i]
var turn: bool = st.phase == "bid" and i == st.turn
var col := ACCENT if turn else (Color.WHITE if s.alive else DIM)
# Wer dran ist, bekommt eine hinterlegte Zeile. Ein Pfeil und etwas
# Farbe gehen in der Liste unter.
var zeile := PanelContainer.new()
zeile.add_theme_stylebox_override("panel", _zug_box if turn else _leer_box)
players_box.add_child(zeile)
var entry := VBoxContainer.new()
entry.add_theme_constant_override("separation", 2)
players_box.add_child(entry)
zeile.add_child(entry)
var nm := _label(("▸ " if turn else "") + _short(String(s.name)) + (" (KI)" if s.ai else ""), 14, col)
nm.clip_text = true
nm.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
entry.add_child(nm)
if not s.alive:
entry.add_child(_label(" ausgeschieden", 13, DIM))
entry.add_child(_label("ausgeschieden", 13, DIM))
continue
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 4)
@@ -595,35 +687,24 @@ func update_state(st: Dictionary, my_seat: int) -> void:
_zeichne_hand(st)
_clear(boosts_box)
var boosts: Array = st.get("boosts", [])
var any := false
for i in mini(boosts.size(), st.seats.size()):
if String(boosts[i]) == "":
continue
any = true
var b: Dictionary = BoosterDB.get_booster(String(boosts[i]))
var line := HBoxContainer.new()
line.add_theme_constant_override("separation", 5)
boosts_box.add_child(line)
line.add_child(_label(_short(String(st.seats[i].name)) + ":", 14,
ACCENT if i == my_seat else Color(0.78, 0.80, 0.86)))
var kind := Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5)
line.add_child(_label(String(b.get("name", "")), 14, kind))
if int(st.min_raise) > 1:
boosts_box.add_child(_label("Störfeuer: mind. %d erhöhen" % st.min_raise, 14, ACCENT))
any = true
if st.must_bid:
boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT))
any = true
if not any:
boosts_box.add_child(_label("Keine Karte gespielt", 14, DIM))
_zeichne_mitte(st, my_seat)
banner_label.text = st.banner
var my_turn: bool = st.phase == "bid" and st.turn == my_seat and st.seats[my_seat].alive
var kam_dran := my_turn and not _my_turn
_my_turn = my_turn
if st.phase == "bid" and int(st.turn) < st.seats.size():
var wer := "%s ist dran" % _short(String(st.seats[st.turn].name))
if my_turn:
turn_label.text = "Du bist dran"
elif not st.seats[my_seat].alive:
turn_label.text = "Du bist raus · " + wer
else:
turn_label.text = wer
turn_label.add_theme_color_override("font_color", ACCENT if my_turn else DIM)
else:
turn_label.text = ""
action_panel.modulate = Color(1, 1, 1, 1.0 if my_turn else 0.45)
bid_btn.disabled = not my_turn
_count_max = maxi(1, int(st.total))
@@ -665,7 +746,7 @@ func _process(_delta: float) -> void:
timer_bid.text = text if wants_bid else ""
timer_bid.add_theme_color_override("font_color", col)
# Läuft die Ansagezeit ab, während die Würfelauswahl offen steht, wird
# automatisch angesagt - die Karte bleibt dann ungespielt auf der Hand.
# automatisch angesagt - die Münze bleibt dann ungeworfen liegen.
timer_boost.text = "Noch %s bis zur Ansage" % text if wants_boost else ""
timer_boost.add_theme_color_override("font_color", col)
@@ -698,10 +779,10 @@ func _seat_dice(st: Dictionary, seat: int, my_seat: int) -> Array:
for _k in int(st.seats[seat].count):
out.append(-1)
var peek: Dictionary = st.peek
if peek.has(seat):
var idx := int(peek[seat][0])
for paar: Array in peek.get(seat, []):
var idx := int(paar[0])
if idx >= 0 and idx < out.size():
out[idx] = int(peek[seat][1])
out[idx] = int(paar[1])
return out
@@ -711,52 +792,215 @@ func set_banner(text: String) -> void:
# ---------------------------------------------------------------- Booster
## Die Hand liegt offen unten links. Ein Klick spielt die Karte sofort;
## nur Karten mit eigenem Ziel fragen noch nach dem Würfel.
## Der Vorrat liegt offen unten links. Ein Klick wirft die Münze sofort; nur
## Münzen mit eigenem Ziel fragen noch nach dem Würfel. Die Wirkung steht im
## Tooltip - auf eine Münze passt nur das Sinnbild.
func _zeichne_hand(st: Dictionary) -> void:
if hand_box == null:
return
var karten: Array = st.get("hand", [])
hand_panel.visible = not karten.is_empty()
_clear(hand_box)
var muenzen: Array = st.get("hand", [])
# Wer raus ist, schaut nur noch zu. Münzen, die er nie werfen kann,
# stünden nur klickbar im Weg.
var dabei: bool = _my_seat < st.seats.size() and st.seats[_my_seat].alive
hand_panel.visible = dabei and not muenzen.is_empty()
var spielbar: bool = st.phase == "bid"
for id: String in karten:
# Jeder Zustand kommt mehrmals an. Ein Neuaufbau würde die Münze unter dem
# Zeiger wegreissen und die Erklärung mitnehmen.
var sig := "%s|%s" % [muenzen, spielbar]
if sig == _hand_sig:
return
_hand_sig = sig
_clear(hand_box)
for id: String in muenzen:
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
continue
var buff: bool = b.kind == BoosterDB.Kind.BUFF
var col := Color(0.5, 0.9, 0.6) if buff else Color(0.95, 0.5, 0.5)
var col := _kind_col(b)
var btn := Button.new()
btn.custom_minimum_size = Vector2(168, 132)
btn.tooltip_text = String(b.desc)
btn.custom_minimum_size = Vector2(104, 120)
btn.disabled = not spielbar
# Ein gesperrter Knopf meldet keine Maus mehr; die Erklärung soll aber
# auch dann kommen, wenn gerade nicht geworfen werden darf.
btn.mouse_filter = Control.MOUSE_FILTER_STOP
btn.pressed.connect(_pick.bind(id))
hand_box.add_child(btn)
_info_an(btn, id, false, hand_panel)
var v := VBoxContainer.new()
v.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
v.offset_left = 9
v.offset_top = 7
v.offset_right = -9
v.offset_bottom = -7
v.offset_top = 8
v.offset_bottom = -6
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
v.add_theme_constant_override("separation", 2)
v.add_theme_constant_override("separation", 4)
btn.add_child(v)
var kopf := HBoxContainer.new()
kopf.mouse_filter = Control.MOUSE_FILTER_IGNORE
v.add_child(kopf)
kopf.add_child(KartenIcon.new(id, col, 34.0))
var ic := MuenzIcon.new(id, col, 72.0)
ic.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
v.add_child(ic)
var name_l := _label(String(b.name), 15, col)
var name_l := _label(String(b.name), 14, col)
name_l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_l.mouse_filter = Control.MOUSE_FILTER_IGNORE
v.add_child(name_l)
var d := _label(String(b.desc), 11, Color(0.78, 0.80, 0.86))
d.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
d.mouse_filter = Control.MOUSE_FILTER_IGNORE
d.size_flags_vertical = Control.SIZE_EXPAND_FILL
v.add_child(d)
## Was liegt in der Mitte, plus die Wirkungen, die gerade auf einem liegen.
## Auch hier gilt: nur neu bauen, wenn sich etwas geändert hat.
func _zeichne_mitte(st: Dictionary, my_seat: int) -> void:
var boosts: Array = st.get("boosts", [])
# Der neueste Wurf muss bei jedem Zustand geprüft werden, auch ohne Neubau.
var neu := {}
for i in mini(boosts.size(), st.seats.size()):
var m: Dictionary = boosts[i]
if not m.is_empty() and int(m.get("nr", 0)) > int(neu.get("nr", 0)):
neu = {"nr": m.get("nr", 0), "id": m.get("id", ""),
"stark": m.get("stark", false), "seat": i}
_pruefe_wurf(neu, st)
var sig := "%s|%s|%s" % [boosts, st.min_raise, st.must_bid]
if sig == _mitte_sig:
return
_mitte_sig = sig
_clear(boosts_box)
var any := false
for i in mini(boosts.size(), st.seats.size()):
var m: Dictionary = boosts[i]
if m.is_empty():
continue
var id := String(m.get("id", ""))
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
continue
any = true
var line := HBoxContainer.new()
line.add_theme_constant_override("separation", 5)
# Der Container muss die Maus annehmen, sonst gibt es kein Überfahren.
line.mouse_filter = Control.MOUSE_FILTER_STOP
boosts_box.add_child(line)
_info_an(line, id, true)
line.add_child(MuenzIcon.new(id, _kind_col(b), 22.0))
line.add_child(_label(_short(String(st.seats[i].name)) + ":", 14,
ACCENT if i == my_seat else Color(0.78, 0.80, 0.86)))
line.add_child(_label(String(b.get("name", "")), 14, _kind_col(b)))
if bool(m.get("stark", false)):
line.add_child(_label("· Wappen", 14, ACCENT))
if int(st.min_raise) > 1:
boosts_box.add_child(_label("Störfeuer: mind. %d erhöhen" % st.min_raise, 14, ACCENT))
any = true
if st.must_bid:
boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT))
any = true
if not any:
boosts_box.add_child(_label("Keine Münze in der Mitte", 14, DIM))
## Erklärung zu einer Münze, sobald die Maus daraufliegt. `unter` legt sie
## unter das Feld statt darüber - oben links wäre sonst kein Platz. `anker`
## ist, woran sie sich ausrichtet: beim Vorrat das ganze Feld, sonst läge sie
## halb über dessen Überschrift.
func _info_an(ctrl: Control, id: String, unter: bool, anker: Control = null) -> void:
var an: Control = anker if anker != null else ctrl
ctrl.mouse_entered.connect(_info_zeigen.bind(ctrl, id, unter, an))
ctrl.mouse_exited.connect(_info_weg.bind(ctrl))
func _info_zeigen(ctrl: Control, id: String, unter: bool, anker: Control) -> void:
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
return
_info_ctrl = ctrl
info_panel.visible = false
info_name.text = String(b.name)
info_desc.text = String(b.desc)
info_stark.text = "Wappen: %s" % b.get("stark", "")
# Die Höhe steht erst nach dem Umbruch fest, deshalb ein Bild später
# setzen. Bis dahin bleibt das Feld unsichtbar, sonst springt es.
await get_tree().process_frame
if _info_ctrl != ctrl or not is_instance_valid(ctrl) or not is_instance_valid(anker):
return
info_panel.reset_size()
var gr := info_panel.size
var r := anker.get_global_rect()
var y: float = r.position.y + r.size.y + 8.0 if unter else r.position.y - gr.y - 8.0
info_panel.position = Vector2(
clampf(r.position.x, 8.0, maxf(8.0, root.size.x - gr.x - 8.0)),
clampf(y, 8.0, maxf(8.0, root.size.y - gr.y - 8.0)))
info_panel.visible = true
func _info_weg(ctrl: Control) -> void:
if _info_ctrl != ctrl:
return
_info_ctrl = null
info_panel.visible = false
func _kind_col(b: Dictionary) -> Color:
return Color(0.5, 0.9, 0.6) if b.kind == BoosterDB.Kind.BUFF else Color(0.95, 0.5, 0.5)
## Jeder Wurf hat eine laufende Nummer. Steigt sie, ist eine neue Münze
## gefallen und wird gezeigt. Zu Rundenbeginn ist die Mitte leer, dann faengt
## die Zählung wieder bei null an.
func _pruefe_wurf(neu: Dictionary, st: Dictionary) -> void:
var nr := int(neu.get("nr", 0))
if nr == 0:
_letzter_wurf = 0
return
if nr <= _letzter_wurf:
return
_letzter_wurf = nr
var seat := int(neu.get("seat", 0))
_zeig_wurf(String(neu.get("id", "")), bool(neu.get("stark", false)),
_short(String(st.seats[seat].name)))
## Der Ausgang steht schon fest - der Host hat ihn ausgewürfelt. Die Drehung
## ist nur die Ansicht dazu und endet auf der richtigen Seite.
func _zeig_wurf(id: String, stark: bool, wer: String) -> void:
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
return
wurf_icon.id = id
wurf_icon.kind_col = _kind_col(b)
wurf_icon.seite = 0
wurf_icon.scale = Vector2.ONE
wurf_icon.queue_redraw()
wurf_label.text = "%s wirft\n%s" % [wer, b.name]
wurf_label.add_theme_color_override("font_color", DIM)
wurf_slot.visible = true
if _wurf_tween != null and _wurf_tween.is_valid():
_wurf_tween.kill()
_wurf_tween = create_tween()
_wurf_tween.tween_method(_dreh, 0.0, TAU * 2.5, 0.8)
_wurf_tween.tween_callback(_wurf_ende.bind(stark, b))
_wurf_tween.tween_interval(1.6)
_wurf_tween.tween_callback(func() -> void: wurf_slot.visible = false)
## Die Münze wird waagerecht gestaucht; bei negativer Breite sieht man die
## Rückseite. Das ist eine Drehung, ohne dass etwas in 3D stehen muss.
func _dreh(winkel: float) -> void:
var c := cos(winkel)
wurf_icon.scale.x = maxf(0.06, absf(c))
var seite := 0 if c >= 0.0 else 1
if wurf_icon.seite != seite:
wurf_icon.seite = seite
wurf_icon.queue_redraw()
func _wurf_ende(stark: bool, b: Dictionary) -> void:
wurf_icon.scale.x = 1.0
wurf_icon.seite = 0 if stark else 1
wurf_icon.queue_redraw()
if stark:
wurf_label.text = "%s\nWappen - verstärkt" % b.name
wurf_label.add_theme_color_override("font_color", ACCENT)
else:
wurf_label.text = "%s\nZahl - Grundwirkung" % b.name
wurf_label.add_theme_color_override("font_color", DIM)
func _pick(id: String) -> void:

View File

@@ -8,6 +8,8 @@ const WALL_SEGS := 20 ## Bandensegmente - Godot kennt keinen hohlen Zylin
const WALL_H := 0.55 ## unsichtbare Begrenzung, damit nichts herausspringt
const SEAT_ARC := ZONE_RAD * 2.0 + 0.85 ## Bogenlänge, die ein Platz am Tischrand braucht
const MIN_ZONE_R := 1.15
const RIM_RUHE := Color(0.22, 0.15, 0.09) ## Zonenrand, wenn der Platz wartet
const RIM_ZUG := Color(0.85, 0.66, 0.22) ## Zonenrand des Platzes, der dran ist
const TILT_DEG := 15.0 ## Neigung der Kamera gegen die Senkrechte
const HUD_TOP := 0.13 ## Bildanteil oben für die Gebotsanzeige
const HUD_BOTTOM := 0.17 ## Bildanteil unten für die Ansage-Leiste
@@ -27,6 +29,7 @@ var bid_counts: Array[Label3D] = []
var bid_icons: Array[Sprite3D] = []
var _bid_anchor: Array[Vector3] = []
var plates: Array[Sprite3D] = []
var rims: Array[MeshInstance3D] = [] ## Rand der Zone, leuchtet beim Zug
var dice: Array = [] ## seat -> Array[Die]
var camera: Camera3D
@@ -172,8 +175,9 @@ func _build_seat(i: int) -> void:
tm.ring_segments = 8
rim.mesh = tm
rim.position = Vector3(0, 0.02, 0)
rim.material_override = _mat(Color(0.22, 0.15, 0.09), 0.6)
rim.material_override = _mat(RIM_RUHE, 0.6)
zone.add_child(rim)
rims.append(rim)
# Becher
var cup := Node3D.new()
@@ -393,17 +397,26 @@ func _mat(col: Color, rough: float) -> StandardMaterial3D:
## --- Würfel ---
## Nur die neu dazugekommenen Würfel werden hingelegt. Die schon liegenden
## bleiben, wie sie sind - ein Nachschub mitten in der Runde würde ihnen sonst
## die Drehung nehmen und damit auf dem Tisch die Augenzahl.
func set_dice_count(seat: int, count: int) -> void:
var arr: Array = dice[seat]
while arr.size() > count:
var d = arr.pop_back()
d.queue_free()
# Ein neuer Würfel darf nicht sichtbar sein, wenn die anderen unterm
# Becher liegen - sonst verrät er die Hand des Gegners.
var sichtbar: bool = arr.is_empty() or arr[0].visible
var ab := arr.size()
while arr.size() < count:
var d := Die.new()
add_child(d)
d.global_position = zones[seat].global_position + Vector3(0, 0.4, 0)
d.visible = sichtbar
arr.append(d)
_layout_idle(seat)
if arr.size() > ab:
_layout_idle(seat, ab)
func dice_of(seat: int) -> Array:
@@ -426,8 +439,9 @@ func throw_dice(seat: int, rng: RandomNumberGenerator) -> void:
arr[i].throw_from(zone.global_transform * off, rng)
## Legt die Würfel ordentlich nebeneinander (für Clients/Reveal).
func _layout_idle(seat: int) -> void:
## Legt die Würfel ordentlich nebeneinander (für Clients/Reveal). Ab `von`
## bleiben die davorliegenden unangetastet.
func _layout_idle(seat: int, von := 0) -> void:
var zone: Node3D = zones[seat]
var arr: Array = dice[seat]
# Bogen auf einem Kreis um die Zonenmitte - passt zur runden Bande.
@@ -435,7 +449,7 @@ func _layout_idle(seat: int) -> void:
# Würfels plus Luft, sonst überlappen die Bilder beim Aufdecken.
const BOGEN_R := 0.48 # etwas weiter aussen, damit das Namensschild frei bleibt
var schritt: float = (Die.SIZE * 1.7) / BOGEN_R
for i in arr.size():
for i in range(von, arr.size()):
var ang := PI + (float(i) - (arr.size() - 1) * 0.5) * schritt
var t := zone.global_transform
t.origin = zone.global_transform * Vector3(
@@ -458,6 +472,19 @@ func freeze_dice(seat: int) -> void:
d.set_kinematic(true)
## Der Platz, der dran ist, bekommt einen leuchtenden Rand. In der dunklen
## Szene ist das von jeder Ecke aus zu sehen - ein farbiger Name allein nicht.
func set_active_seat(seat: int) -> void:
for i in rims.size():
var m: StandardMaterial3D = rims[i].material_override
var dran: bool = i == seat
m.albedo_color = RIM_ZUG if dran else RIM_RUHE
m.emission_enabled = dran
if dran:
m.emission = Color(1.0, 0.78, 0.30)
m.emission_energy_multiplier = 1.1
func set_dice_visible(seat: int, on: bool) -> void:
for d in dice[seat]:
d.visible = on