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

@@ -172,6 +172,22 @@ Ruhe.
Wer keine Würfel mehr hat, scheidet aus und schaut zu. Der Letzte gewinnt.
## Kampagne
Im Menü unter **Kampagne** liegt eine Seekarte mit sechs Kapitänen, von
*Roter Jack* (leicht, 900) bis *Admiral Krähe* (schwer, 1360). Ein Klick auf
einen Hafen startet die Partie: der Kapitän spielt auf seiner Stufe, seine
Mannschaft eine Stufe darunter.
* Du hast eine **Wertung** (Elo, Start 1000) und daraus einen **Rang**:
Schiffsjunge, Matrose, Maat, Steuermann, Kapitän, Admiral.
* **Geschlagen** ist ein Kapitän, wenn er vor dir ausscheidet - oder du
gewinnst. Er verschwindet dann für zwei Partien auf See und **kommt
stärker zurück**: mehr Wertung, eine Stufe höher, ab „schwer" mehr
Mannschaft.
* Verlierst du, bleibt er, und deine Wertung sinkt.
* Der Stand liegt in `user://kampagne.json`. „Neu anfangen" löscht ihn.
## Münzen
Münzen ersetzen die frühere Booster-Phase. Die kostete jede Runde eine eigene
@@ -248,12 +264,13 @@ 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.
* 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`.
* Kein Bild, kein Mesh aus einer Datei. Der Look ist **Low-Poly wie Knete**:
flache, pastellige Farben, runde Kanten, weiche Schatten, heller Himmel.
**Stoffe** (`stoffe.gd`) sind nur Farbe und Rauheit - was Holz von Filz
trennt, ist die Form. **Formen** (`formen.gd`) entstehen aus Profilen, die
um die Achse gedreht werden; Fasen sind Viertelkreise im Profil. Becher =
Kegelstumpf mit runden Kanten + Messingrand + Band. 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.
@@ -286,8 +303,9 @@ 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` | Materialien aus Regeln, Texturen zur Laufzeit - keine Dateien |
| `scripts/formen.gd` | Becher, Fass, Laterne aus Primitiven nach Bauregeln |
| `scripts/kampagne.gd` | Autoload: Seekarte, Kapitäne, Wertung, Speicherstand |
| `scripts/stoffe.gd` | Flache Farben der Knete-Palette, ein Material je Stoff |
| `scripts/formen.gd` | Gedrehte Profile mit Fasen: Scheiben, Becher, Laterne |
| `scripts/icons.gd` | Gezeichnete Münz- und Würfelbilder fürs HUD |
| `scripts/hud_panels.gd` | Vorrat, Topf, Erklärung, Verlauf, Protokoll |
| `tests/` | Headless-Tests, `make test` (~30 s); `make sim` misst die Bots - siehe `tests/README.md` |

View File

@@ -6,15 +6,21 @@ 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).
Der Look ist **Low-Poly wie Knete** (Vorbild: pastelliges Haus-Diorama):
flache Farben, runde Kanten, weiche Schatten, heller Himmel. Der frühere
Plan einer dunklen Taverne mit Maserung und Relief ist verworfen - er wirkte
aus Kamerahöhe schäbig.
- `stoffe.gd`: eine Farbe je Stoff, matt, kaum Glanz. Keine Texturen.
- `formen.gd`: Profile (Radius, Höhe) um die Achse gedreht, Fasen als
Viertelkreise. Scheibe, runder Zylinder, Ring, Becher, Laterne.
- Tisch: Holzscheibe mit Filzeinlage auf einem Mittelfuss, auf einer runden
Sockelplatte wie ein Diorama. Zonen: Filzscheiben mit Holzring.
- Licht: eine Sonne mit weichen Schatten, viel Umgebungslicht, kein Nebel,
kein Glow. Belichtung 0,9, sonst brennt die Platte aus.
- Gelernt: Der Look lebt von den Fasen und dem Licht, nicht vom Material.
Ohne Fasen sieht flache Farbe nach Prototyp aus. Und aus Kamerahöhe muss
am Rand Himmel bleiben, sonst ist alles Platte.
## Grundidee

View File

@@ -12,6 +12,7 @@ config/features=PackedStringArray("4.7", "Mobile")
BoosterDB="*res://scripts/boosters.gd"
Net="*res://scripts/net.gd"
Klang="*res://scripts/klang.gd"
Kampagne="*res://scripts/kampagne.gd"
[display]

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

78
tests/kampagne.gd Normal file
View File

