This commit is contained in:
team3
2026-09-16 17:55:28 +02:00
parent 9800c614bb
commit a0597110bd
17 changed files with 652 additions and 278 deletions

View File

@@ -84,6 +84,7 @@ func _phase_reveal(result: Dictionary) -> void:
g.seats[l].count = 0
g.host_values[l] = []
g.table.set_dice_count(l, 0)
g.ausgeschieden.append(l)
g._log("%s scheidet aus." % g.seats[l].name)
else:
g._zieh_muenze(l)

View File

@@ -1,10 +1,76 @@
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.
## Formen aus Regeln. Der Kern des Low-Poly-Looks sind runde Kanten: jede
## Form wird als Profil (Radius, Höhe) beschrieben und um die Achse gedreht.
## Fasen sind Viertelkreise im Profil. 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")
const FASE_SEG := 4 ## Punkte je Viertelkreis - vier reichen bei glatten Normalen
## Ein Profil um die Y-Achse drehen. `profil` läuft von unten (Achse) nach
## oben (Achse), Punkte sind (radius, höhe). Glatte Normalen, damit die
## Fasen weich wirken und nicht wie Absätze.
static func drehen(profil: PackedVector2Array, segmente := 40) -> ArrayMesh:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
st.set_smooth_group(0)
for i in range(profil.size() - 1):
var a := profil[i]
var b := profil[i + 1]
for k in segmente:
var t0 := TAU * float(k) / segmente
var t1 := TAU * float(k + 1) / segmente
var p00 := Vector3(cos(t0) * a.x, a.y, sin(t0) * a.x)
var p10 := Vector3(cos(t1) * a.x, a.y, sin(t1) * a.x)
var p01 := Vector3(cos(t0) * b.x, b.y, sin(t0) * b.x)
var p11 := Vector3(cos(t1) * b.x, b.y, sin(t1) * b.x)
if a.x > 0.0001:
st.add_vertex(p00)
st.add_vertex(p10)
st.add_vertex(p01)
if b.x > 0.0001:
st.add_vertex(p10)
st.add_vertex(p11)
st.add_vertex(p01)
st.index()
st.generate_normals()
return st.commit()
## Viertelkreis von `von` nach `bis` um die Ecke `mitte` (Profilkoordinaten).
static func _bogen(mitte: Vector2, r: float, a0: float, a1: float) -> PackedVector2Array:
var out := PackedVector2Array()
for k in range(1, FASE_SEG + 1):
var t := lerpf(a0, a1, float(k) / FASE_SEG)
out.append(mitte + Vector2(cos(t), sin(t)) * r)
return out
## Zylinder oder Kegelstumpf mit runden Kanten oben und unten.
static func rund_zylinder(r_unten: float, r_oben: float, hoehe: float, fase: float) -> ArrayMesh:
fase = minf(fase, minf(hoehe * 0.5, minf(r_unten, r_oben) * 0.9))
var p := PackedVector2Array()
p.append(Vector2(0.0, 0.0))
p.append(Vector2(r_unten - fase, 0.0))
p.append_array(_bogen(Vector2(r_unten - fase, fase), fase, -PI * 0.5, 0.0))
p.append_array(_bogen(Vector2(r_oben - fase, hoehe - fase), fase, 0.0, PI * 0.5))
p.append(Vector2(0.0, hoehe))
return drehen(p)
static func scheibe(radius: float, hoehe: float, fase: float) -> ArrayMesh:
return rund_zylinder(radius, radius, hoehe, fase)
## Ring mit rundem Querschnitt - für Ränder und Bänder.
static func ring(radius: float, dicke: float) -> TorusMesh:
var t := TorusMesh.new()
t.inner_radius = radius - dicke
t.outer_radius = radius + dicke
t.rings = 40
t.ring_segments = 10
return t
static func _teil(mesh: Mesh, stoff: Material, pos: Vector3, eltern: Node3D) -> MeshInstance3D:
@@ -16,65 +82,31 @@ static func _teil(mesh: Mesh, stoff: Material, pos: Vector3, eltern: Node3D) ->
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.
## Würfelbecher: Kegelstumpf mit runden Kanten, oben ein Messingrand, ein
## Band um den Bauch. Geschlossen - die Kamera schaut von oben.
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)
_teil(rund_zylinder(unten, oben, hoehe, 0.05), Stoffe.holz_dunkel(), Vector3.ZERO, n)
_teil(ring(oben - 0.01, 0.018), Stoffe.messing(), Vector3(0, hoehe - 0.02, 0), n)
_teil(ring(lerpf(unten, oben, 0.35) + 0.004, 0.012), Stoffe.messing(), Vector3(0, hoehe * 0.35, 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.
## Laterne: Sockel, leuchtendes Glas, Kappe, Bügel.
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
_teil(rund_zylinder(0.11, 0.09, 0.05, 0.015), Stoffe.messing(), Vector3.ZERO, n)
var glas := Stoffe.eigene("glas")
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)
glas.emission = Color(1.0, 0.80, 0.40)
glas.emission_energy_multiplier = 1.6
_teil(rund_zylinder(0.08, 0.07, hoehe * 0.6, 0.02), glas, Vector3(0, 0.05, 0), n)
_teil(rund_zylinder(0.10, 0.03, 0.08, 0.015), Stoffe.messing(), Vector3(0, 0.05 + hoehe * 0.6, 0), n)
_teil(ring(0.035, 0.007), 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.light_color = Color(1.0, 0.85, 0.55)
licht.light_energy = 0.8
licht.omni_range = 1.6
licht.position = Vector3(0, 0.05 + hoehe * 0.3, 0)
n.add_child(licht)
return n

View File

@@ -25,6 +25,8 @@ var dice_start := START_DICE
## 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 ausgeschieden: Array = [] ## Plätze in der Reihenfolge ihres Ausscheidens
var cfg_start: Dictionary = {} ## Startaufstellung, fürs Nochmal
var rng := RandomNumberGenerator.new()
var seats: Array = [] ## [{peer, name, ai, count, alive}]
@@ -67,6 +69,7 @@ func _ready() -> void:
func setup(cfg: Dictionary) -> void:
cfg_start = cfg.duplicate(true)
DiceAI.stufe = int(cfg.get("stufe", 1))
seats.clear()
if cfg.has("seats"):
@@ -83,8 +86,11 @@ func setup(cfg: Dictionary) -> void:
for p in peers:
seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false,
"count": dice, "alive": true, "verwaist": false})
var bots: Array = cfg.get("bots", [])
for i in int(cfg.ai):
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true,
var b: Dictionary = bots[i] if i < bots.size() else {}
seats.append({"peer": 0, "name": String(b.get("name", "Bot %d" % (i + 1))), "ai": true,
"stufe": int(b.get("stufe", DiceAI.stufe)),
"count": dice, "alive": true, "verwaist": false})
# Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz.
@@ -108,6 +114,7 @@ func setup(cfg: Dictionary) -> void:
hud.challenge_pressed.connect(_on_challenge_pressed)
hud.exact_pressed.connect(_on_exact_pressed)
hud.rematch_pressed.connect(_on_rematch)
hud.karte_pressed.connect(_on_karte)
hud.booster_chosen.connect(_on_booster_chosen)
hud.quit_pressed.connect(_on_quit)
hud.pause_changed.connect(_on_pause_changed)
@@ -209,6 +216,7 @@ func _host_loop() -> void:
winner = s.name
banner = "%s gewinnt" % winner
_log(banner)
Kampagne.ergebnis(seats, ausgeschieden)
_push_state()
@@ -433,6 +441,7 @@ func _phase_bid() -> Dictionary:
await _sleep(1.1)
# Erst planen, dann passend dazu eine Münze werfen. Die kann die
# Lage ändern - also danach noch einmal entscheiden.
DiceAI.stufe = int(seats[turn].get("stufe", DiceAI.stufe))
var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid,
must_bid.get(turn, false), rng, _bot_wissen(turn))
if await _bot_muenze(turn, a):
@@ -788,7 +797,19 @@ func _on_rematch() -> void:
for s in seats:
if s.ai:
bots += 1
Net.rematch = {"name": Net.player_name, "ai": bots, "stufe": DiceAI.stufe}
var kap := ""
if Kampagne.kampagnenpartie and cfg_start.get("bots", []).size() > 0:
kap = String(cfg_start.bots[0].name)
Net.rematch = {"name": Net.player_name, "ai": bots, "stufe": int(cfg_start.get("stufe", 1)),
"bots": cfg_start.get("bots", []), "kampagne": kap}
_on_quit()
## Zurück auf die Seekarte: das frisch geladene Menü zeigt sie sofort.
func _on_karte() -> void:
if _quitting:
return
Kampagne.zur_karte = true
_on_quit()

