update
This commit is contained in:
10
Makefile
10
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.
|
||||
|
||||
21
README.md
21
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` |
|
||||
|
||||
16
assets.md
16
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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
80
scripts/formen.gd
Normal 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
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()])
|
||||
|
||||
@@ -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
2
tests/out/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
52
tests/shots.gd
Normal file
52
tests/shots.gd
Normal 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
14
tests/shots.sh
Executable 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
6
tests/shots.tscn
Normal 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")
|
||||
15
tests/sim.gd
15
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:
|
||||
|
||||
Reference in New Issue
Block a user