This commit is contained in:
team3
2026-09-16 16:15:13 +02:00
parent b9d6b115c0
commit f1c78f66f4
17 changed files with 676 additions and 458 deletions

View File

@@ -226,6 +226,11 @@ Neuwurf sind deshalb raus - sie nahmen dem Lesen der anderen den Sinn.
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.
**Bonuswürfel füllen nur auf.** Nachschub und der Genau-Bonus gehen nie über
die Startzahl hinaus; wer voll ist, kann Nachschub nicht werfen. Sonst kämen
Würfel zurück, und der Tisch würde nie leerer - gemessen: einzelne Partien
liefen über 30 Runden.
### Anzeige
* Oben rechts steht der **Gebotsverlauf** der Runde. Bluffs liest man daran,
@@ -242,12 +247,19 @@ das ist seine Grundwirkung, und Wappen macht daraus nur eine 6.
die Münzen zu seiner Zone.
* Klänge (Würfel, Münze, Becher, Gewinn) werden beim Start berechnet. Es gibt
keine Tondateien, so wie es auch keine Bilddateien gibt.
* Holz, Filz und Messing sind Shader: Maserung, Flaum und Anlauf kommen aus
Rauschen, nicht aus Texturen. Die Meshes bleiben Godot-Primitive. Der Plan
dazu steht in `assets.md`.
* Auf dem Handy nur Querformat - ein Tisch von oben braucht Breite.
* Die Regeln stehen im Menü unter **Regeln**, inklusive aller sechs Münzen.
### Bots
Drei Stufen, im Menü wählbar: **leicht** ficht voreilig an und bietet
riskant, **schwer** bluffet öfter und zweifelt nur, wenn es sicher ist.
`make sim` misst sie: bei drei Plätzen mit je drei Würfeln dauert eine Partie
im Schnitt 6 (leicht) bis 14 (schwer) Runden; einzelne laufen bis 30. Zwölf
Partien je Stufe streuen stark - für echtes Abstimmen fünfzig nehmen.
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
@@ -270,6 +282,9 @@ vermutlich haarscharf, rufen sie „Genau".
| `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) |
| `scripts/hud.gd` | Spiel-UI |
| `scripts/klang.gd` | Autoload: berechnete Klänge, keine Dateien |
| `scripts/stoffe.gd` | Holz, Filz, Messing als Shader aus Rauschen - keine Texturdateien |
| `scripts/icons.gd` | Gezeichnete Münz- und Würfelbilder fürs HUD |
| `scripts/hud_panels.gd` | Vorrat, Topf, Erklärung, Verlauf, Protokoll |
| `tests/` | Headless-Tests, `make test` (~30 s); `make sim` misst die Bots - siehe `tests/README.md` |
| `scripts/menu.gd` | Startmenü und Lobby |

View File

@@ -19,6 +19,8 @@ window/size/viewport_width=1280
window/size/viewport_height=720
window/size/mode=3
window/stretch/mode="canvas_items"
; Ein Tisch von oben braucht Breite: nur Querformat, beide Richtungen.
window/handheld/orientation=4
[physics]

View File

@@ -52,9 +52,13 @@ func _phase_reveal(result: Dictionary) -> void:
# bringt, weniger als "alle anderen" - das kippte bei vielen Spielern.
verlierer = [int(b.seat)]
winner = rufer
bonus = rufer
g.banner = "Genau! %s verliert, %s bekommt einen Würfel" % [
g.seats[int(b.seat)].name, g.seats[rufer].name]
# Der Bonuswürfel füllt nur auf: wer schon seine Startzahl hat, bekommt keinen.
if g.seats[rufer].count < g.dice_start:
bonus = rufer
g.banner = "Genau! %s verliert, %s bekommt einen Würfel" % [
g.seats[int(b.seat)].name, g.seats[rufer].name]
else:
g.banner = "Genau! %s verliert" % g.seats[int(b.seat)].name
elif genau:
verlierer = [rufer]
winner = int(b.seat)

View File

@@ -24,7 +24,9 @@ const TOPF_MAX := 6
const WAPPEN_CHANCE := 0.5
## `target`: fragt nach einem eigenen Würfel. `gebot`: geht nur, wenn eine
## Ansage auf dem Tisch liegt.
## Ansage auf dem Tisch liegt. `platz`: geht nur, wenn man unter seiner
## Startzahl liegt - Bonuswürfel füllen auf, nie darüber hinaus. Sonst
## kämen Würfel zurück, die Partie zöge sich, und der Tisch würde nie leerer.
const ALL := [
{
"id": "peek", "kind": Kind.BUFF, "target": false, "gebot": false,
@@ -45,7 +47,7 @@ const ALL := [
"stark": "Ein zweiter, zufälliger Würfel auch.",
},
{
"id": "gain_die", "kind": Kind.BUFF, "target": false, "gebot": false,
"id": "gain_die", "kind": Kind.BUFF, "target": false, "gebot": false, "platz": true,
"name": "Nachschub",
"desc": "Du bekommst einen Würfel dazu.",
"stark": "Der neue Würfel zeigt sofort eine 6.",
@@ -80,6 +82,10 @@ func needs_bid(id: String) -> bool:
return get_booster(id).get("gebot", false)
func needs_room(id: String) -> bool:
return get_booster(id).get("platz", 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

@@ -18,6 +18,9 @@ var hud: CanvasLayer
## setzen 0, sonst dauert eine Bot-Partie Minuten statt Sekunden. Kein
## class_name dafür: den Klassencache baut nur der Editor, headless fehlt er.
var tempo := 1.0
## Würfel je Platz zu Beginn. Bonuswürfel (Nachschub, Genau) füllen nur bis
## hierhin auf - so wird der Tisch über die Partie sicher leerer.
var dice_start := START_DICE
var rng := RandomNumberGenerator.new()
var seats: Array = [] ## [{peer, name, ai, count, alive}]
@@ -65,11 +68,13 @@ func setup(cfg: Dictionary) -> void:
if cfg.has("seats"):
# Rückkehrer: die laufende Aufstellung, Platz für Platz, in der
# Reihenfolge des Servers - daran hängen alle Indizes im Zustand.
dice_start = int(cfg.get("dice", START_DICE))
for s in cfg.seats:
seats.append({"peer": int(s.peer), "name": String(s.name), "ai": bool(s.ai),
"count": int(s.count), "alive": bool(s.alive), "verwaist": false})
else:
var dice := int(cfg.get("dice", START_DICE)) # Tests spielen mit weniger
dice_start = dice
var peers: Array = cfg.peers
for p in peers:
seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false,
@@ -146,7 +151,7 @@ func wiedereinstieg(peer: int, pname: String) -> Dictionary:
aufstellung.append({"peer": t.peer, "name": t.name, "ai": t.ai,
"count": t.count, "alive": t.alive})
_nachliefern(i)
return {"seats": aufstellung, "stufe": DiceAI.stufe}
return {"seats": aufstellung, "stufe": DiceAI.stufe, "dice": dice_start}
return {}
@@ -628,6 +633,7 @@ func _state_for(seat: int) -> Dictionary:
"bids": _seat_bids(),
"verlauf": verlauf.duplicate(true),
"topf_an": _topf_ziel,
"voll": seats[seat].count >= dice_start,
"log": log_lines.duplicate(),
"hand": (hand.get(seat, []) as Array).duplicate(),
}

View File

@@ -18,6 +18,9 @@ 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
const Icons = preload("res://scripts/icons.gd")
## Vorrat, Topf, Erklärung, Verlauf, Protokoll - ausgelagert nach hud_panels.gd.
var panels := preload("res://scripts/hud_panels.gd").new(self)
var root: Control
var players_box: VBoxContainer
@@ -75,162 +78,6 @@ var target_row: HBoxContainer
var _face := 1
## 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
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:
"peek":
_auge(m, s)
"count":
_striche(m, s)
"set_face":
_schiefer_wuerfel(m, s * 0.50)
"gain_die":
_wuerfel(m + Vector2(-s * 0.10, 0), s * 0.46, 4)
_plus(m + Vector2(s * 0.26, -s * 0.20), s * 0.20)
"bid_up":
_pfeil(m, s * 0.52, true)
"gag":
var r := Rect2(m - Vector2(s * 0.28, s * 0.16), Vector2(s * 0.56, s * 0.32))
draw_rect(r, farbe, false, s * 0.07)
draw_line(m + Vector2(-s * 0.28, s * 0.22), m + Vector2(s * 0.28, -s * 0.22),
farbe, s * 0.08)
_:
draw_circle(m, s * 0.26, farbe)
## Vier Striche und einer quer - eine Strichliste, das Zeichen fürs Zählen.
func _striche(mitte: Vector2, s: float) -> void:
var w := maxf(1.5, s * 0.07)
for i in 4:
var x: float = mitte.x + (float(i) - 1.5) * s * 0.16
draw_line(Vector2(x, mitte.y - s * 0.26), Vector2(x, mitte.y + s * 0.26), farbe, w)
draw_line(mitte + Vector2(-s * 0.32, s * 0.20), mitte + Vector2(s * 0.32, -s * 0.20), farbe, w)
## Ein gekippter Würfel mit einem Auge - der wird gerade umgedreht.
func _schiefer_wuerfel(mitte: Vector2, kante: float) -> void:
var h := kante * 0.5
var ecken := PackedVector2Array()
for e: Vector2 in [Vector2(-h, -h), Vector2(h, -h), Vector2(h, h), Vector2(-h, h)]:
ecken.append(mitte + e.rotated(0.35))
draw_colored_polygon(ecken, farbe)
draw_circle(mitte, kante * 0.11, FLAECHE)
func _wuerfel(mitte: Vector2, kante: float, augen: int) -> void:
var r := Rect2(mitte - Vector2(kante, kante) * 0.5, Vector2(kante, kante))
draw_rect(r, farbe, true)
var punkt := kante * 0.10
var weg := kante * 0.26
for p: Vector2 in DieIcon.PIPS[augen]:
draw_circle(mitte + p * weg, punkt, FLAECHE)
func _plus(mitte: Vector2, r: float) -> void:
draw_rect(Rect2(mitte - Vector2(r, r * 0.28), Vector2(r * 2, r * 0.56)), farbe, true)
draw_rect(Rect2(mitte - Vector2(r * 0.28, r), Vector2(r * 0.56, r * 2)), farbe, true)
## Der Schaft sitzt hinter der Spitze, sonst überlagern sich beide zu
## einem Klecks.
func _pfeil(mitte: Vector2, h: float, hoch: bool) -> void:
var d := -1.0 if hoch else 1.0
var schaft_y: float = mitte.y - d * h * 0.48
var oben: float = minf(mitte.y, schaft_y)
draw_rect(Rect2(Vector2(mitte.x - h * 0.13, oben),
Vector2(h * 0.26, absf(mitte.y - schaft_y))), farbe, true)
draw_colored_polygon([mitte + Vector2(0, d * h * 0.50),
mitte + Vector2(-h * 0.34, d * h * 0.02),
mitte + Vector2(h * 0.34, d * h * 0.02)], farbe)
func _auge(mitte: Vector2, s: float) -> void:
var punkte := PackedVector2Array()
for i in 17:
var t := -1.0 + 2.0 * i / 16.0
punkte.append(mitte + Vector2(t * s * 0.34, -sqrt(maxf(0.0, 1.0 - t * t)) * s * 0.20))
for i in 17:
var t := 1.0 - 2.0 * i / 16.0
punkte.append(mitte + Vector2(t * s * 0.34, sqrt(maxf(0.0, 1.0 - t * t)) * s * 0.20))
draw_polyline(punkte, farbe, s * 0.05, true)
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)],
2: [Vector2(-1, -1), Vector2(1, 1)],
3: [Vector2(-1, -1), Vector2(0, 0), Vector2(1, 1)],
4: [Vector2(-1, -1), Vector2(-1, 1), Vector2(1, -1), Vector2(1, 1)],
5: [Vector2(-1, -1), Vector2(-1, 1), Vector2(0, 0), Vector2(1, -1), Vector2(1, 1)],
6: [Vector2(-1, -1), Vector2(-1, 0), Vector2(-1, 1),
Vector2(1, -1), Vector2(1, 0), Vector2(1, 1)],
}
var value := 0
var _box := StyleBoxFlat.new()
func _init(v: int, px: float = 0.0, bg := Color.TRANSPARENT) -> void:
value = v
mouse_filter = Control.MOUSE_FILTER_IGNORE
if px > 0.0:
custom_minimum_size = Vector2(px, px)
# sonst zieht der Container das Symbol in die Höhe und es wird unrund
size_flags_vertical = Control.SIZE_SHRINK_CENTER
if bg.a > 0.0:
_box.bg_color = bg
else:
_box.bg_color = Color(0.93, 0.92, 0.88) if v > 0 else Color(0.26, 0.28, 0.34)
_box.set_corner_radius_all(4)
_box.border_color = Color(0, 0, 0, 0.35)
_box.set_border_width_all(1)
func _draw() -> void:
# Immer im grössten Quadrat zeichnen, das in die Fläche passt.
var s := minf(size.x, size.y)
var mid := size * 0.5
draw_style_box(_box, Rect2(mid - Vector2(s, s) * 0.5, Vector2(s, s)))
if value <= 0:
var f := get_theme_default_font()
var fs := int(s * 0.66)
var w := f.get_string_size("?", HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x
draw_string(f, Vector2(mid.x - w * 0.5, mid.y + fs * 0.35), "?",
HORIZONTAL_ALIGNMENT_LEFT, -1, fs, Color(0.85, 0.88, 0.94))
return
for p: Vector2 in PIPS[value]:
draw_circle(mid + p * (s * 0.24), s * 0.105, Color(0.09, 0.08, 0.1))
var _deadline := 0.0 ## lokaler Zeitstempel, 0 = keine Frist
var _phase := ""
var _my_turn := false
@@ -411,7 +258,7 @@ func _build() -> void:
b.add_theme_stylebox_override("pressed", gewaehlt)
b.add_theme_stylebox_override("hover_pressed", gewaehlt)
b.pressed.connect(_on_face.bind(f))
var ic := DieIcon.new(f)
var ic := Icons.DieIcon.new(f)
ic.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
ic.offset_left = 7
ic.offset_top = 7
@@ -714,7 +561,7 @@ func update_state(st: Dictionary, my_seat: int) -> void:
row.add_theme_constant_override("separation", 4)
entry.add_child(row)
for v: int in _seat_dice(st, i, my_seat):
row.add_child(DieIcon.new(v, 21.0))
row.add_child(Icons.DieIcon.new(v, 21.0))
_clear(bid_row)
if st.bid.is_empty():
@@ -737,12 +584,12 @@ func update_state(st: Dictionary, my_seat: int) -> void:
bg = GRUEN
elif i >= _actual:
bg = ROT
bid_row.add_child(DieIcon.new(int(st.bid.face), px, bg))
bid_row.add_child(Icons.DieIcon.new(int(st.bid.face), px, bg))
_zeichne_hand(st)
_zeichne_mitte(st, my_seat)
_zeichne_verlauf(st, my_seat)
_zeichne_log(st)
panels._zeichne_hand(st)
panels._zeichne_mitte(st, my_seat)
panels._zeichne_verlauf(st, my_seat)
panels._zeichne_log(st)
# Am Ende steht der Sieger im Endbildschirm; das Banner darüber wäre doppelt.
banner_label.text = "" if st.phase == "over" else st.banner
@@ -850,245 +697,6 @@ func set_banner(text: String) -> void:
# ---------------------------------------------------------------- Booster
## 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 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()
# Geworfen wird nur am eigenen Zug. Was eine Ansage braucht, wartet auf eine.
var spielbar: bool = st.phase == "bid" and int(st.turn) == _my_seat
var gebot: bool = not st.bid.is_empty()
# Jeder Zustand kommt mehrmals an. Ein Neuaufbau würde die Münze unter dem
# Zeiger wegreissen und die Erklärung mitnehmen.
var sig := "%s|%s|%s" % [muenzen, spielbar, gebot]
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 col := _kind_col(b)
var btn := Button.new()
btn.custom_minimum_size = Vector2(104, 120)
btn.disabled = not spielbar or (BoosterDB.needs_bid(id) and not gebot)
# Der gesperrte Knopf dunkelt nur seinen Rand ab; Münze und Name darauf
# blieben sonst so hell wie bei einer werfbaren.
btn.modulate = Color(1, 1, 1, 1.0 if not btn.disabled else 0.4)
# Ein gesperrter Knopf meldet keine Maus mehr; die Erklärung soll aber
# auch dann kommen, wenn gerade nicht geworfen werden darf.
btn.mouse_filter = Control.MOUSE_FILTER_STOP
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_top = 8
v.offset_bottom = -6
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
v.add_theme_constant_override("separation", 4)
btn.add_child(v)
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), 14, col)
name_l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_l.mouse_filter = Control.MOUSE_FILTER_IGNORE
v.add_child(name_l)
## Der Topf: was diese Runde geworfen wurde und wem es gehört, wenn er
## das Aufdecken gewinnt. Darunter, was gerade auf einem selbst liegt.
## Auch hier gilt: nur neu bauen, wenn sich etwas geändert hat.
func _zeichne_mitte(st: Dictionary, my_seat: int) -> void:
var boosts: Array = st.get("boosts", [])
# Der neueste Wurf muss bei jedem Zustand geprüft werden, auch ohne Neubau.
var neu := {}
for m in boosts:
if int(m.get("nr", 0)) > int(neu.get("nr", 0)):
neu = m
_pruefe_wurf(neu, st)
var zaehlung: Dictionary = st.get("zaehlung", {})
var sig := "%s|%s|%s" % [boosts, zaehlung, st.must_bid]
if sig == _mitte_sig:
return
_mitte_sig = sig
_clear(boosts_box)
boosts_box.add_child(_label("Topf" if not boosts.is_empty() else "Topf: leer", 13, DIM))
for m in boosts:
var id := String(m.get("id", ""))
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
continue
var wer := int(m.get("seat", 0))
var line := HBoxContainer.new()
line.add_theme_constant_override("separation", 5)
# Der Container muss die Maus annehmen, sonst gibt es kein Überfahren.
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(String(b.get("name", "")), 14, _kind_col(b)))
if bool(m.get("stark", false)):
line.add_child(_label("· Wappen", 14, ACCENT))
if wer < st.seats.size():
line.add_child(_label("von " + _short(String(st.seats[wer].name)), 13,
ACCENT if wer == my_seat else DIM))
# Ergebnis vom Zählen: so oft liegt die Augenzahl am Tisch.
for f in zaehlung:
var line := HBoxContainer.new()
line.add_theme_constant_override("separation", 5)
boosts_box.add_child(line)
var von := int(zaehlung[f][0])
var bis := int(zaehlung[f][1])
var text := "Am Tisch: %d ×" % von if von == bis else "Am Tisch: %d–%d ×" % [von, bis]
line.add_child(_label(text, 14, ACCENT))
line.add_child(DieIcon.new(int(f), 18.0))
if st.must_bid:
boosts_box.add_child(_label("Maulkorb: du musst bieten", 14, ACCENT))
## 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
## 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))
if seat >= st.seats.size():
return
_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 Münze
## fällt in 3D auf den Tisch; hier steht der Text dazu, kurz und dann weg.
func _zeig_wurf(id: String, stark: bool, wer: String) -> void:
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
return
wurf_label.text = "%s wirft %s\n%s" % [wer, b.name,
"Wappen - " + String(b.get("stark", "")) if stark else "Zahl - Grundwirkung"]
wurf_label.add_theme_color_override("font_color", ACCENT if stark else DIM)
wurf_slot.visible = true
Klang.muenze()
if _wurf_tween != null and _wurf_tween.is_valid():
_wurf_tween.kill()
_wurf_tween = create_tween()
_wurf_tween.tween_interval(2.6)
_wurf_tween.tween_callback(func() -> void: wurf_slot.visible = false)
## Die letzten Zeilen des Spielprotokolls.
func _zeichne_log(st: Dictionary) -> void:
var zeilen: Array = st.get("log", [])
var sig := str(zeilen)
if sig == _log_sig:
return
_log_sig = sig
_clear(log_box)
var ab := maxi(0, zeilen.size() - 5)
for i in range(ab, zeilen.size()):
var l := _label(String(zeilen[i]), 13, ACCENT if i == zeilen.size() - 1 else DIM)
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.custom_minimum_size = Vector2(340, 0)
log_box.add_child(l)
func _pick(id: String) -> void:
# Handy: erster Tipp öffnet nur die Erklärung (die kommt über den
@@ -1110,7 +718,7 @@ func _pick(id: String) -> void:
var b := Button.new()
b.custom_minimum_size = Vector2(56, 56)
b.pressed.connect(_pick_target.bind(i))
var ic := DieIcon.new(int(_my_values[i]))
var ic := Icons.DieIcon.new(int(_my_values[i]))
ic.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
ic.offset_left = 10
ic.offset_top = 10

253
scripts/hud_panels.gd Normal file
View File

@@ -0,0 +1,253 @@
extends RefCounted
## Die Münzfelder des HUD: Vorrat, Topf, Erklärung beim Überfahren, die
## Wurfansage, dazu Gebotsverlauf und Protokoll. Arbeitet auf dem HUD `h`,
## dem die Knoten und Signale gehören. Kein class_name (headless kein Cache).
const Icons = preload("res://scripts/icons.gd")
var h: CanvasLayer
func _init(hud: CanvasLayer) -> void:
h = hud
## 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 h.hand_box == null:
return
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 = h._my_seat < st.seats.size() and st.seats[h._my_seat].alive
h.hand_panel.visible = dabei and not muenzen.is_empty()
# Geworfen wird nur am eigenen Zug. Was eine Ansage braucht, wartet auf eine.
var spielbar: bool = st.phase == "bid" and int(st.turn) == h._my_seat
var gebot: bool = not st.bid.is_empty()
var voll: bool = st.get("voll", false)
# 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|%s|%s" % [muenzen, spielbar, gebot, voll]
if sig == h._hand_sig:
return
h._hand_sig = sig
h._clear(h.hand_box)
for id: String in muenzen:
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
continue
var col := _kind_col(b)
var btn := Button.new()
btn.custom_minimum_size = Vector2(104, 120)
btn.disabled = not spielbar or (BoosterDB.needs_bid(id) and not gebot) \
or (BoosterDB.needs_room(id) and voll)
# Der gesperrte Knopf dunkelt nur seinen Rand ab; Münze und Name darauf
# blieben sonst so hell wie bei einer werfbaren.
btn.modulate = Color(1, 1, 1, 1.0 if not btn.disabled else 0.4)
# Ein gesperrter Knopf meldet keine Maus mehr; die Erklärung soll aber
# auch dann kommen, wenn gerade nicht geworfen werden darf.
btn.mouse_filter = Control.MOUSE_FILTER_STOP
btn.pressed.connect(h._pick.bind(id))
h.hand_box.add_child(btn)
_info_an(btn, id, false, h.hand_panel)
var v := VBoxContainer.new()
v.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
v.offset_top = 8
v.offset_bottom = -6
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
v.add_theme_constant_override("separation", 4)
btn.add_child(v)
var ic := Icons.MuenzIcon.new(id, col, 72.0)
ic.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
v.add_child(ic)
var name_l: Label = h._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)
## Der Topf: was diese Runde geworfen wurde und wem es gehört, wenn er
## das Aufdecken gewinnt. Darunter, was gerade auf einem selbst liegt.
## Auch hier gilt: nur neu bauen, wenn sich etwas geändert hat.
func _zeichne_mitte(st: Dictionary, my_seat: int) -> void:
var boosts: Array = st.get("boosts", [])
# Der neueste Wurf muss bei jedem Zustand geprüft werden, auch ohne Neubau.
var neu := {}
for m in boosts:
if int(m.get("nr", 0)) > int(neu.get("nr", 0)):
neu = m
_pruefe_wurf(neu, st)
var zaehlung: Dictionary = st.get("zaehlung", {})
var sig := "%s|%s|%s" % [boosts, zaehlung, st.must_bid]
if sig == h._mitte_sig:
return
h._mitte_sig = sig
h._clear(h.boosts_box)
h.boosts_box.add_child(h._label("Topf" if not boosts.is_empty() else "Topf: leer", 13, h.DIM))
for m in boosts:
var id := String(m.get("id", ""))
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
continue
var wer := int(m.get("seat", 0))
var line := HBoxContainer.new()
line.add_theme_constant_override("separation", 5)
# Der Container muss die Maus annehmen, sonst gibt es kein Überfahren.
line.mouse_filter = Control.MOUSE_FILTER_STOP
h.boosts_box.add_child(line)
_info_an(line, id, true)
line.add_child(Icons.MuenzIcon.new(id, _kind_col(b), 22.0))
line.add_child(h._label(String(b.get("name", "")), 14, _kind_col(b)))
if bool(m.get("stark", false)):
line.add_child(h._label("· Wappen", 14, h.ACCENT))
if wer < st.seats.size():
line.add_child(h._label("von " + h._short(String(st.seats[wer].name)), 13,
h.ACCENT if wer == my_seat else h.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)
h.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(h._label(text, 14, h.ACCENT))
line.add_child(Icons.DieIcon.new(int(f), 18.0))
if st.must_bid:
h.boosts_box.add_child(h._label("Maulkorb: du musst bieten", 14, h.ACCENT))
## 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 == h._verlauf_sig:
return
h._verlauf_sig = sig
h._clear(h.verlauf_box)
if verlauf.is_empty():
return
h.verlauf_box.add_child(h._label("Verlauf", 13, h.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: Color = h.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)
h.verlauf_box.add_child(line)
var nm: Label = h._label(h._short(String(st.seats[wer].name)), 13, col)
nm.custom_minimum_size = Vector2(64, 0)
line.add_child(nm)
line.add_child(h._label("%d ×" % int(e.get("count", 0)), 13, col))
line.add_child(Icons.DieIcon.new(int(e.get("face", 1)), 16.0))
if bool(e.get("erhoeht", false)):
line.add_child(h._label("erhöht", 12, h.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
h._info_ctrl = ctrl
h.info_panel.visible = false
h.info_name.text = String(b.name)
h.info_desc.text = String(b.desc)
h.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 h.get_tree().process_frame
if h._info_ctrl != ctrl or not is_instance_valid(ctrl) or not is_instance_valid(anker):
return
h.info_panel.reset_size()
var gr: Vector2 = h.info_panel.size
var r: Rect2 = 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
h.info_panel.position = Vector2(
clampf(r.position.x, 8.0, maxf(8.0, h.root.size.x - gr.x - 8.0)),
clampf(y, 8.0, maxf(8.0, h.root.size.y - gr.y - 8.0)))
h.info_panel.visible = true
func _info_weg(ctrl: Control) -> void:
if h._info_ctrl != ctrl:
return
h._info_ctrl = null
h.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:
h._letzter_wurf = 0
return
if nr <= h._letzter_wurf:
return
h._letzter_wurf = nr
var seat := int(neu.get("seat", 0))
if seat >= st.seats.size():
return
_zeig_wurf(String(neu.get("id", "")), bool(neu.get("stark", false)),
h._short(String(st.seats[seat].name)))
## Der Ausgang steht schon fest - der Host hat ihn ausgewürfelt. Die Münze
## fällt in 3D auf den Tisch; hier steht der Text dazu, kurz und dann weg.
func _zeig_wurf(id: String, stark: bool, wer: String) -> void:
var b: Dictionary = BoosterDB.get_booster(id)
if b.is_empty():
return
h.wurf_label.text = "%s wirft %s\n%s" % [wer, b.name,
"Wappen - " + String(b.get("stark", "")) if stark else "Zahl - Grundwirkung"]
h.wurf_label.add_theme_color_override("font_color", h.ACCENT if stark else h.DIM)
h.wurf_slot.visible = true
Klang.muenze()
if h._wurf_tween != null and h._wurf_tween.is_valid():
h._wurf_tween.kill()
h._wurf_tween = h.create_tween()
h._wurf_tween.tween_interval(2.6)
h._wurf_tween.tween_callback(func() -> void: h.wurf_slot.visible = false)
## Die letzten Zeilen des Spielprotokolls.
func _zeichne_log(st: Dictionary) -> void:
var zeilen: Array = st.get("log", [])
var sig := str(zeilen)
if sig == h._log_sig:
return
h._log_sig = sig
h._clear(h.log_box)
var ab := maxi(0, zeilen.size() - 5)
for i in range(ab, zeilen.size()):
var l: Label = h._label(String(zeilen[i]), 13, h.ACCENT if i == zeilen.size() - 1 else h.DIM)
l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
l.custom_minimum_size = Vector2(340, 0)
h.log_box.add_child(l)

163
scripts/icons.gd Normal file
View File

@@ -0,0 +1,163 @@
extends RefCounted
## Gezeichnete Sinnbilder für das HUD: Münze und Würfel. Bilder als Dateien
## gibt es im Projekt nicht; alles wird zur Laufzeit gemalt. Kein class_name:
## den Klassencache baut nur der Editor, headless fehlt er - deshalb
## `const Icons = preload(...)` und dann `Icons.MuenzIcon.new(...)`.
## 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
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:
"peek":
_auge(m, s)
"count":
_striche(m, s)
"set_face":
_schiefer_wuerfel(m, s * 0.50)
"gain_die":
_wuerfel(m + Vector2(-s * 0.10, 0), s * 0.46, 4)
_plus(m + Vector2(s * 0.26, -s * 0.20), s * 0.20)
"bid_up":
_pfeil(m, s * 0.52, true)
"gag":
var r := Rect2(m - Vector2(s * 0.28, s * 0.16), Vector2(s * 0.56, s * 0.32))
draw_rect(r, farbe, false, s * 0.07)
draw_line(m + Vector2(-s * 0.28, s * 0.22), m + Vector2(s * 0.28, -s * 0.22),
farbe, s * 0.08)
_:
draw_circle(m, s * 0.26, farbe)
## Vier Striche und einer quer - eine Strichliste, das Zeichen fürs Zählen.
func _striche(mitte: Vector2, s: float) -> void:
var w := maxf(1.5, s * 0.07)
for i in 4:
var x: float = mitte.x + (float(i) - 1.5) * s * 0.16
draw_line(Vector2(x, mitte.y - s * 0.26), Vector2(x, mitte.y + s * 0.26), farbe, w)
draw_line(mitte + Vector2(-s * 0.32, s * 0.20), mitte + Vector2(s * 0.32, -s * 0.20), farbe, w)
## Ein gekippter Würfel mit einem Auge - der wird gerade umgedreht.
func _schiefer_wuerfel(mitte: Vector2, kante: float) -> void:
var h := kante * 0.5
var ecken := PackedVector2Array()
for e: Vector2 in [Vector2(-h, -h), Vector2(h, -h), Vector2(h, h), Vector2(-h, h)]:
ecken.append(mitte + e.rotated(0.35))
draw_colored_polygon(ecken, farbe)
draw_circle(mitte, kante * 0.11, FLAECHE)
func _wuerfel(mitte: Vector2, kante: float, augen: int) -> void:
var r := Rect2(mitte - Vector2(kante, kante) * 0.5, Vector2(kante, kante))
draw_rect(r, farbe, true)
var punkt := kante * 0.10
var weg := kante * 0.26
for p: Vector2 in DieIcon.PIPS[augen]:
draw_circle(mitte + p * weg, punkt, FLAECHE)
func _plus(mitte: Vector2, r: float) -> void:
draw_rect(Rect2(mitte - Vector2(r, r * 0.28), Vector2(r * 2, r * 0.56)), farbe, true)
draw_rect(Rect2(mitte - Vector2(r * 0.28, r), Vector2(r * 0.56, r * 2)), farbe, true)
## Der Schaft sitzt hinter der Spitze, sonst überlagern sich beide zu
## einem Klecks.
func _pfeil(mitte: Vector2, h: float, hoch: bool) -> void:
var d := -1.0 if hoch else 1.0
var schaft_y: float = mitte.y - d * h * 0.48
var oben: float = minf(mitte.y, schaft_y)
draw_rect(Rect2(Vector2(mitte.x - h * 0.13, oben),
Vector2(h * 0.26, absf(mitte.y - schaft_y))), farbe, true)
draw_colored_polygon([mitte + Vector2(0, d * h * 0.50),
mitte + Vector2(-h * 0.34, d * h * 0.02),
mitte + Vector2(h * 0.34, d * h * 0.02)], farbe)
func _auge(mitte: Vector2, s: float) -> void:
var punkte := PackedVector2Array()
for i in 17:
var t := -1.0 + 2.0 * i / 16.0
punkte.append(mitte + Vector2(t * s * 0.34, -sqrt(maxf(0.0, 1.0 - t * t)) * s * 0.20))
for i in 17:
var t := 1.0 - 2.0 * i / 16.0
punkte.append(mitte + Vector2(t * s * 0.34, sqrt(maxf(0.0, 1.0 - t * t)) * s * 0.20))
draw_polyline(punkte, farbe, s * 0.05, true)
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)],
2: [Vector2(-1, -1), Vector2(1, 1)],
3: [Vector2(-1, -1), Vector2(0, 0), Vector2(1, 1)],
4: [Vector2(-1, -1), Vector2(-1, 1), Vector2(1, -1), Vector2(1, 1)],
5: [Vector2(-1, -1), Vector2(-1, 1), Vector2(0, 0), Vector2(1, -1), Vector2(1, 1)],
6: [Vector2(-1, -1), Vector2(-1, 0), Vector2(-1, 1),
Vector2(1, -1), Vector2(1, 0), Vector2(1, 1)],
}
var value := 0
var _box := StyleBoxFlat.new()
func _init(v: int, px: float = 0.0, bg := Color.TRANSPARENT) -> void:
value = v
mouse_filter = Control.MOUSE_FILTER_IGNORE
if px > 0.0:
custom_minimum_size = Vector2(px, px)
# sonst zieht der Container das Symbol in die Höhe und es wird unrund
size_flags_vertical = Control.SIZE_SHRINK_CENTER
if bg.a > 0.0:
_box.bg_color = bg
else:
_box.bg_color = Color(0.93, 0.92, 0.88) if v > 0 else Color(0.26, 0.28, 0.34)
_box.set_corner_radius_all(4)
_box.border_color = Color(0, 0, 0, 0.35)
_box.set_border_width_all(1)
func _draw() -> void:
# Immer im grössten Quadrat zeichnen, das in die Fläche passt.
var s := minf(size.x, size.y)
var mid := size * 0.5
draw_style_box(_box, Rect2(mid - Vector2(s, s) * 0.5, Vector2(s, s)))
if value <= 0:
var f := get_theme_default_font()
var fs := int(s * 0.66)
var w := f.get_string_size("?", HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x
draw_string(f, Vector2(mid.x - w * 0.5, mid.y + fs * 0.35), "?",
HORIZONTAL_ALIGNMENT_LEFT, -1, fs, Color(0.85, 0.88, 0.94))
return
for p: Vector2 in PIPS[value]:
draw_circle(mid + p * (s * 0.24), s * 0.105, Color(0.09, 0.08, 0.1))

View File

@@ -200,7 +200,7 @@ func _build_regeln() -> Control:
["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."],
["Genau", "Wer glaubt, die Ansage trifft exakt, ruft „Genau\". Trifft es, verliert der Ansager einen Würfel und der Rufer bekommt einen dazu - aber nie über seine Startzahl hinaus. 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."],

View File

@@ -31,6 +31,8 @@ func _do_play(seat: int, id: String, target: int) -> void:
return
if BoosterDB.needs_bid(id) and g.bid.is_empty():
return
if BoosterDB.needs_room(id) and g.seats[seat].count >= g.dice_start:
return
var muenzen: Array = g.hand.get(seat, [])
if not muenzen.has(id):
return
@@ -156,7 +158,7 @@ func _bot_muenze(seat: int, a: Dictionary) -> bool:
var ziel := 0
# Wirklich hinten liegen: Nachschub ist der Weg zurück. Bei Gleichstand
# nicht - sonst wirft ihn zu Beginn jeder, und der Tisch wird nie leerer.
if muenzen.has("gain_die") and vals.size() < _groesste_hand():
if muenzen.has("gain_die") and vals.size() < _groesste_hand() and vals.size() < g.dice_start:
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"):
@@ -177,9 +179,12 @@ func _bot_muenze(seat: int, a: Dictionary) -> bool:
# 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 g.bid.is_empty():
wahl = id
break
if BoosterDB.needs_bid(id) and g.bid.is_empty():
continue
if BoosterDB.needs_room(id) and vals.size() >= g.dice_start:
continue
wahl = id
break
if wahl == "":
return false
await _do_play(seat, wahl, ziel)

129
scripts/stoffe.gd Normal file
View File

@@ -0,0 +1,129 @@
extends RefCounted
## Materialien ohne Texturdateien: Holz, Filz und Messing werden im Shader
## aus Rauschen gerechnet. Alles, was Maserung, Flecken oder Anlauf ist, sind
## Zahlen - keine Bilder. Siehe assets.md. Kein class_name (headless kein Cache).
const RAUSCHEN := """
float hash2(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise2(vec2 p) {
vec2 i = floor(p); vec2 f = fract(p); f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash2(i), hash2(i + vec2(1, 0)), f.x),
mix(hash2(i + vec2(0, 1)), hash2(i + vec2(1, 1)), f.x), f.y);
}
float fbm2(vec2 p) {
float v = 0.0; float a = 0.5;
for (int k = 0; k < 4; k++) { v += a * noise2(p); p = p * 2.03 + 17.0; a *= 0.5; }
return v;
}
"""
## Holz: Jahresringe entlang einer Achse, leicht verzerrt, plus feine
## Faser. Weltkoordinaten, damit die Maserung über Teile hinweg durchläuft.
const HOLZ := """
shader_type spatial;
uniform vec4 base_color : source_color = vec4(0.42, 0.27, 0.13, 1.0);
uniform vec4 dark_color : source_color = vec4(0.22, 0.13, 0.06, 1.0);
uniform float wear : hint_range(0.0, 1.0) = 0.4;
uniform float ring_scale = 9.0;
varying vec3 wp;
""" + RAUSCHEN + """
void vertex() { wp = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; }
void fragment() {
vec2 p = wp.xz;
float verzerrt = p.x * ring_scale + fbm2(p * 2.5) * 3.0;
float ring = smoothstep(0.2, 0.8, abs(fract(verzerrt) - 0.5) * 2.0);
float faser = noise2(vec2(p.x * 60.0, p.y * 4.0)) * 0.12;
vec3 col = mix(dark_color.rgb, base_color.rgb, ring) + faser - 0.06;
float flecken = fbm2(p * 6.0 + 40.0);
col = mix(col, col * 0.75, smoothstep(0.55, 0.8, flecken) * wear);
ALBEDO = col;
ROUGHNESS = 0.55 + ring * 0.25 + wear * 0.15;
SPECULAR = 0.3;
}
"""
## Filz: gleichmässige Fläche mit feinem Flaum, leicht fleckig, an den
## Rändern dunkler. Ohne das sähe die Zone aus wie Plastik.
const FILZ := """
shader_type spatial;
uniform vec4 base_color : source_color = vec4(0.07, 0.20, 0.13, 1.0);
uniform float wear : hint_range(0.0, 1.0) = 0.5;
uniform float radius = 0.68;
varying vec3 lp;
varying vec3 wp;
""" + RAUSCHEN + """
void vertex() { lp = VERTEX; wp = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; }
void fragment() {
float flaum = noise2(wp.xz * 90.0) * 0.10 + noise2(wp.xz * 300.0) * 0.05;
float flecken = fbm2(wp.xz * 3.0) ;
float rand = smoothstep(radius * 0.7, radius, length(lp.xz));
vec3 col = base_color.rgb * (0.9 + flaum) * (1.0 - flecken * 0.25 * wear) * (1.0 - rand * 0.25);
ALBEDO = col;
ROUGHNESS = 0.97;
SPECULAR = 0.1;
}
"""
## Messing: metallisch, mit Anlauf in den Vertiefungen und einem Hauch
## Farbschwankung. Die Prägung bleibt ein eigenes Mesh.
const MESSING := """
shader_type spatial;
uniform vec4 base_color : source_color = vec4(0.80, 0.62, 0.26, 1.0);
uniform float wear : hint_range(0.0, 1.0) = 0.4;
uniform float emission : hint_range(0.0, 2.0) = 0.35;
varying vec3 wp;
""" + RAUSCHEN + """
void vertex() { wp = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; }
void fragment() {
float anlauf = fbm2(wp.xz * 40.0);
vec3 col = base_color.rgb * (0.85 + anlauf * 0.3);
col = mix(col, col * vec3(0.7, 0.6, 0.5), smoothstep(0.6, 0.85, anlauf) * wear);
ALBEDO = col;
METALLIC = 0.9;
ROUGHNESS = 0.25 + anlauf * 0.35 * wear;
EMISSION = base_color.rgb * 0.6 * emission;
}
"""
static var _holz: Shader
static var _filz: Shader
static var _messing: Shader
static func _shader(code: String) -> Shader:
var s := Shader.new()
s.code = code
return s
static func holz(farbe: Color, dunkel: Color, wear := 0.4, ring_scale := 9.0) -> ShaderMaterial:
if _holz == null:
_holz = _shader(HOLZ)
var m := ShaderMaterial.new()
m.shader = _holz
m.set_shader_parameter("base_color", farbe)
m.set_shader_parameter("dark_color", dunkel)
m.set_shader_parameter("wear", wear)
m.set_shader_parameter("ring_scale", ring_scale)
return m
static func filz(farbe: Color, radius: float, wear := 0.5) -> ShaderMaterial:
if _filz == null:
_filz = _shader(FILZ)
var m := ShaderMaterial.new()
m.shader = _filz
m.set_shader_parameter("base_color", farbe)
m.set_shader_parameter("radius", radius)
m.set_shader_parameter("wear", wear)
return m
static func messing(emission := 0.35, wear := 0.4) -> ShaderMaterial:
if _messing == null:
_messing = _shader(MESSING)
var m := ShaderMaterial.new()
m.shader = _messing
m.set_shader_parameter("emission", emission)
m.set_shader_parameter("wear", wear)
return m

View File

@@ -8,6 +8,7 @@ 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 Stoffe = preload("res://scripts/stoffe.gd") ## Holz, Filz, Messing aus Rauschen
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
@@ -32,7 +33,7 @@ var plates: Array[Sprite3D] = []
var rims: Array[MeshInstance3D] = [] ## Rand der Zone, leuchtet beim Zug
var topf_muenzen: Dictionary = {} ## Wurfnummer -> Münze in der Tischmitte
static var _muenz_mesh: CylinderMesh
static var _muenz_mat: StandardMaterial3D
static var _muenz_mat: ShaderMaterial
var dice: Array = [] ## seat -> Array[Die]
var camera: Camera3D
@@ -117,7 +118,7 @@ func _build_room() -> void:
tcm.height = 0.12
tmi.mesh = tcm
tmi.position = Vector3(0, TABLE_H - 0.06, 0)
tmi.material_override = _mat(Color(0.10, 0.28, 0.17), 0.85)
tmi.material_override = Stoffe.filz(Color(0.10, 0.28, 0.17), table_r, 0.35)
table.add_child(tmi)
add_child(table)
@@ -129,7 +130,7 @@ func _build_room() -> void:
lcm.height = TABLE_H - 0.12
leg.mesh = lcm
leg.position = Vector3(0, (TABLE_H - 0.12) * 0.5, 0)
leg.material_override = _mat(Color(0.16, 0.11, 0.07), 0.7)
leg.material_override = Stoffe.holz(Color(0.30, 0.19, 0.09), Color(0.16, 0.10, 0.05), 0.5, 4.0)
add_child(leg)
@@ -166,7 +167,7 @@ func _build_seat(i: int) -> void:
fm.radial_segments = 32
felt.mesh = fm
felt.position = Vector3(0, 0.006, 0)
felt.material_override = _mat(Color(0.07, 0.20, 0.13), 0.95)
felt.material_override = Stoffe.filz(Color(0.07, 0.20, 0.13), ZONE_RAD, 0.6)
zone.add_child(felt)
# Sichtbarer Rand
@@ -193,7 +194,9 @@ func _build_seat(i: int) -> void:
cm.height = 0.44
cup_mi.mesh = cm
cup_mi.position = Vector3(0, 0.22, 0)
cup_mi.material_override = _mat(Color(0.35, 0.12, 0.10), 0.5)
# Enge, weiche Maserung: die Ringe laufen am Becher senkrecht, zu grob sähe
# das nach Fassdauben aus.
cup_mi.material_override = Stoffe.holz(Color(0.40, 0.17, 0.11), Color(0.30, 0.12, 0.08), 0.5, 5.0)
cup.add_child(cup_mi)
cups.append(cup)
cup_tweens.append(null)
@@ -547,7 +550,7 @@ func set_topf(liste: Array, ziel := -1) -> void:
tw.tween_callback(mi.queue_free)
static var _muenz_mat_wappen: StandardMaterial3D
static var _muenz_mat_wappen: ShaderMaterial
static var _praeg_mesh: TorusMesh
static var _praeg_mat: StandardMaterial3D
@@ -564,17 +567,10 @@ static func _muenz_form() -> CylinderMesh:
return _muenz_mesh
static func _muenz_stoff(wappen: bool) -> StandardMaterial3D:
static func _muenz_stoff(wappen: bool) -> ShaderMaterial:
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
_muenz_mat_wappen = _muenz_mat.duplicate()
_muenz_mat_wappen.emission_energy_multiplier = 0.9
_muenz_mat = Stoffe.messing(0.35)
_muenz_mat_wappen = Stoffe.messing(0.9)
return _muenz_mat_wappen if wappen else _muenz_mat

View File

@@ -26,6 +26,11 @@ func _lauf() -> void:
game.must_bid.clear()
# --- Genau-Treffer: Ansager verliert, Rufer bekommt einen
game.must_bid.clear()
# Der Rufer (Platz 1) liegt unter seiner Startzahl - nur dann gibt es den
# Bonus. Erst wegnehmen, dann zählen: der entfernte Würfel könnte eine 3 sein.
game.seats[1].count = 4
game.host_values[1].pop_back()
game.table.set_dice_count(1, 4)
var n3: int = game._count_face(3)
if n3 == 0:
game._set_die(me, 0, 3)

View File

@@ -38,6 +38,9 @@ func _lauf() -> void:
game.hand[me] = ["count"]
await game._do_play(me, "count", 0)
_pruef("Zählen ohne Ansage abgelehnt", game.hand[me].has("count"))
game.hand[me] = ["gain_die"]
await game._do_play(me, "gain_die", 0)
_pruef("Nachschub bei voller Startzahl abgelehnt", game.hand[me].has("gain_die") and game.seats[me].count == 5)
# --- Topf: Deckel und Übertrag
game.topf.clear()

View File

@@ -103,6 +103,9 @@ func _lauf() -> void:
laeuft = await _warte(func(): return game.phase == "bid" or game.phase == "roll", 20.0)
_pruef("%s: Nochmal im Netz bringt eine neue Partie (Platz %d)" % [rolle, game.local_seat if neu else -1],
neu and laeuft and game.local_seat >= 0)
# Kurz bleiben: sonst schliesst der Raum, bevor der Server-Läufer die
# neue Partie überhaupt gesehen hat.
await get_tree().create_timer(1.5).timeout
if is_instance_valid(game):
print("%s: '%s'" % [rolle, game.banner])
for i in game.seats.size():

View File

@@ -6,34 +6,51 @@ var game: Node
func _ready() -> void:
# Es laufen zwei Räume zugleich. Jedes Spiel bekommt tempo 0, jedes Ende
# wird gemeldet; nach dem zweiten Ende noch kurz auf die Nochmal-Partien warten.
var t := 0.0
var runden := 0
var letzte := ""
var zuletzt := "" # letzter gesehener Stand - die Clients gehen sofort,
# dann räumt der Server den Raum ab, bevor wir gucken
while t < 1500.0:
var gesehen := {} # Spielknoten -> {runden, letzte}
var enden := 0
var nochmal := 0
while t < 1500.0 and enden < 2:
await get_tree().create_timer(0.2).timeout
t += 0.2
if game == null:
game = _find(get_tree().root, "Game")
if game != null:
game.tempo = 0.0
var raeume := get_tree().root.get_node_or_null("Main/Raeume")
if raeume == null:
continue
if not is_instance_valid(game):
break
if game.phase != letzte:
letzte = game.phase
if letzte == "roll":
runden += 1
var staende: Array = game.seats.map(func(x): return "%s=%d" % [x.name, x.count])
zuletzt = "%s '%s' Runden=%d %s" % [game.phase, game.banner, runden, staende]
if game.phase == "over":
break
print("SERVER: Ende %s" % zuletzt)
var alt: Node = game
var neu := await _warte(func(): game = _find(get_tree().root, "Game"); return game != null and game != alt, 15.0)
if neu:
game.tempo = 0.0
print("SERVER: Nochmal-Partie aufgebaut=%s" % neu)
await get_tree().create_timer(15.0).timeout
for raum in raeume.get_children():
var g := raum.get_node_or_null("Container/Viewport/Game")
if g == null:
continue
if not gesehen.has(g):
gesehen[g] = {"runden": 0, "letzte": "", "raum": raum.name, "fertig": false}
g.tempo = 0.0
if gesehen.size() > 2:
nochmal += 1
var info: Dictionary = gesehen[g]
if g.phase != info.letzte:
info.letzte = g.phase
if g.phase == "roll":
info.runden += 1
if g.phase == "over" and not info.fertig:
info.fertig = true
enden += 1
var staende: Array = g.seats.map(func(x): return "%s=%d" % [x.name, x.count])
print("SERVER: Raum %s Ende '%s' Runden=%d %s" % [raum.name, g.banner, info.runden, staende])
# Die Nochmal-Partien leben nur kurz: die Clients prüfen sie und gehen,
# dann schliesst der Raum. Also laufend schauen statt einmal am Ende.
var w := 0.0
while w < 8.0:
await get_tree().create_timer(0.1).timeout
w += 0.1
var raeume2 := get_tree().root.get_node_or_null("Main/Raeume")
if raeume2 == null:
continue
for raum in raeume2.get_children():
var g := raum.get_node_or_null("Container/Viewport/Game")
if g != null and not gesehen.has(g):
gesehen[g] = {"runden": 0, "letzte": "", "raum": raum.name, "fertig": false}
nochmal += 1
print("SERVER: Räume gesehen=%d, Nochmal-Partien=%d" % [gesehen.size() - nochmal, nochmal])
await get_tree().create_timer(10.0).timeout
get_tree().quit()

View File

@@ -24,12 +24,15 @@ starte logik 90
starte genau 90
starte nochmal 60
starte bots 90 2
rm -f "$TMP/code.txt"
rm -f "$TMP/code.txt" "$TMP/code2.txt"
starte mp_start:srv 200 server "$PORT"
sleep 2
# Zwei Räume auf demselben Server: die RPCs müssen je Raum getrennt bleiben.
PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" starte mp_start:host 190 host "$TMP/code.txt"
PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" starte mp_start:host2 190 host "$TMP/code2.txt"
sleep 1
PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" starte mp_start:join 190 join "$TMP/code.txt"
PIRATE_DICE_SERVER="ws://127.0.0.1:$PORT" starte mp_start:join2 190 join "$TMP/code2.txt"
# Warten, aber bei Skriptfehlern sofort abbrechen.
while :; do
@@ -49,7 +52,7 @@ done
wait 2>/dev/null
ergebnis=0
for name in logik genau nochmal bots mp_start:host mp_start:join mp_start:srv; do
for name in logik genau nochmal bots mp_start:host mp_start:join mp_start:host2 mp_start:join2 mp_start:srv; do
log="$TMP/$name.log"
echo "=== $name"
grep -E "^(OK |FEHL|SERVER:| |TEST:)" "$log"