View File

@@ -5,6 +5,7 @@ signal bid_pressed(count: int, face: int)
signal challenge_pressed
signal exact_pressed
signal rematch_pressed
signal karte_pressed
signal booster_chosen(id: String, target: int)
signal quit_pressed
signal pause_changed(paused: bool)
@@ -54,6 +55,7 @@ var pause_slot: Control
var over_slot: Control
var over_title: Label
var over_again: Button
var over_karte: Button
var pause_hint: Label
var boost_panel: PanelContainer
var boost_cards: HBoxContainer
@@ -178,9 +180,12 @@ func _build() -> void:
# --- Letzte Wette in der Tischmitte, als so viele Würfel wie angesagt
var bid_slot := _slot(0, 0, 1, 1, 0, 0, 0, 0)
var bid_panel := _panel(Color(0.05, 0.06, 0.09, 0.55))
bid_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
bid_slot.add_child(bid_panel)
bid_box = VBoxContainer.new()
bid_box.add_theme_constant_override("separation", 8)
bid_slot.add_child(bid_box)
bid_panel.add_child(bid_box)
bid_name = _label("", 21, ACCENT)
bid_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# Auf die freie Mitte begrenzen, sonst läuft der Text über die Nachbarplätze.
@@ -205,11 +210,14 @@ func _build() -> void:
root.add_child(banner_label)
# --- Wer ist dran: direkt über der Ansageleiste, wo der Blick ohnehin liegt
# Im eigenen Feld: auf der hellen Platte wäre gelbe Schrift unlesbar.
var tp := _panel(Color(0.05, 0.06, 0.09, 0.85))
tp.mouse_filter = Control.MOUSE_FILTER_IGNORE
_slot(0, 1, 1, 1, 16, -152, -16, -114).add_child(tp)
turn_label = _label("", 19, ACCENT)
turn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
turn_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_place(turn_label, 0, 1, 1, 1, 16, -146, -16, -114)
root.add_child(turn_label)
tp.add_child(turn_label)
# --- Ansage: kompakte Leiste unten in der Mitte
action_panel = _panel()
@@ -312,10 +320,12 @@ func _build() -> void:
# den Tisch; hier steht nur, wer was geworfen hat und wie es fiel.
wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 200)
wurf_slot.visible = false
var wp := _panel()
wurf_slot.add_child(wp)
wurf_label = _label("", 16, ACCENT)
wurf_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
wurf_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
wurf_slot.add_child(wurf_label)
wp.add_child(wurf_label)
# --- Protokoll unten rechts: was der Topf tut, wer geht, wer zurückkommt.
var lp := _panel()
@@ -427,6 +437,12 @@ func _build() -> void:
over_again.custom_minimum_size = Vector2(0, 42)
over_again.pressed.connect(func() -> void: rematch_pressed.emit())
ov.add_child(over_again)
over_karte = Button.new()
over_karte.text = "Zur Seekarte"
over_karte.custom_minimum_size = Vector2(0, 42)
over_karte.pressed.connect(func() -> void: karte_pressed.emit())
over_karte.visible = false
ov.add_child(over_karte)
var menue := Button.new()
menue.text = "Zum Menü"
menue.custom_minimum_size = Vector2(0, 42)
@@ -621,6 +637,10 @@ func update_state(st: Dictionary, my_seat: int) -> void:
banner_label.text = "" if st.phase == "over" else st.banner
if st.phase == "over" and not over_slot.visible:
over_title.text = String(st.banner)
# Kampagne: Wertung und Rang statt nur des Siegers.
if Kampagne.kampagnenpartie and Kampagne.letztes != "":
over_title.text = String(st.banner) + "\n" + Kampagne.letztes
over_karte.visible = Kampagne.kampagnenpartie
# Nochmal: solo immer, im Netz nur für den Raumbesitzer - der Server
# setzt den Raum dann für alle neu auf.
over_again.visible = Net.room_code == Net.SOLO or Net.is_owner