@@ -0,0 +1,78 @@
extends "res://tests/basis.gd"
## Kampagne: Wertung, Rang, Verschwinden und Rückkehr der Kapitäne,
## Speichern und Laden, und eine echte Partie gegen einen Kapitän im
## Simulationsmodus.
var game: Node
func _ready() -> void:
Kampagne.datei = "user://kampagne_test.json"
Kampagne.zuruecksetzen()
add_child(preload("res://scenes/main.tscn").instantiate())
await get_tree().process_frame
_lauf()
func _seats(alive: Array, namen: Array) -> Array:
var out: Array = []
for i in namen.size():
out.append({"name": namen[i], "alive": alive[i]})
return out
func _lauf() -> void:
# --- Wertung und Rang
_pruef("Start: 1000, Matrose", int(Kampagne.elo) == 1000 and Kampagne.mein_rang() == "Matrose")
_pruef("Rang steigt mit der Wertung", Kampagne.rang(1300) == "Kapitän" and Kampagne.rang(800) == "Schiffsjunge")
# --- Sieg: Kapitän verschwindet, Wertung steigt
Kampagne.aktiv = "Roter Jack"
Kampagne.ergebnis(_seats([true, false], ["Spieler", "Roter Jack"]), [1])
var jack: Dictionary = Kampagne.kapitaen("Roter Jack")
_pruef("Sieg: Wertung steigt", Kampagne.elo > 1000.0)
_pruef("Sieg: Kapitän geht auf See", int(jack.weg) == Kampagne.AUF_SEE and int(jack.geschlagen) == 1)
_pruef("Ergebnistext steht", Kampagne.letztes.begins_with("Sieg gegen Roter Jack"))
Kampagne.starten("Roter Jack")
_pruef("Kein Start gegen einen auf See", Kampagne.aktiv == "" and _find(get_tree().root, "Game") == null)
var vorher := Kampagne.elo
# --- Niederlage: Spieler fliegt vor dem Kapitän
Kampagne.aktiv = "Salz-Marie"
Kampagne.ergebnis(_seats([false, false, true], ["Spieler", "Salz-Marie", "Maat 1"]), [0, 1])
_pruef("Niederlage: Wertung sinkt", Kampagne.elo < vorher)
_pruef("Niederlage: Kapitänin bleibt", int(Kampagne.kapitaen("Salz-Marie").weg) == 0)
_pruef("Jack rückt näher", int(Kampagne.kapitaen("Roter Jack").weg) == Kampagne.AUF_SEE - 1)
# --- Sieg, weil der Kapitän vor dem Spieler ging, obwohl ein Maat gewann
Kampagne.aktiv = "Salz-Marie"
Kampagne.ergebnis(_seats([false, false, true], ["Spieler", "Salz-Marie", "Maat 1"]), [1, 0])
_pruef("Kapitän vor mir raus zählt als Sieg", int(Kampagne.kapitaen("Salz-Marie").weg) == Kampagne.AUF_SEE)
jack = Kampagne.kapitaen("Roter Jack")
_pruef("Jack ist zurück, stärker", int(jack.weg) == 0 and int(jack.stufe) == 1 and float(jack.elo) > 900.0)
# --- Speichern und Laden
var elo_alt := Kampagne.elo
Kampagne.speichern()
Kampagne.elo = 0.0
Kampagne.laden()
_pruef("Laden stellt Wertung und Kapitäne wieder her",
absf(Kampagne.elo - elo_alt) < 0.01 and int(Kampagne.kapitaen("Salz-Marie").weg) == Kampagne.AUF_SEE
and int(Kampagne.kapitaen("Roter Jack").stufe) == 1)
# --- Echte Partie gegen Jack im Simulationsmodus
var partien_vorher := Kampagne.partien
Kampagne.starten("Roter Jack")
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0)
_pruef("Partie gegen den Kapitän steht", game != null and game.seats.size() == 2
and String(game.seats[1].name) == "Roter Jack" and int(game.seats[1].stufe) == 1)
game.tempo = 0.0
game.simulation = true
game.seats[game.local_seat].ai = true
await _warte(func(): return game.phase == "over", 120.0)
_pruef("Partie endet", game.phase == "over")
_pruef("Ergebnis verbucht", Kampagne.partien == partien_vorher + 1 and Kampagne.aktiv == "" and Kampagne.letztes != "")
_pruef("Endfeld zeigt Wertung", game.hud.over_slot.visible and Kampagne.letztes in game.hud.over_title.text
and game.hud.over_karte.visible)
DirAccess.remove_absolute(ProjectSettings.globalize_path(Kampagne.datei))
_fertig()

6
tests/kampagne.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/kampagne.gd" id="1"]
[node name="Test" type="Node"]
script = ExtResource("1")

View File

@@ -22,6 +22,7 @@ starte() { # name, timeout, argumente... -> Hintergrund
starte logik 90
starte genau 90
starte kampagne 90
starte nochmal 60
starte bots 90 2
rm -f "$TMP/code.txt" "$TMP/code2.txt"
@@ -52,7 +53,7 @@ done
wait 2>/dev/null
ergebnis=0
for name in logik genau nochmal bots mp_start:host mp_start:join mp_start:host2 mp_start:join2 mp_start:srv; do
for name in logik genau kampagne nochmal bots mp_start:host mp_start:join mp_start:host2 mp_start:join2 mp_start:srv; do
log="$TMP/$name.log"
echo "=== $name"
grep -E "^(OK |FEHL|SERVER:| |TEST:)" "$log"

View File

@@ -19,6 +19,9 @@ func _ready() -> void:
main.menu._show("main")
await get_tree().create_timer(0.2).timeout
await _bild("menue_start")
main.menu._show("kampagne")
await get_tree().create_timer(0.2).timeout
await _bild("menue_seekarte")
Net.start_singleplayer("Tester", 3, 1)
_lauf()