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

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