173
scripts/kampagne.gd Normal file
View File

@@ -0,0 +1,173 @@
extends Node
## Kampagne: eine Seekarte mit Kapitänen. Wen du schlägst, der verschwindet
## auf See und kommt nach zwei Partien stärker zurück - höhere Wertung, bessere
## Bots, mehr Mannschaft. Du hast eine Wertung (Elo) und daraus einen Rang.
## Der Stand liegt in user://kampagne.json.
const DATEI := "user://kampagne.json"
const K := 32.0 ## Elo-Schrittweite
const AUF_SEE := 2 ## Partien, bis ein Geschlagener zurück ist
const RAENGE := [
[0, "Schiffsjunge"], [950, "Matrose"], [1050, "Maat"],
[1150, "Steuermann"], [1280, "Kapitän"], [1420, "Admiral"],
]
## Kapitäne mit Wertung, Bot-Stufe, Mannschaft (weitere Bots eine Stufe
## darunter) und Platz auf der Karte (0..1 in Breite und Höhe).
const START_KAPITAENE := [
{"name": "Roter Jack", "elo": 900, "stufe": 0, "mannschaft": 0, "pos": Vector2(0.14, 0.32)},
{"name": "Salz-Marie", "elo": 960, "stufe": 0, "mannschaft": 1, "pos": Vector2(0.33, 0.68)},
{"name": "Einauge Bill", "elo": 1030, "stufe": 1, "mannschaft": 1, "pos": Vector2(0.52, 0.24)},
{"name": "Käpt'n Grau", "elo": 1110, "stufe": 1, "mannschaft": 2, "pos": Vector2(0.70, 0.62)},
{"name": "Die Witwe", "elo": 1210, "stufe": 2, "mannschaft": 2, "pos": Vector2(0.87, 0.30)},
{"name": "Admiral Krähe", "elo": 1360, "stufe": 2, "mannschaft": 3, "pos": Vector2(0.50, 0.86)},
]
var datei := DATEI ## Tests schreiben woandershin
var elo := 1000.0
var partien := 0
var siege := 0
var kapitaene: Array = [] ## {name, elo, stufe, mannschaft, pos, weg, geschlagen}
var aktiv := "" ## Kapitän der laufenden Partie, "" = keine Kampagnenpartie
var letztes := "" ## Text zum letzten Ergebnis, fürs Endfeld
var zur_karte := false ## nach dem Ende zurück auf die Karte statt ins Hauptmenü
var kampagnenpartie := false ## die laufende (oder gerade beendete) Partie gehört zur Kampagne
func _ready() -> void:
laden()
static func rang(wert: float) -> String:
var r := ""
for stufe in RAENGE:
if wert >= float(stufe[0]):
r = String(stufe[1])
return r
func mein_rang() -> String:
return rang(elo)
func zuruecksetzen() -> void:
elo = 1000.0
partien = 0
siege = 0
aktiv = ""
letztes = ""
kapitaene = []
for k in START_KAPITAENE:
var e: Dictionary = k.duplicate()
e.weg = 0
e.geschlagen = 0
kapitaene.append(e)
func kapitaen(name: String) -> Dictionary:
for k in kapitaene:
if k.name == name:
return k
return {}
## Partie gegen einen Kapitän: er selbst auf seiner Stufe, seine Mannschaft
## eine Stufe darunter. Der Spieler sitzt auf Platz 0.
func starten(name: String) -> void:
var k := kapitaen(name)
if k.is_empty() or int(k.weg) > 0:
return
aktiv = name
kampagnenpartie = true
var bots: Array = [{"name": name, "stufe": int(k.stufe)}]
for i in int(k.mannschaft):
bots.append({"name": "Maat %d" % (i + 1), "stufe": maxi(0, int(k.stufe) - 1)})
Net.start_singleplayer(Net.player_name, bots.size(), int(k.stufe), 5, bots)
## Wird vom Spiel am Ende gerufen. `ausgeschieden` ist die Reihenfolge der
## Plätze, die rausgeflogen sind. Geschlagen ist der Kapitän, wenn er vor dem
## Spieler ging - oder der Spieler gewonnen hat.
func ergebnis(seats: Array, ausgeschieden: Array) -> void:
if aktiv == "":
return
var k := kapitaen(aktiv)
aktiv = ""
if k.is_empty():
return
var spieler := 0
var kap := -1
for i in seats.size():
if String(seats[i].name) == String(k.name):
kap = i
var sieg: bool
if seats[spieler].alive:
sieg = true
elif kap >= 0 and ausgeschieden.has(kap) and ausgeschieden.has(spieler):
sieg = ausgeschieden.find(kap) < ausgeschieden.find(spieler)
else:
sieg = false
var erwartet := 1.0 / (1.0 + pow(10.0, (float(k.elo) - elo) / 400.0))
var punkte := 1.0 if sieg else 0.0
var vorher := elo
var delta := K * (punkte - erwartet)
elo = maxf(0.0, elo + delta)
k.elo = maxf(0.0, float(k.elo) - delta)
partien += 1
# Die anderen auf See rücken einen Schritt näher an die Rückkehr.
for o in kapitaene:
if o != k and int(o.weg) > 0:
o.weg = int(o.weg) - 1
if int(o.weg) == 0:
_zurueck(o)
if sieg:
siege += 1
k.weg = AUF_SEE
k.geschlagen = int(k.geschlagen) + 1
letztes = "Sieg gegen %s! %d → %d (%s)" % [k.name, int(round(vorher)), int(round(elo)), mein_rang()]
else:
letztes = "%s behält die Oberhand. %d → %d (%s)" % [k.name, int(round(vorher)), int(round(elo)), mein_rang()]
speichern()
## Zurück von See: besser als vorher. Stufe bis 2, dann wächst die Mannschaft.
func _zurueck(k: Dictionary) -> void:
k.elo = float(k.elo) + 60.0
if int(k.stufe) < 2:
k.stufe = int(k.stufe) + 1
else:
k.mannschaft = mini(4, int(k.mannschaft) + 1)
func speichern() -> void:
var daten := {"elo": elo, "partien": partien, "siege": siege, "kapitaene": []}
for k in kapitaene:
daten.kapitaene.append({"name": k.name, "elo": k.elo, "stufe": k.stufe,
"mannschaft": k.mannschaft, "weg": k.weg, "geschlagen": k.geschlagen})
var f := FileAccess.open(datei, FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify(daten))
f.close()
func laden() -> void:
zuruecksetzen()
if not FileAccess.file_exists(datei):
return
var text := FileAccess.get_file_as_string(datei)
var daten = JSON.parse_string(text)
if not (daten is Dictionary):
return
elo = float(daten.get("elo", 1000.0))
partien = int(daten.get("partien", 0))
siege = int(daten.get("siege", 0))
for d in daten.get("kapitaene", []):
var k := kapitaen(String(d.get("name", "")))
if k.is_empty():
continue
k.elo = float(d.get("elo", k.elo))
k.stufe = int(d.get("stufe", k.stufe))
k.mannschaft = int(d.get("mannschaft", k.mannschaft))
k.weg = int(d.get("weg", 0))
k.geschlagen = int(d.get("geschlagen", 0))

