From 9800c614bbdf2949fa37ee0540f7937644315b41 Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 16 Sep 2026 17:00:41 +0200 Subject: [PATCH] update --- Makefile | 10 +- README.md | 21 ++-- assets.md | 16 ++- scripts/ai.gd | 29 ++++- scripts/aufdecken.gd | 6 +- scripts/boosters.gd | 8 +- scripts/die.gd | 12 +-- scripts/formen.gd | 80 ++++++++++++++ scripts/game.gd | 43 +++++++- scripts/hud.gd | 32 +++++- scripts/menu.gd | 2 +- scripts/muenzen.gd | 6 +- scripts/stoffe.gd | 244 +++++++++++++++++++++++-------------------- scripts/table.gd | 58 +++++----- tests/bots.gd | 2 +- tests/logik.gd | 30 ++++++ tests/out/.gitignore | 2 + tests/shots.gd | 52 +++++++++ tests/shots.sh | 14 +++ tests/shots.tscn | 6 ++ tests/sim.gd | 15 +-- 21 files changed, 497 insertions(+), 191 deletions(-) create mode 100644 scripts/formen.gd create mode 100644 tests/out/.gitignore create mode 100644 tests/shots.gd create mode 100755 tests/shots.sh create mode 100644 tests/shots.tscn diff --git a/Makefile b/Makefile index 249fd45..2fc1374 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev server prod logs down test sim +.PHONY: dev server prod logs down test sim shots GODOT ?= $(HOME)/bin/godot @@ -16,9 +16,13 @@ dev: test: GODOT=$(GODOT) bash tests/run.sh -# Bot gegen Bot, je Stufe 12 Partien parallel (~3 min): Rundenzahl und Siegquoten. +# Screenshots nach tests/out/ - mit Display oder per xvfb-run. +shots: + GODOT=$(GODOT) bash tests/shots.sh + +# Bot gegen Bot ohne Physik, je Stufe 50 Partien parallel: Rundenzahl und Siegquoten. sim: - @for s in 0 1 2; do $(GODOT) --headless --path . res://tests/sim.tscn -- $$s 12 2>/dev/null | grep "^SIM" & done; wait + @for s in 0 1 2; do $(GODOT) --headless --path . res://tests/sim.tscn -- $$s 50 2>/dev/null | grep "^SIM" & done; wait # ── Produktion ─────────────────────────────────────────────────────────────── # Auf dem Server ausführen, im Projektverzeichnis. Der Build holt Godot selbst. diff --git a/README.md b/README.md index 6e31d51..c41bfb8 100644 --- a/README.md +++ b/README.md @@ -226,10 +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. +**Bonuswürfel gehen nur an den, der hinten liegt.** Nachschub kann werfen, +wer höchstens die halbe Startzahl hat; den Genau-Bonus bekommt nur ein Rufer +mit weniger Würfeln als der Ansager. Sonst kämen Würfel zurück, und der Tisch +würde nie leerer - `make sim` zeigte davor einzelne Partien mit über 50 +Runden. ### Anzeige @@ -247,9 +248,12 @@ liefen über 30 Runden. 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`. +* Kein Bild, kein Mesh aus einer Datei. **Stoffe** sind Regeln (`stoffe.gd`): + Farbverlauf, Rauschart, Reliefstärke, Rauheit. Daraus entstehen zur + Laufzeit drei Texturen je Stoff - Farbe, Relief, Rauheit. Das Relief macht + aus gemustertem Plastik Filz, Holz und Messing. **Formen** sind Bauregeln + (`formen.gd`): Becher = Kegelstumpf + Rand + zwei Bänder, Laterne = Sockel + + Glas + Kappe. Kanten sind schmale Ringe. Der Plan 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. @@ -282,7 +286,8 @@ 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/stoffe.gd` | Materialien aus Regeln, Texturen zur Laufzeit - keine Dateien | +| `scripts/formen.gd` | Becher, Fass, Laterne aus Primitiven nach Bauregeln | | `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` | diff --git a/assets.md b/assets.md index ec71ad6..d1d14d2 100644 --- a/assets.md +++ b/assets.md @@ -1,6 +1,20 @@ # Asset-Plan -Status: Konzept. Noch nicht umgesetzt. +Status: umgesetzt in `scripts/stoffe.gd` (Materialregeln) und +`scripts/formen.gd` (Bauregeln). Unten der Plan, darunter, was davon wie +gebaut ist. + +## Stand + +- Stoffe: Filz, Holz, dunkles Holz, Messing, Knochen. Je Regel drei + Laufzeit-Texturen aus `NoiseTexture2D`: Farbe (Rauschen durch + Farbverlauf), Relief (`as_normal_map`), Rauheit. Kein Shadercode. +- Formen: Becher, Fass, Laterne aus Primitiven. Kanten als Torus-Ringe. +- Gelernt: Rauschen nur als Farbe bleibt flach - das Relief macht den + Unterschied. Grobes, glänzendes Relief auf Filz sieht nach nassem Moos aus: + fein, matt, kaum Höhe. Maserung entsteht durch gestreckte UV (1:9). +- Verworfen: Dielen auf dem Boden (aus Kamerahöhe nur Rauschen), Lampe über + der Mitte (hängt der Kamera vor der Linse), Fass am Boden (ein Fleck). ## Grundidee diff --git a/scripts/ai.gd b/scripts/ai.gd index dcfdb40..1dd0394 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -69,13 +69,19 @@ static func _regler() -> Dictionary: return STUFEN[clampi(stufe, 0, STUFEN.size() - 1)] +## `wissen`: was der Bot über die anderen weiss - "bekannt" sind aufgedeckte +## Gegnerwürfel (Spähen), "fenster" ist je Augenzahl [von, bis] vom Zählen. +## Ohne das würfe er die Münzen und ignorierte, was sie ihm sagen. static func choose_action(values: Array, total_dice: int, bid: Dictionary, - must_bid: bool, rng: RandomNumberGenerator) -> Dictionary: + must_bid: bool, rng: RandomNumberGenerator, wissen: Dictionary = {}) -> Dictionary: var known: Array = [] for v in values: if v > 0: known.append(v) - var unknown := total_dice - known.size() + for v in wissen.get("bekannt", []): + known.append(int(v)) + var unknown := maxi(0, total_dice - known.size()) + var fenster: Dictionary = wissen.get("fenster", {}) if bid.is_empty(): var op := _opening(known, unknown, rng) @@ -83,6 +89,21 @@ static func choose_action(values: Array, total_dice: int, bid: Dictionary, var r := _regler() var hold := chance(known, unknown, bid.count, bid.face) + var genau_p := exact_chance(known, unknown, bid.count, bid.face) + if fenster.has(int(bid.face)): + # Gezählt: das Fenster schlägt jede Schätzung. + var f: Array = fenster[int(bid.face)] + var von := int(f[0]) + var bis := int(f[1]) + if bis < int(bid.count): + hold = 0.0 + genau_p = 0.0 + elif von >= int(bid.count): + hold = 1.0 + genau_p = 1.0 if von == bis and von == int(bid.count) else (0.34 if von == int(bid.count) else 0.0) + else: + hold = float(bis - int(bid.count) + 1) / float(bis - von + 1) + genau_p = 1.0 / float(bis - von + 1) var raise_ = _best_raise(known, unknown, total_dice, bid, float(r.sicher)) var low := lowest_raise(bid, total_dice) @@ -91,8 +112,8 @@ static func choose_action(values: Array, total_dice: int, bid: Dictionary, # Anfechtung: der Ansager verliert, und man bekommt selbst einen dazu. # Aber nur ab und zu: Genau nimmt keinen Würfel vom Tisch. Ein Bot, # der es bei jeder Gelegenheit ruft, zieht die Partie ins Endlose. - if exact_chance(known, unknown, bid.count, bid.face) > float(r.genau) and hold < 0.6 \ - and rng.randf() < GENAU_LUST: + # Sicheres Wissen (Fenster exakt) nutzt er dagegen immer. + if genau_p >= 0.999 or (genau_p > float(r.genau) and hold < 0.6 and rng.randf() < GENAU_LUST): return {"type": "exact"} var doubt := float(r.zweifel) + rng.randf_range(-0.07, 0.07) if hold < doubt and (raise_.is_empty() or raise_.p < 0.32): diff --git a/scripts/aufdecken.gd b/scripts/aufdecken.gd index edca530..7523c0d 100644 --- a/scripts/aufdecken.gd +++ b/scripts/aufdecken.gd @@ -52,8 +52,10 @@ func _phase_reveal(result: Dictionary) -> void: # bringt, weniger als "alle anderen" - das kippte bei vielen Spielern. verlierer = [int(b.seat)] winner = rufer - # Der Bonuswürfel füllt nur auf: wer schon seine Startzahl hat, bekommt keinen. - if g.seats[rufer].count < g.dice_start: + # Der Bonuswürfel geht nur an den, der hinten liegt: weniger Würfel als + # der Ansager. Sonst schiebt Genau nur hin und her, und der Tisch wird + # nie leerer - gemessen an Partien mit über 50 Runden. + if g.seats[rufer].count < g.seats[int(b.seat)].count: bonus = rufer g.banner = "Genau! %s verliert, %s bekommt einen Würfel" % [ g.seats[int(b.seat)].name, g.seats[rufer].name] diff --git a/scripts/boosters.gd b/scripts/boosters.gd index 537342d..5acd6cd 100644 --- a/scripts/boosters.gd +++ b/scripts/boosters.gd @@ -24,9 +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. `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. +## Ansage auf dem Tisch liegt. `platz`: geht nur, wer wirklich hinten liegt - +## höchstens die halbe Startzahl. 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, @@ -49,7 +49,7 @@ const ALL := [ { "id": "gain_die", "kind": Kind.BUFF, "target": false, "gebot": false, "platz": true, "name": "Nachschub", - "desc": "Du bekommst einen Würfel dazu.", + "desc": "Du bekommst einen Würfel dazu. Nur, wenn du höchstens die halbe Startzahl hast.", "stark": "Der neue Würfel zeigt sofort eine 6.", }, { diff --git a/scripts/die.gd b/scripts/die.gd index 1a633d1..2107000 100644 --- a/scripts/die.gd +++ b/scripts/die.gd @@ -31,6 +31,7 @@ const PIPS := { # Körper und Augen stecken in einem gemeinsamen Mesh mit Vertexfarben, # damit ein Würfel nur ein einziges Renderobjekt ist. +const Stoffe = preload("res://scripts/stoffe.gd") static var _mesh: ArrayMesh static var _mat: StandardMaterial3D static var _gold: StandardMaterial3D ## fürs Zählen beim Aufdecken @@ -121,14 +122,11 @@ static func _add_pips(st: SurfaceTool, n: Vector3, t: Array, value: int) -> void func _build_visuals() -> void: if _mesh == null: _mesh = _build_mesh() - _mat = StandardMaterial3D.new() - _mat.vertex_color_use_as_albedo = true - # Kamera und Deckenlicht stehen beide senkrecht: bei glatter Oberfläche - # liegt der Glanzpunkt genau auf der Fläche und hellt die Augen auf. - _mat.roughness = 0.9 - _mat.metallic_specular = 0.0 + # Knochen statt Plastik: Vertexfarben bleiben (Körper hell, Augen + # dunkel), der Shader legt Maserung und einen warmen Kantenschein darüber. + _mat = Stoffe.knochen() if _gold == null: - # Vertexfarben bleiben, werden nur gold getönt - die Augen bleiben dunkel. + # Fürs Zählen: dieselbe Fläche, gold getönt - die Augen bleiben dunkel. _gold = _mat.duplicate() _gold.albedo_color = Color(1.0, 0.82, 0.35) _gold.emission_enabled = true diff --git a/scripts/formen.gd b/scripts/formen.gd new file mode 100644 index 0000000..924a8cf --- /dev/null +++ b/scripts/formen.gd @@ -0,0 +1,80 @@ +extends RefCounted +## Formen aus Regeln: jedes Ding hat eine Grundform aus Godot-Primitiven, und +## Regeln setzen Teile darauf - Rand, Bänder, Reifen, Kappe. Kanten sind +## schmale Ringe statt Fasen. Kein Mesh kommt aus einer Datei. +## Kein class_name: den Klassencache baut nur der Editor, headless fehlt er. + +const Stoffe = preload("res://scripts/stoffe.gd") + + +static func _teil(mesh: Mesh, stoff: Material, pos: Vector3, eltern: Node3D) -> MeshInstance3D: + var mi := MeshInstance3D.new() + mi.mesh = mesh + mi.material_override = stoff + mi.position = pos + eltern.add_child(mi) + return mi + + +static func _zylinder(oben: float, unten: float, hoehe: float, segmente := 28) -> CylinderMesh: + var c := CylinderMesh.new() + c.top_radius = oben + c.bottom_radius = unten + c.height = hoehe + c.radial_segments = segmente + return c + + +static func _ring(radius: float, dicke: float) -> TorusMesh: + var t := TorusMesh.new() + t.inner_radius = radius - dicke + t.outer_radius = radius + dicke + t.rings = 32 + t.ring_segments = 8 + return t + + +## Würfelbecher: Kegelstumpf aus dunklem Holz, oben ein Messingrand, zwei +## Messingbänder um den Bauch. Der Deckel oben ist geschlossen - die Kamera +## schaut von oben, ein offener Becher zeigte nur ein schwarzes Loch. +static func becher(hoehe := 0.44, oben := 0.33, unten := 0.42) -> Node3D: + var n := Node3D.new() + _teil(_zylinder(oben, unten, hoehe), Stoffe.holz_dunkel(), Vector3(0, hoehe * 0.5, 0), n) + _teil(_ring(oben, 0.012), Stoffe.messing(), Vector3(0, hoehe - 0.004, 0), n) + for k in [0.22, 0.62]: + var r: float = lerpf(unten, oben, k) + _teil(_ring(r, 0.008), Stoffe.messing(), Vector3(0, hoehe * k, 0), n) + return n + + +## Fass: bauchiger Zylinder, drei Reifen, Boden und Deckel etwas kleiner. +static func fass(hoehe := 0.6, radius := 0.24) -> Node3D: + var n := Node3D.new() + _teil(_zylinder(radius * 0.86, radius * 0.86, hoehe), Stoffe.holz(), Vector3(0, hoehe * 0.5, 0), n) + _teil(_zylinder(radius, radius, hoehe * 0.5), Stoffe.holz(), Vector3(0, hoehe * 0.5, 0), n) + for k in [0.12, 0.5, 0.88]: + var r: float = radius if absf(k - 0.5) < 0.2 else radius * 0.88 + _teil(_ring(r, 0.012), Stoffe.messing(), Vector3(0, hoehe * k, 0), n) + return n + + +## Laterne: Sockel, Glaskörper (leuchtend), Kappe mit Ring. +static func laterne(hoehe := 0.5) -> Node3D: + var n := Node3D.new() + _teil(_zylinder(0.09, 0.11, 0.05), Stoffe.messing(), Vector3(0, 0.025, 0), n) + var glas := StandardMaterial3D.new() + glas.albedo_color = Color(1.0, 0.85, 0.5, 0.75) + glas.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + glas.emission_enabled = true + glas.emission = Color(1.0, 0.75, 0.35) + glas.emission_energy_multiplier = 2.5 + _teil(_zylinder(0.07, 0.08, hoehe * 0.6), glas, Vector3(0, 0.05 + hoehe * 0.3, 0), n) + _teil(_zylinder(0.03, 0.10, 0.08), Stoffe.messing(), Vector3(0, 0.05 + hoehe * 0.6 + 0.04, 0), n) + _teil(_ring(0.035, 0.006), Stoffe.messing(), Vector3(0, 0.05 + hoehe * 0.6 + 0.10, 0), n) + var licht := OmniLight3D.new() + licht.light_color = Color(1.0, 0.8, 0.45) + licht.light_energy = 1.2 + licht.omni_range = 1.8 + licht.position = Vector3(0, 0.05 + hoehe * 0.3, 0) + n.add_child(licht) + return n diff --git a/scripts/game.gd b/scripts/game.gd index 7f25586..4bd83ae 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -21,6 +21,10 @@ 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 +## Simulation: kein Würfelwurf, keine Physik - Werte werden direkt gezogen. +## Nur für `make sim`; fünfzig Partien in Sekunden statt Minuten. +var simulation := false +var runde := 0 ## laufende Rundennummer, für Anzeige und Messung var rng := RandomNumberGenerator.new() var seats: Array = [] ## [{peer, name, ai, count, alive}] @@ -259,6 +263,7 @@ func _now() -> float: func _phase_roll() -> void: phase = "roll" + runde += 1 deadline = 0.0 reveal_total = 0 bid = {} @@ -277,6 +282,15 @@ func _phase_roll() -> void: table.set_cup_down(i, i != local_seat) _push_state() + if simulation: + for i in _alive_seats(): + host_values[i] = [] + for _k in seats[i].count: + host_values[i].append(rng.randi_range(1, 6)) + table.arrange_and_show(i, host_values[i], rng) + _push_state() + return + _stream = true for i in _alive_seats(): table.throw_dice(i, rng) @@ -369,6 +383,11 @@ func _all_values() -> Array: ## Würfel neu werfen und die Augenzahl danach übernehmen. func _neu_werfen(seat: int, idxs: Array) -> void: + if simulation: + for k: int in idxs: + if k < host_values[seat].size(): + host_values[seat][k] = rng.randi_range(1, 6) + return _stream = true _throw_subset(seat, idxs) await _await_settled([seat]) @@ -415,10 +434,10 @@ func _phase_bid() -> Dictionary: # Erst planen, dann passend dazu eine Münze werfen. Die kann die # Lage ändern - also danach noch einmal entscheiden. var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, - must_bid.get(turn, false), rng) + must_bid.get(turn, false), rng, _bot_wissen(turn)) if await _bot_muenze(turn, a): a = DiceAI.choose_action(host_values[turn], _total_dice(), bid, - must_bid.get(turn, false), rng) + must_bid.get(turn, false), rng, _bot_wissen(turn)) if not _alive(): return {} var res := _apply_action(turn, a) @@ -433,7 +452,7 @@ func _phase_bid() -> Dictionary: else: # Zeit abgelaufen oder getrennt: automatisch entscheiden var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, - must_bid.get(turn, false), rng) + must_bid.get(turn, false), rng, _bot_wissen(turn)) _log("%s wird automatisch gespielt." % seats[turn].name) var res := _apply_action(turn, a) if not res.is_empty(): @@ -442,6 +461,22 @@ func _phase_bid() -> Dictionary: return {} +## Nachschub gibt es nur, wer wirklich hinten liegt: höchstens die halbe +## Startzahl. Gemessen: mit jedem Rückweg dehnt sich die Partie - einzelne +## liefen über 50 Runden. +func _hinten(seat: int) -> bool: + return seats[seat].count * 2 <= dice_start + + +## Was ein Platz über die anderen weiss: aufgedeckte Würfel und Zählfenster. +func _bot_wissen(seat: int) -> Dictionary: + var bekannt: Array = [] + for o in peeked.get(seat, {}): + for paar in peeked[seat][o]: + bekannt.append(int(paar[1])) + return {"bekannt": bekannt, "fenster": gezaehlt.get(seat, {})} + + ## Gibt {} zurück wenn das Spiel weitergeht, sonst das Anfechtungs-Ergebnis. func _apply_action(seat: int, a: Dictionary) -> Dictionary: if a.type == "exact": @@ -633,7 +668,7 @@ func _state_for(seat: int) -> Dictionary: "bids": _seat_bids(), "verlauf": verlauf.duplicate(true), "topf_an": _topf_ziel, - "voll": seats[seat].count >= dice_start, + "voll": not _hinten(seat), "log": log_lines.duplicate(), "hand": (hand.get(seat, []) as Array).duplicate(), } diff --git a/scripts/hud.gd b/scripts/hud.gd index 847f198..828ce7d 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -498,11 +498,37 @@ func _on_exact() -> void: exact_pressed.emit() +## Tastatur: Esc Pause, 1-6 Augenzahl, +/- Anzahl, Enter Ansagen, +## A Anfechten, G Genau. Die Knöpfe entscheiden, was gerade erlaubt ist - +## eine Taste drückt nur, was auch klickbar wäre. func _unhandled_key_input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and not event.echo \ - and event.keycode == KEY_ESCAPE: + if not (event is InputEventKey and event.pressed and not event.echo): + return + var k: Key = event.keycode + if k == KEY_ESCAPE: _set_paused(not pause_slot.visible) - get_viewport().set_input_as_handled() + elif k >= KEY_1 and k <= KEY_6: + var f := int(k - KEY_1) + 1 + if _my_turn and _face_allowed(f): + _on_face(f) + elif k == KEY_PLUS or k == KEY_KP_ADD or k == KEY_UP: + if not plus_btn.disabled: + _step_count(1) + elif k == KEY_MINUS or k == KEY_KP_SUBTRACT or k == KEY_DOWN: + if not minus_btn.disabled: + _step_count(-1) + elif k == KEY_ENTER or k == KEY_KP_ENTER: + if not bid_btn.disabled: + _on_bid() + elif k == KEY_A: + if not challenge_btn.disabled: + _on_challenge() + elif k == KEY_G: + if not exact_btn.disabled: + _on_exact() + else: + return + get_viewport().set_input_as_handled() func _set_paused(on: bool) -> void: diff --git a/scripts/menu.gd b/scripts/menu.gd index 44d4954..79b24bf 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 - aber nie über seine Startzahl hinaus. Daneben verliert der Rufer einen."], + ["Genau", "Wer glaubt, die Ansage trifft exakt, ruft „Genau\". Trifft es, verliert der Ansager einen Würfel; hat der Rufer weniger Würfel als der Ansager, bekommt er einen dazu. Daneben verliert der Rufer einen."], ["Münzen", "Jeder startet mit einer Münze; wer einen Würfel verliert, bekommt eine. Geworfen wird nur am eigenen Zug, vor der Ansage. Wappen verstärkt die Wirkung, Zahl lässt es bei der Grundwirkung - die kommt immer."], ["Topf", "Geworfene Münzen liegen in der Mitte. Wer das Aufdecken gewinnt, nimmt sie. Mehr als vier passen nicht in den Vorrat; der Rest bleibt liegen. Im Topf liegen höchstens sechs - die älteste fällt vom Tisch."], ["Ende", "Wer keine Würfel mehr hat, schaut zu. Der Letzte gewinnt."], diff --git a/scripts/muenzen.gd b/scripts/muenzen.gd index 940b67f..6f51c10 100644 --- a/scripts/muenzen.gd +++ b/scripts/muenzen.gd @@ -31,7 +31,7 @@ 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: + if BoosterDB.needs_room(id) and not g._hinten(seat): return var muenzen: Array = g.hand.get(seat, []) if not muenzen.has(id): @@ -158,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() and vals.size() < g.dice_start: + if muenzen.has("gain_die") and g._hinten(seat): 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"): @@ -181,7 +181,7 @@ func _bot_muenze(seat: int, a: Dictionary) -> bool: for id: String in muenzen: if BoosterDB.needs_bid(id) and g.bid.is_empty(): continue - if BoosterDB.needs_room(id) and vals.size() >= g.dice_start: + if BoosterDB.needs_room(id) and not g._hinten(seat): continue wahl = id break diff --git a/scripts/stoffe.gd b/scripts/stoffe.gd index 5076d85..c57307e 100644 --- a/scripts/stoffe.gd +++ b/scripts/stoffe.gd @@ -1,129 +1,145 @@ 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). +## Materialien aus Regeln, ohne eine einzige Datei. Je Stoff ein Eintrag: +## Farbverlauf, Rauschart, Reliefstärke, Rauheit, Maßstab. Daraus baut +## `mach()` ein StandardMaterial3D mit drei zur Laufzeit erzeugten Texturen: +## Farbe (Rauschen durch einen Farbverlauf), Relief (dasselbe Rauschen als +## Normalenkarte) und Rauheit. Das Relief ist der Unterschied zwischen +## "gemustertes Plastik" und Filz, Holz, Messing. +## +## Kein class_name: den Klassencache baut nur der Editor, headless fehlt er. -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); +const STOFFE := { + "filz": { + # Feiner Flaum, kaum Relief, ganz matt. Grobes, glänzendes Relief sah + # nach nassem Moos aus. + "farben": [Color(0.06, 0.18, 0.11), Color(0.08, 0.23, 0.14), Color(0.10, 0.26, 0.16)], + "art": FastNoiseLite.TYPE_SIMPLEX_SMOOTH, "freq": 0.35, "oktaven": 3, + "relief": 0.12, "rauheit": [0.96, 1.0], "metall": 0.0, "uv": Vector3(14, 14, 1), + "glanz": 0.05, + }, + "holz": { + "farben": [Color(0.20, 0.12, 0.06), Color(0.36, 0.23, 0.11), Color(0.46, 0.31, 0.16)], + "art": FastNoiseLite.TYPE_SIMPLEX, "freq": 0.03, "oktaven": 5, "warp": 25.0, + "relief": 0.6, "rauheit": [0.45, 0.75], "metall": 0.0, + # stark gestreckt: aus dem Rauschen wird Maserung + "uv": Vector3(1.0, 9.0, 1.0), + }, + "holz_dunkel": { + "farben": [Color(0.14, 0.06, 0.04), Color(0.30, 0.12, 0.07), Color(0.40, 0.18, 0.10)], + "art": FastNoiseLite.TYPE_SIMPLEX, "freq": 0.03, "oktaven": 5, "warp": 25.0, + "relief": 0.5, "rauheit": [0.4, 0.7], "metall": 0.0, "uv": Vector3(1.0, 7.0, 1.0), + }, + "messing": { + "farben": [Color(0.55, 0.40, 0.14), Color(0.80, 0.62, 0.26), Color(0.95, 0.80, 0.42)], + "art": FastNoiseLite.TYPE_CELLULAR, "freq": 0.12, "oktaven": 2, + "relief": 0.25, "rauheit": [0.2, 0.5], "metall": 0.9, "uv": Vector3(3, 3, 1), + }, + "knochen": { + # Farbe kommt von den Vertexfarben (Körper hell, Augen dunkel); hier + # nur feine Dellen und eine warme, leicht fettige Oberfläche. + "farben": [], "art": FastNoiseLite.TYPE_SIMPLEX_SMOOTH, "freq": 0.25, "oktaven": 3, + "relief": 0.2, "rauheit": [0.45, 0.65], "metall": 0.0, "uv": Vector3(4, 4, 1), + }, } -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 var _fertig: Dictionary = {} ## name -> StandardMaterial3D, einmal gebaut -static func _shader(code: String) -> Shader: - var s := Shader.new() - s.code = code - return s +static func _rauschen(r: Dictionary, seed: int) -> FastNoiseLite: + var n := FastNoiseLite.new() + n.seed = seed + n.noise_type = r.art + n.frequency = r.freq + n.fractal_octaves = r.oktaven + if r.has("warp"): + n.domain_warp_enabled = true + n.domain_warp_amplitude = r.warp + return n -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) +static func _textur(n: FastNoiseLite, groesse: int) -> NoiseTexture2D: + var t := NoiseTexture2D.new() + t.noise = n + t.width = groesse + t.height = groesse + t.seamless = true + t.generate_mipmaps = true + return t + + +## Baut das Material nach Regel `name`. Gleiche Regel, gleiches Material: +## alle Becher teilen eine Instanz, so bleibt es ein Renderobjekt je Stoff. +static func mach(name: String) -> StandardMaterial3D: + if _fertig.has(name): + return _fertig[name] + var r: Dictionary = STOFFE[name] + var m := StandardMaterial3D.new() + var basis := _rauschen(r, name.hash() % 1000) + + if not r.farben.is_empty(): + var verlauf := Gradient.new() + var farben: Array = r.farben + verlauf.offsets = PackedFloat32Array([0.0, 0.5, 1.0]) + verlauf.colors = PackedColorArray([farben[0], farben[1], farben[2]]) + var farbe := _textur(basis, 256) + farbe.color_ramp = verlauf + m.albedo_texture = farbe + else: + m.vertex_color_use_as_albedo = true + + var relief := _textur(basis, 256) + relief.as_normal_map = true + relief.bump_strength = float(r.relief) * 8.0 + m.normal_enabled = true + m.normal_texture = relief + m.normal_scale = 1.0 + + # Rauheit schwankt mit dem Rauschen zwischen den zwei Werten. + var rau := _textur(_rauschen(r, name.hash() % 1000 + 7), 128) + m.roughness_texture = rau + m.roughness_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_RED + m.roughness = float(r.rauheit[1]) + m.metallic = float(r.metall) + m.metallic_specular = float(r.get("glanz", 0.5 if r.metall > 0.5 else 0.3)) + m.uv1_scale = r.uv + m.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC + _fertig[name] = m 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) +## Eigene Instanz, wenn ein Objekt Farbe oder Leuchten ändern muss. +static func eigene(name: String) -> StandardMaterial3D: + return mach(name).duplicate() + + +static func filz() -> StandardMaterial3D: + return mach("filz") + + +static func holz() -> StandardMaterial3D: + return mach("holz") + + +static func holz_dunkel() -> StandardMaterial3D: + return mach("holz_dunkel") + + +static func messing(emission := 0.0) -> StandardMaterial3D: + var m := eigene("messing") + if emission > 0.0: + m.emission_enabled = true + m.emission = Color(1.0, 0.75, 0.30) + m.emission_energy_multiplier = emission 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) +static func knochen() -> StandardMaterial3D: + return mach("knochen") + + +## Zonenrand: Holz, das leuchten kann, wenn der Platz dran ist. +static func rand() -> StandardMaterial3D: + var m := eigene("holz") + m.emission = Color(1.0, 0.78, 0.30) + m.emission_enabled = false return m diff --git a/scripts/table.gd b/scripts/table.gd index 83d9800..0d35996 100644 --- a/scripts/table.gd +++ b/scripts/table.gd @@ -8,7 +8,8 @@ const WALL_SEGS := 20 ## Bandensegmente - Godot kennt keinen hohlen Zylin const WALL_H := 0.55 ## unsichtbare Begrenzung, damit nichts herausspringt const SEAT_ARC := ZONE_RAD * 2.0 + 0.85 ## Bogenlänge, die ein Platz am Tischrand braucht const MIN_ZONE_R := 1.15 -const Stoffe = preload("res://scripts/stoffe.gd") ## Holz, Filz, Messing aus Rauschen +const Stoffe = preload("res://scripts/stoffe.gd") ## Materialien aus Regeln, ohne Dateien +const Formen = preload("res://scripts/formen.gd") ## Becher, Fass, Laterne aus Primitiven 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 @@ -33,7 +34,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: ShaderMaterial +static var _muenz_mat: StandardMaterial3D var dice: Array = [] ## seat -> Array[Die] var camera: Camera3D @@ -52,9 +53,19 @@ func build(count: int, local_index: int) -> void: for i in count: _build_seat(i) dice.append([]) + _deko() _build_camera() +## Eine Laterne am Tischrand - weit genug draussen, dass sie keine Zone +## verdeckt, nah genug, dass ihr Schein den Filz erreicht. Das Fass aus den +## Formregeln bleibt weg: vom Boden aus ist es aus Kamerahöhe nur ein Fleck. +func _deko() -> void: + var laterne := Formen.laterne() + laterne.position = Vector3(-(table_r + 0.35), TABLE_H, table_r * 0.55) + add_child(laterne) + + func _build_room() -> void: var env := WorldEnvironment.new() var e := Environment.new() @@ -64,6 +75,10 @@ func _build_room() -> void: e.ambient_light_color = Color(0.35, 0.38, 0.45) e.ambient_light_energy = 1.1 e.tonemap_mode = Environment.TONE_MAPPER_FILMIC + e.glow_enabled = true + e.glow_intensity = 0.45 + e.glow_bloom = 0.05 + e.glow_hdr_threshold = 0.9 e.fog_enabled = true e.fog_light_color = Color(0.05, 0.06, 0.09) e.fog_density = 0.02 @@ -81,6 +96,9 @@ func _build_room() -> void: lamp.shadow_enabled = true add_child(lamp) + # Kein sichtbarer Lampenschirm: die Kamera schaut fast senkrecht von oben, + # alles über der Tischmitte hinge ihr vor der Linse. + var fill := DirectionalLight3D.new() fill.rotation_degrees = Vector3(-55, 35, 0) fill.light_energy = 0.25 @@ -98,6 +116,8 @@ func _build_room() -> void: var fpm := PlaneMesh.new() fpm.size = Vector2(24, 24) floor_mi.mesh = fpm + # Bewusst glatt und dunkel: eine Maserung auf der riesigen Fläche wird aus + # Kamerahöhe zu Rauschen, und der Nebel soll den Boden ohnehin schlucken. floor_mi.material_override = _mat(Color(0.08, 0.07, 0.09), 0.9) floor_body.add_child(floor_mi) add_child(floor_body) @@ -118,7 +138,7 @@ func _build_room() -> void: tcm.height = 0.12 tmi.mesh = tcm tmi.position = Vector3(0, TABLE_H - 0.06, 0) - tmi.material_override = Stoffe.filz(Color(0.10, 0.28, 0.17), table_r, 0.35) + tmi.material_override = Stoffe.filz() table.add_child(tmi) add_child(table) @@ -130,7 +150,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 = Stoffe.holz(Color(0.30, 0.19, 0.09), Color(0.16, 0.10, 0.05), 0.5, 4.0) + leg.material_override = Stoffe.holz() add_child(leg) @@ -167,7 +187,7 @@ func _build_seat(i: int) -> void: fm.radial_segments = 32 felt.mesh = fm felt.position = Vector3(0, 0.006, 0) - felt.material_override = Stoffe.filz(Color(0.07, 0.20, 0.13), ZONE_RAD, 0.6) + felt.material_override = Stoffe.filz() zone.add_child(felt) # Sichtbarer Rand @@ -179,7 +199,7 @@ func _build_seat(i: int) -> void: tm.ring_segments = 8 rim.mesh = tm rim.position = Vector3(0, 0.02, 0) - rim.material_override = _mat(RIM_RUHE, 0.6) + rim.material_override = Stoffe.rand() zone.add_child(rim) rims.append(rim) @@ -187,17 +207,7 @@ func _build_seat(i: int) -> void: var cup := Node3D.new() cup.position = Vector3(0, 0.02, 0) zone.add_child(cup) - var cup_mi := MeshInstance3D.new() - var cm := CylinderMesh.new() - cm.top_radius = 0.33 - cm.bottom_radius = 0.42 - cm.height = 0.44 - cup_mi.mesh = cm - cup_mi.position = Vector3(0, 0.22, 0) - # 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) + cup.add_child(Formen.becher()) cups.append(cup) cup_tweens.append(null) @@ -550,7 +560,7 @@ func set_topf(liste: Array, ziel := -1) -> void: tw.tween_callback(mi.queue_free) -static var _muenz_mat_wappen: ShaderMaterial +static var _muenz_mat_wappen: StandardMaterial3D static var _praeg_mesh: TorusMesh static var _praeg_mat: StandardMaterial3D @@ -567,9 +577,9 @@ static func _muenz_form() -> CylinderMesh: return _muenz_mesh -static func _muenz_stoff(wappen: bool) -> ShaderMaterial: +static func _muenz_stoff(wappen: bool) -> StandardMaterial3D: if _muenz_mat == null: - _muenz_mat = Stoffe.messing(0.35) + _muenz_mat = Stoffe.messing(0.25) _muenz_mat_wappen = Stoffe.messing(0.9) return _muenz_mat_wappen if wappen else _muenz_mat @@ -620,12 +630,8 @@ func hervorhebung_weg() -> void: func set_active_seat(seat: int) -> void: for i in rims.size(): var m: StandardMaterial3D = rims[i].material_override - var dran: bool = i == seat - m.albedo_color = RIM_ZUG if dran else RIM_RUHE - m.emission_enabled = dran - if dran: - m.emission = Color(1.0, 0.78, 0.30) - m.emission_energy_multiplier = 1.1 + m.emission_enabled = i == seat + m.emission_energy_multiplier = 1.2 func set_dice_visible(seat: int, on: bool) -> void: diff --git a/tests/bots.gd b/tests/bots.gd index ffb6f8b..e8cec86 100644 --- a/tests/bots.gd +++ b/tests/bots.gd @@ -57,7 +57,7 @@ func _lauf() -> void: for d in game.table.dice_of(i): print(" Wuerfel Platz %d: pos=%s sleeping=%s freeze=%s settled=%s flat=%.2f" % [ i, d.global_position, d.sleeping, d.freeze, d.is_settled(), d.flatness()]) - _pruef("Partie endet (%s, Stufe %d, %d Runden, %.0fs Spielzeit)" % [game.banner, DiceAI.stufe, runden, t], game.phase == "over") + _pruef("Partie endet (%s, Stufe %d, %d Runden, %.0fs Spielzeit)" % [game.banner, DiceAI.stufe, game.runde, t], game.phase == "over") _pruef("Münzen wurden geworfen (%d)" % zaehler[" wirft "], zaehler[" wirft "] > 0) _pruef("Topf wurde vergeben (%d)" % zaehler["Topf"], zaehler["Topf"] > 0) print(" Genau-Rufe=%d, vom Tisch=%d, Topf am Ende=%d" % [zaehler["Genau"], zaehler["vom Tisch"], game.topf.size()]) diff --git a/tests/logik.gd b/tests/logik.gd index ea3b4eb..5b631e7 100644 --- a/tests/logik.gd +++ b/tests/logik.gd @@ -88,6 +88,36 @@ func _lauf() -> void: _pruef("Würfeln endet trotz verirrtem Würfel, Wert gelesen", d0.global_position.y > 0.5 and game.host_values[me][0] >= 1 and game.host_values[me][0] <= 6) + # --- KI nutzt, was Spähen und Zählen verraten + var r := RandomNumberGenerator.new() + r.seed = 7 + var gebot := {"seat": 1, "count": 4, "face": 5} + var a: Dictionary = DiceAI.choose_action([1, 2, 3], 9, gebot, false, r, {"fenster": {5: [1, 2]}}) + _pruef("KI: Zählfenster unter der Ansage -> Anfechten", a.type == "challenge") + a = DiceAI.choose_action([5, 5, 2], 9, gebot, false, r, {"bekannt": [5, 5, 5]}) + _pruef("KI: aufgedeckte Fünfen machen die Ansage sicher -> kein Anfechten", a.type != "challenge") + a = DiceAI.choose_action([1, 2, 3], 9, gebot, false, r, {"fenster": {5: [4, 4]}}) + _pruef("KI: exaktes Fenster -> Genau", a.type == "exact") + + # --- Tastatur: Taste tut nur, was der Knopf gerade darf + var taste := func(code: Key) -> void: + var ev := InputEventKey.new() + ev.keycode = code + ev.pressed = true + hud._unhandled_key_input(ev) + var gebote := [] + hud.bid_pressed.connect(func(c, f): gebote.append([c, f])) + game._push_state() + await get_tree().create_timer(0.3).timeout + taste.call(KEY_3) + taste.call(KEY_PLUS) + taste.call(KEY_ENTER) + _pruef("Tastatur: 3, +, Enter sagt 2 x 3 an", gebote == [[2, 3]]) + var angefochten := [] + hud.challenge_pressed.connect(func(): angefochten.append(1)) + taste.call(KEY_A) + _pruef("Tastatur: A ohne Ansage tut nichts", angefochten.is_empty()) + # --- Protokoll und Tipp-Schranke im HUD game._push_state() await get_tree().create_timer(0.3).timeout diff --git a/tests/out/.gitignore b/tests/out/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/out/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/shots.gd b/tests/shots.gd new file mode 100644 index 0000000..4bab5d8 --- /dev/null +++ b/tests/shots.gd @@ -0,0 +1,52 @@ +extends "res://tests/basis.gd" +## Bilder vom Spiel und vom Menü für die Sichtprüfung. Braucht ein Display - +## oder xvfb, siehe tests/shots.sh. Schreibt nach tests/out/. + +var game: Node +const OUT := "res://tests/out" + + +func _ready() -> void: + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT)) + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_WINDOWED) + DisplayServer.window_set_size(Vector2i(1280, 720)) + var main := preload("res://scenes/main.tscn").instantiate() + add_child(main) + await get_tree().process_frame + main.menu._show("regeln") + await get_tree().create_timer(0.3).timeout + await _bild("menue_regeln") + main.menu._show("main") + await get_tree().create_timer(0.2).timeout + await _bild("menue_start") + Net.start_singleplayer("Tester", 3, 1) + _lauf() + + +func _bild(name: String) -> void: + await RenderingServer.frame_post_draw + var ok := get_viewport().get_texture().get_image().save_png("%s/%s.png" % [OUT, name]) + _pruef("Bild %s" % name, ok == OK) + + +func _lauf() -> void: + await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0) + await _warte(func(): return game.phase == "bid", 40.0) + var me: int = game.local_seat + game.hand[me] = ["gag", "peek", "count"] + game._push_state() + await get_tree().create_timer(0.4).timeout + await _bild("tisch_zug") + await game._do_play(me, "gag", 0) + await game._do_play(me, "peek", 0) + await get_tree().create_timer(1.2).timeout + await _bild("tisch_topf") + game.hud.hand_box.get_child(0).mouse_entered.emit() + await get_tree().create_timer(0.3).timeout + await _bild("tisch_erklaerung") + var n5: int = game._count_face(5) + game.bid = {"seat": 1, "count": maxi(1, n5), "face": 5} + game._phase_reveal({"challenger": me, "bid": game.bid, "exact": false}) + await get_tree().create_timer(1.3 + 1.2).timeout + await _bild("aufdecken_zaehlen") + _fertig() diff --git a/tests/shots.sh b/tests/shots.sh new file mode 100755 index 0000000..42b5cfd --- /dev/null +++ b/tests/shots.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Screenshots für die Sichtprüfung nach tests/out/. Mit Display direkt, ohne +# Display über xvfb-run (Paket xvfb). Aufruf: make shots +set -u +GODOT="${GODOT:-$HOME/bin/godot}" +cd "$(dirname "$0")/.." +if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then + timeout 120 "$GODOT" --path . res://tests/shots.tscn 2>&1 | grep -E "^(OK |FEHL|TEST:)" +elif command -v xvfb-run >/dev/null; then + timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" "$GODOT" --path . res://tests/shots.tscn 2>&1 | grep -E "^(OK |FEHL|TEST:)" +else + echo "Kein Display und kein xvfb-run - Screenshots übersprungen."; exit 0 +fi +ls -1 tests/out/ diff --git a/tests/shots.tscn b/tests/shots.tscn new file mode 100644 index 0000000..88851ab --- /dev/null +++ b/tests/shots.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/shots.gd" id="1"] + +[node name="Test" type="Node"] +script = ExtResource("1") diff --git a/tests/sim.gd b/tests/sim.gd index 5a32d67..42d3cb6 100644 --- a/tests/sim.gd +++ b/tests/sim.gd @@ -29,18 +29,13 @@ func _lauf() -> void: Net.start_singleplayer("Tester", 2, stufe, 3) await _warte(func(): game = _find(get_tree().root, "Game"); return game != null and game.phase != "lobby", 20.0) game.tempo = 0.0 + game.simulation = true game.seats[game.local_seat].ai = true - var runden := 0 - var letzte := "" var gesehen := {} var t := 0.0 while game.phase != "over" and t < 300.0: - await get_tree().create_timer(0.1).timeout - t += 0.1 - if game.phase != letzte: - letzte = game.phase - if letzte == "roll": - runden += 1 + await get_tree().create_timer(0.02).timeout + t += 0.02 for z in game.log_lines: if gesehen.has(z): continue @@ -49,8 +44,8 @@ func _lauf() -> void: genau += 1 if " wirft " in z: wuerfe += 1 - runden_summe += runden - laengste = maxi(laengste, runden) + runden_summe += game.runde + laengste = maxi(laengste, game.runde) var sieger := "" for s in game.seats: if s.alive: