This commit is contained in:
team3
2026-09-16 17:00:41 +02:00
parent f1c78f66f4
commit 9800c614bb
21 changed files with 497 additions and 191 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.",
},
{

View File

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

80
scripts/formen.gd Normal file
View File

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

View File

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

View File

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

View File

@@ -200,7 +200,7 @@ func _build_regeln() -> Control:
["Würfeln", "Jeder hat fünf Würfel unter dem Becher. Nur die eigenen sieht man."],
["Ansagen", "Reihum sagt man, wie viele Würfel einer Augenzahl auf dem ganzen Tisch liegen - zum Beispiel „vier Dreien\". Wer dran ist, muss höher gehen: mehr Würfel, oder gleich viele mit höherer Augenzahl. Oder er zweifelt."],
["Anfechten", "Stimmt die Ansage nicht, verliert der Ansager einen Würfel. Stimmt sie, verliert der Zweifler einen. Mehr als angesagt zählt als richtig."],
["Genau", "Wer glaubt, die Ansage trifft exakt, ruft „Genau\". Trifft es, verliert der Ansager einen Würfel und der Rufer bekommt einen dazu - 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."],

View File

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

View File

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

View File

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

View File

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

View File

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

2
tests/out/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

52
tests/shots.gd Normal file
View File

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

14
tests/shots.sh Executable file
View File

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

6
tests/shots.tscn Normal file
View File

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

View File

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