View File

@@ -35,7 +35,13 @@ func _ready() -> void:
if not Net.rematch.is_empty():
var r: Dictionary = Net.rematch
Net.rematch = {}
Net.start_singleplayer(String(r.name), int(r.ai), int(r.stufe))
var kap := String(r.get("kampagne", ""))
if kap != "":
# Nochmal gegen denselben Kapitän: nur, wenn er noch da ist.
Kampagne.starten(kap)
else:
Kampagne.kampagnenpartie = false
Net.start_singleplayer(String(r.name), int(r.ai), int(r.stufe), 5, r.get("bots", []))
## Das Spiel startet im Vollbild; F11 schaltet um.

View File

@@ -14,6 +14,10 @@ var zurueck_btn: Button ## "Zurück in Raum XY" nach einem Abbruch
var solo_stufe: OptionButton
var lobby_stufe: OptionButton
# Kampagne
var kamp_kopf: Label
var karte: Control
# Mehrspieler
var name_edit: LineEdit
var code_edit: LineEdit
@@ -41,11 +45,16 @@ func _ready() -> void:
screens["join"] = _screen(root, _build_join())
screens["lobby"] = _screen(root, _build_lobby())
screens["regeln"] = _screen(root, _build_regeln())
screens["kampagne"] = _screen(root, _build_kampagne())
Net.lobby_changed.connect(_refresh)
Net.connection_failed.connect(_on_failed)
Net.server_closed.connect(_on_closed)
_show("main")
if Kampagne.zur_karte:
Kampagne.zur_karte = false
_show("kampagne")
else:
_show("main")
# ------------------------------------------------------------ Aufbau
@@ -63,6 +72,8 @@ func _show(which: String) -> void:
for key: String in screens:
screens[key].visible = key == which
_sagen("")
if which == "kampagne":
_karte_fuellen()
if which == "main" and zurueck_btn != null:
zurueck_btn.visible = not Net.letzter_raum.is_empty()
if zurueck_btn.visible:
@@ -93,6 +104,82 @@ func _text(t: String, size: int, col := Color.WHITE) -> Label:
return l
## Gezeichnete Karte im Knete-Look: helle See, sandige Inseln unter jedem
## Hafen, eine gestrichelte Route von leicht nach schwer.
class Seekarte extends Control:
func _draw() -> void:
var g := size
draw_rect(Rect2(Vector2.ZERO, g), Color(0.56, 0.74, 0.88))
var sortiert: Array = Kampagne.kapitaene.duplicate()
sortiert.sort_custom(func(a, b): return float(a.elo) < float(b.elo))
var letzte := Vector2(-1, -1)
for k in sortiert:
var p: Vector2 = Vector2(k.pos) * g
if letzte.x >= 0.0:
var n := 14
for i in n:
if i % 2 == 0:
draw_line(letzte.lerp(p, float(i) / n), letzte.lerp(p, float(i + 1) / n),
Color(1, 1, 1, 0.45), 2.0)
letzte = p
for k in Kampagne.kapitaene:
var p: Vector2 = Vector2(k.pos) * g
var da: bool = int(k.weg) == 0
draw_circle(p + Vector2(0, 6), 62.0, Color(0.0, 0.0, 0.0, 0.10))
draw_circle(p, 62.0, Color(0.92, 0.84, 0.68) if da else Color(0.75, 0.78, 0.82))
draw_circle(p + Vector2(-10, -30), 16.0, Color(0.35, 0.72, 0.44) if da else Color(0.6, 0.65, 0.66))
draw_circle(p + Vector2(14, -34), 11.0, Color(0.35, 0.72, 0.44) if da else Color(0.6, 0.65, 0.66))
## Die Seekarte: Kapitäne als Häfen. Wer auf See ist, steht ausgegraut da
## mit der Zahl der Partien bis zur Rückkehr.
func _build_kampagne() -> Control:
var box := _box(940)
box.add_child(_text("Seekarte", 26, Color(1.0, 0.82, 0.32)))
kamp_kopf = _text("", 15, Color(0.85, 0.87, 0.92))
box.add_child(kamp_kopf)
karte = Seekarte.new()
karte.custom_minimum_size = Vector2(920, 440)
box.add_child(karte)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
box.add_child(row)
_button("Neu anfangen", row, func() -> void:
Kampagne.zuruecksetzen()
Kampagne.speichern()
_karte_fuellen())
_button("Zurück", row, _show.bind("main"))
return box
func _karte_fuellen() -> void:
kamp_kopf.text = "Du: %s, Wertung %d · %d Partien, %d Siege" % [
Kampagne.mein_rang(), int(round(Kampagne.elo)), Kampagne.partien, Kampagne.siege]
for c in karte.get_children():
if c is Button:
karte.remove_child(c)
c.queue_free()
karte.queue_redraw()
var groesse := karte.custom_minimum_size
for k in Kampagne.kapitaene:
var b := Button.new()
var weg := int(k.weg)
if weg > 0:
b.text = "%s\nauf See · zurück in %d" % [k.name, weg]
b.disabled = true
else:
b.text = "%s\n%s · %d" % [k.name, ["leicht", "normal", "schwer"][int(k.stufe)], int(round(k.elo))]
b.pressed.connect(_on_kapitaen.bind(String(k.name)))
b.custom_minimum_size = Vector2(170, 52)
var p: Vector2 = k.pos
b.position = Vector2(p.x * groesse.x - 85, p.y * groesse.y + 4)
karte.add_child(b)
func _on_kapitaen(name: String) -> void:
Kampagne.starten(name)
func _on_wieder_rein() -> void:
_show("join")
_sagen("Verbinde mit Raum %s …" % Net.letzter_raum.get("code", ""))
@@ -184,6 +271,7 @@ func _build_main() -> Control:
# Nach einem Verbindungsabbruch: derselbe Name, derselbe Code, ein Klick.
zurueck_btn = _button("", box, _on_wieder_rein)
zurueck_btn.visible = false
_button("Kampagne", box, _show.bind("kampagne"))
_button("Einzelspieler", box, _show.bind("single"))
_button("Mehrspieler", box, _show.bind("multi"))
_button("Regeln", box, _show.bind("regeln"))
@@ -330,6 +418,7 @@ func _build_lobby() -> Control:
# ------------------------------------------------------------ Aktionen
func _on_solo_start() -> void:
Kampagne.kampagnenpartie = false
Net.start_singleplayer("Spieler", solo_bots.value, solo_stufe.selected)

