diff --git a/README.md b/README.md index 3a6f5c2..6e31d51 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/project.godot b/project.godot index 6673812..68a5db2 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/scripts/aufdecken.gd b/scripts/aufdecken.gd index 1e58798..edca530 100644 --- a/scripts/aufdecken.gd +++ b/scripts/aufdecken.gd @@ -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) diff --git a/scripts/boosters.gd b/scripts/boosters.gd index 98d053c..537342d 100644 --- a/scripts/boosters.gd +++ b/scripts/boosters.gd @@ -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) diff --git a/scripts/game.gd b/scripts/game.gd index 6998ab0..7f25586 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -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(), } diff --git a/scripts/hud.gd b/scripts/hud.gd index 0011f2b..847f198 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -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 diff --git a/scripts/hud_panels.gd b/scripts/hud_panels.gd new file mode 100644 index 0000000..ef4d2f8 --- /dev/null +++ b/scripts/hud_panels.gd @@ -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) diff --git a/scripts/icons.gd b/scripts/icons.gd new file mode 100644 index 0000000..3ba66cb --- /dev/null +++ b/scripts/icons.gd @@ -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)) diff --git a/scripts/menu.gd b/scripts/menu.gd index 66453ec..44d4954 100644 --- a/scripts/menu.gd +++ b/scripts/menu.gd @@ -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."], diff --git a/scripts/muenzen.gd b/scripts/muenzen.gd index ad09925..940b67f 100644 --- a/scripts/muenzen.gd +++ b/scripts/muenzen.gd @@ -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) diff --git a/scripts/stoffe.gd b/scripts/stoffe.gd new file mode 100644 index 0000000..5076d85 --- /dev/null +++ b/scripts/stoffe.gd @@ -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 diff --git a/scripts/table.gd b/scripts/table.gd index c114c31..83d9800 100644 --- a/scripts/table.gd +++ b/scripts/table.gd @@ -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 diff --git a/tests/genau.gd b/tests/genau.gd index a141bf7..9786b5b 100644 --- a/tests/genau.gd +++ b/tests/genau.gd @@ -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) diff --git a/tests/logik.gd b/tests/logik.gd index 84b25da..ea3b4eb 100644 --- a/tests/logik.gd +++ b/tests/logik.gd @@ -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() diff --git a/tests/mp_client.gd b/tests/mp_client.gd index d496e39..9bc9a32 100644 --- a/tests/mp_client.gd +++ b/tests/mp_client.gd @@ -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(): diff --git a/tests/mp_server.gd b/tests/mp_server.gd index e380362..80a96cb 100644 --- a/tests/mp_server.gd +++ b/tests/mp_server.gd @@ -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() diff --git a/tests/run.sh b/tests/run.sh index 0c9bc30..33d94b6 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -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"