View File

@@ -258,7 +258,9 @@ func _push_lobby(code: String, data: Dictionary, besitzer: int) -> void:
# ── Start ────────────────────────────────────────────────────────────────────
## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots.
func start_singleplayer(pname: String, ai_count: int, stufe := 1, dice := 5) -> void:
## `bots`: optionale Liste {name, stufe} - die Kampagne setzt so Kapitän und
## Mannschaft mit eigenen Stufen an den Tisch.
func start_singleplayer(pname: String, ai_count: int, stufe := 1, dice := 5, bots: Array = []) -> void:
player_name = pname
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
room_code = SOLO
@@ -268,7 +270,7 @@ func start_singleplayer(pname: String, ai_count: int, stufe := 1, dice := 5) ->
in_game = true
lobby_changed.emit()
game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count,
"stufe": stufe, "dice": dice})
"stufe": stufe, "dice": dice, "bots": bots})
## Nach einem Abbruch mit demselben Namen zurück in die laufende Partie.

View File

@@ -1,113 +1,38 @@
extends RefCounted
## 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.
##
## Materialien für den Low-Poly-Look: eine flache Farbe, matt, kein Muster,
## kein Relief. Was "Holz" von "Filz" unterscheidet, ist die Farbe und die
## Form - nicht eine Textur. Die Palette ist pastellig und satt, wie Knete.
## Kein class_name: den Klassencache baut nur der Editor, headless fehlt er.
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),
},
const FARBEN := {
"filz": Color(0.33, 0.70, 0.42),
"filz_dunkel": Color(0.26, 0.60, 0.36),
"holz": Color(0.86, 0.68, 0.46),
"holz_dunkel": Color(0.76, 0.42, 0.30),
"holz_rand": Color(0.60, 0.40, 0.26),
"messing": Color(0.96, 0.80, 0.36),
"knochen": Color(0.99, 0.96, 0.90),
"boden": Color(0.90, 0.84, 0.72),
"glas": Color(1.0, 0.92, 0.55),
}
static var _fertig: Dictionary = {} ## name -> StandardMaterial3D, einmal gebaut
static var _fertig: Dictionary = {}
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 _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.
## Ein Stoff, eine Instanz: gleiche Farbe teilt das Material.
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
m.albedo_color = FARBEN[name]
m.roughness = 0.85
m.metallic = 0.0
# Fast kein Glanzpunkt: Knete glänzt nicht, sie leuchtet vom Licht her.
m.metallic_specular = 0.15
_fertig[name] = m
return m
## Eigene Instanz, wenn ein Objekt Farbe oder Leuchten ändern muss.
static func eigene(name: String) -> StandardMaterial3D:
return mach(name).duplicate()
@@ -116,6 +41,10 @@ static func filz() -> StandardMaterial3D:
return mach("filz")
static func filz_dunkel() -> StandardMaterial3D:
return mach("filz_dunkel")
static func holz() -> StandardMaterial3D:
return mach("holz")
@@ -124,22 +53,36 @@ static func holz_dunkel() -> StandardMaterial3D:
return mach("holz_dunkel")
static func boden() -> StandardMaterial3D:
return mach("boden")
static func messing(emission := 0.0) -> StandardMaterial3D:
if emission <= 0.0:
return mach("messing")
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
m.emission_enabled = true
m.emission = Color(1.0, 0.80, 0.35)
m.emission_energy_multiplier = emission
return m
## Würfel: Farbe kommt aus den Vertexfarben (Körper hell, Augen dunkel).
static func knochen() -> StandardMaterial3D:
return mach("knochen")
if _fertig.has("knochen"):
return _fertig["knochen"]
var m := StandardMaterial3D.new()
m.vertex_color_use_as_albedo = true
m.roughness = 0.8
m.metallic_specular = 0.15
_fertig["knochen"] = m
return m
## Zonenrand: Holz, das leuchten kann, wenn der Platz dran ist.
## Zonenrand: dunkleres Holz als die Becher, damit sich beides trennt; kann
## leuchten, wenn der Platz dran ist.
static func rand() -> StandardMaterial3D:
var m := eigene("holz")
var m := eigene("holz_rand")
m.emission = Color(1.0, 0.78, 0.30)
m.emission_enabled = false
return m

View File

@@ -12,7 +12,7 @@ const Stoffe = preload("res://scripts/stoffe.gd") ## Materialien aus Regeln, o
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
const TILT_DEG := 45.0 ## Neigung der Kamera gegen die Senkrechte
const HUD_TOP := 0.13 ## Bildanteil oben für die Gebotsanzeige
const HUD_BOTTOM := 0.17 ## Bildanteil unten für die Ansage-Leiste
@@ -33,7 +33,7 @@ var _bid_anchor: Array[Vector3] = []
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_mesh: ArrayMesh
static var _muenz_mat: StandardMaterial3D
var dice: Array = [] ## seat -> Array[Die]
@@ -62,96 +62,80 @@ func build(count: int, local_index: int) -> void:
## 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)
laterne.position = Vector3(-(table_r + 0.45), 0.0, table_r * 0.55) # steht auf der Platte
add_child(laterne)
func _build_room() -> void:
# Low-Poly-Look: heller Himmel, eine Sonne mit weichen Schatten, viel
# Umgebungslicht. Kein Nebel, kein Glow - die Formen sollen klar stehen.
var env := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0.04, 0.05, 0.07)
e.background_color = Color(0.56, 0.74, 0.88)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.35, 0.38, 0.45)
e.ambient_light_energy = 1.1
e.ambient_light_color = Color(0.70, 0.78, 0.92)
e.ambient_light_energy = 0.75
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
e.tonemap_exposure = 0.9
env.environment = e
add_child(env)
var lamp := SpotLight3D.new()
lamp.position = Vector3(0, TABLE_H + table_r * 1.45, 0)
lamp.rotation_degrees = Vector3(-90, 0, 0)
lamp.light_energy = 2.5
lamp.light_color = Color(1.0, 0.94, 0.82)
lamp.spot_range = table_r * 4.2
lamp.spot_angle = 60.0
lamp.spot_attenuation = 0.6
lamp.shadow_enabled = true
add_child(lamp)
var sonne := DirectionalLight3D.new()
sonne.light_color = Color(1.0, 0.96, 0.90)
sonne.light_energy = 1.1
sonne.shadow_enabled = true
sonne.light_angular_distance = 3.0 # weiche Schattenkanten
sonne.directional_shadow_mode = DirectionalLight3D.SHADOW_ORTHOGONAL
sonne.directional_shadow_max_distance = 30.0
sonne.rotation_degrees = Vector3(-52.0, 35.0, 0.0)
add_child(sonne)
# Kein sichtbarer Lampenschirm: die Kamera schaut fast senkrecht von oben,
# alles über der Tischmitte hinge ihr vor der Linse.
# Sockelplatte wie ein Diorama: eine runde Scheibe mit weicher Kante,
# darunter nur Himmel.
var platte := MeshInstance3D.new()
# Klein genug, dass an den Rändern Himmel bleibt - sonst ist alles Platte.
platte.mesh = Formen.scheibe(table_r * 1.55, 0.18, 0.08)
platte.position = Vector3(0, -0.18, 0)
platte.material_override = Stoffe.boden()
add_child(platte)
var boden := StaticBody3D.new()
var bc := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = Vector3(40, 0.2, 40)
bc.shape = bs
bc.position = Vector3(0, -0.1, 0)
boden.add_child(bc)
add_child(boden)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-55, 35, 0)
fill.light_energy = 0.25
add_child(fill)
# Boden
var floor_body := StaticBody3D.new()
var floor_col := CollisionShape3D.new()
var fs := BoxShape3D.new()
fs.size = Vector3(24, 0.4, 24)
floor_col.shape = fs
floor_col.position = Vector3(0, -0.2, 0)
floor_body.add_child(floor_col)
var floor_mi := MeshInstance3D.new()
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)
# Tischplatte
var table := StaticBody3D.new()
var tcol := CollisionShape3D.new()
# Tischplatte: Holz mit runder Kante, darauf die Filzeinlage.
var tisch := MeshInstance3D.new()
tisch.mesh = Formen.scheibe(table_r, 0.10, 0.045)
tisch.position = Vector3(0, TABLE_H - 0.10, 0)
tisch.material_override = Stoffe.holz()
add_child(tisch)
var einlage := MeshInstance3D.new()
einlage.mesh = Formen.scheibe(table_r - 0.16, 0.025, 0.012)
einlage.position = Vector3(0, TABLE_H - 0.012, 0)
einlage.material_override = Stoffe.filz()
add_child(einlage)
var tcol := StaticBody3D.new()
var tc := CollisionShape3D.new()
var tcs := CylinderShape3D.new()
tcs.radius = table_r
tcs.height = 0.12
tcol.shape = tcs
tcol.position = Vector3(0, TABLE_H - 0.06, 0)
table.add_child(tcol)
var tmi := MeshInstance3D.new()
var tcm := CylinderMesh.new()
tcm.top_radius = table_r
tcm.bottom_radius = table_r
tcm.height = 0.12
tmi.mesh = tcm
tmi.position = Vector3(0, TABLE_H - 0.06, 0)
tmi.material_override = Stoffe.filz()
table.add_child(tmi)
add_child(table)
tc.shape = tcs
# Oberkante auf Höhe des Zonenfilzes, damit Würfel darauf liegen, nicht darin.
tc.position = Vector3(0, TABLE_H - 0.048, 0)
tcol.add_child(tc)
add_child(tcol)
# Tischbein
var leg := MeshInstance3D.new()
var lcm := CylinderMesh.new()
lcm.top_radius = table_r * 0.09
lcm.bottom_radius = table_r * 0.22
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()
add_child(leg)
# Ein dicker Mittelfuss statt vier Beinen - aus Kamerahöhe sieht man ohnehin
# nur den Ansatz, und rund passt zur Knete.
var fuss := MeshInstance3D.new()
fuss.mesh = Formen.rund_zylinder(0.55, 0.35, TABLE_H - 0.10, 0.06)
fuss.material_override = Stoffe.holz()
add_child(fuss)
func _build_seat(i: int) -> void:
@@ -180,24 +164,14 @@ func _build_seat(i: int) -> void:
# Filzfläche
var felt := MeshInstance3D.new()
var fm := CylinderMesh.new()
fm.top_radius = ZONE_RAD
fm.bottom_radius = ZONE_RAD
fm.height = 0.012
fm.radial_segments = 32
felt.mesh = fm
felt.position = Vector3(0, 0.006, 0)
felt.material_override = Stoffe.filz()
felt.mesh = Formen.scheibe(ZONE_RAD + 0.02, 0.012, 0.006)
felt.position = Vector3(0, 0.0, 0)
felt.material_override = Stoffe.filz_dunkel()
zone.add_child(felt)
# Sichtbarer Rand
var rim := MeshInstance3D.new()
var tm := TorusMesh.new()
tm.inner_radius = ZONE_RAD
tm.outer_radius = ZONE_RAD + 0.07
tm.rings = 32
tm.ring_segments = 8
rim.mesh = tm
rim.mesh = Formen.ring(ZONE_RAD + 0.045, 0.035)
rim.position = Vector3(0, 0.02, 0)
rim.material_override = Stoffe.rand()
zone.add_child(rim)
@@ -287,6 +261,10 @@ func _frame_camera() -> void:
var band := 1.0 - HUD_TOP - HUD_BOTTOM
var half_v := tan(fov_v * 0.5)
var dist := (table_r * cos(tilt)) / (band * half_v)
# Geneigt wächst die nahe Tischhälfte durch die Perspektive; je schräger,
# desto mehr Abstand braucht es, damit die eigene Zone nicht unter der
# Ansageleiste verschwindet.
dist *= 1.0 + 0.38 * sin(tilt)
# Waagerecht ist normalerweise reichlich Platz, in schmalen Fenstern nicht.
dist = maxf(dist, table_r / (0.98 * tan(fov_v * 0.5) * aspect))
# Das freie Band liegt nicht mittig, der Tisch rückt entsprechend nach oben.
@@ -527,7 +505,7 @@ func set_topf(liste: Array, ziel := -1) -> void:
var ang := r.randf() * TAU
var rad := r.randf_range(0.30, 0.55)
var wappen: bool = m.get("stark", false)
var lage := Vector3(cos(ang) * rad, TABLE_H + 0.007, sin(ang) * rad)
var lage := Vector3(cos(ang) * rad, TABLE_H + 0.013, sin(ang) * rad) # auf der Einlage
var mi := MeshInstance3D.new()
mi.mesh = _muenz_form()
mi.material_override = _muenz_stoff(wappen)
@@ -565,15 +543,11 @@ static var _praeg_mesh: TorusMesh
static var _praeg_mat: StandardMaterial3D
static func _muenz_form() -> CylinderMesh:
static func _muenz_form() -> ArrayMesh:
if _muenz_mesh == null:
_muenz_mesh = CylinderMesh.new()
# Aus Kamerahöhe muss eine Münze fast so gross sein wie ein Würfel,
# sonst ist sie ein Punkt.
_muenz_mesh.top_radius = 0.11
_muenz_mesh.bottom_radius = 0.11
_muenz_mesh.height = 0.014
_muenz_mesh.radial_segments = 24
_muenz_mesh = Formen.scheibe(0.11, 0.016, 0.006)
return _muenz_mesh