From a0597110bda9d1bb955ca1a04b96217b34b05648 Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 16 Sep 2026 17:55:28 +0200 Subject: [PATCH] update --- README.md | 34 +++++++-- assets.md | 24 +++--- project.godot | 1 + scripts/aufdecken.gd | 1 + scripts/formen.gd | 138 +++++++++++++++++++++------------- scripts/game.gd | 25 ++++++- scripts/hud.gd | 28 ++++++- scripts/kampagne.gd | 173 +++++++++++++++++++++++++++++++++++++++++++ scripts/main.gd | 8 +- scripts/menu.gd | 91 ++++++++++++++++++++++- scripts/net.gd | 6 +- scripts/stoffe.gd | 147 +++++++++++------------------------- scripts/table.gd | 164 +++++++++++++++++----------------------- tests/kampagne.gd | 78 +++++++++++++++++++ tests/kampagne.tscn | 6 ++ tests/run.sh | 3 +- tests/shots.gd | 3 + 17 files changed, 652 insertions(+), 278 deletions(-) create mode 100644 scripts/kampagne.gd create mode 100644 tests/kampagne.gd create mode 100644 tests/kampagne.tscn diff --git a/README.md b/README.md index c41bfb8..2bd8656 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/assets.md b/assets.md index d1d14d2..46b348a 100644 --- a/assets.md +++ b/assets.md @@ -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 diff --git a/project.godot b/project.godot index 68a5db2..f56b73e 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/scripts/aufdecken.gd b/scripts/aufdecken.gd index 7523c0d..361f821 100644 --- a/scripts/aufdecken.gd +++ b/scripts/aufdecken.gd @@ -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) diff --git a/scripts/formen.gd b/scripts/formen.gd index 924a8cf..5c58397 100644 --- a/scripts/formen.gd +++ b/scripts/formen.gd @@ -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 diff --git a/scripts/game.gd b/scripts/game.gd index 4bd83ae..4622c51 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -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() diff --git a/scripts/hud.gd b/scripts/hud.gd index 828ce7d..780215b 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -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 diff --git a/scripts/kampagne.gd b/scripts/kampagne.gd new file mode 100644 index 0000000..385dfc3 --- /dev/null +++ b/scripts/kampagne.gd @@ -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)) diff --git a/scripts/main.gd b/scripts/main.gd index 3fe4da2..a536186 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -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. diff --git a/scripts/menu.gd b/scripts/menu.gd index 79b24bf..6070f9b 100644 --- a/scripts/menu.gd +++ b/scripts/menu.gd @@ -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) diff --git a/scripts/net.gd b/scripts/net.gd index b815316..eeada10 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -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. diff --git a/scripts/stoffe.gd b/scripts/stoffe.gd index c57307e..cc594c3 100644 --- a/scripts/stoffe.gd +++ b/scripts/stoffe.gd @@ -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 diff --git a/scripts/table.gd b/scripts/table.gd index 0d35996..2bc3057 100644 --- a/scripts/table.gd +++ b/scripts/table.gd @@ -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 diff --git a/tests/kampagne.gd b/tests/kampagne.gd new file mode 100644 index 0000000..3da42eb --- /dev/null +++ b/tests/kampagne.gd @@ -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() diff --git a/tests/kampagne.tscn b/tests/kampagne.tscn new file mode 100644 index 0000000..5f14984 --- /dev/null +++ b/tests/kampagne.tscn @@ -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") diff --git a/tests/run.sh b/tests/run.sh index 33d94b6..8ca316e 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -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" diff --git a/tests/shots.gd b/tests/shots.gd index 4bab5d8..ab4f827 100644 --- a/tests/shots.gd +++ b/tests/shots.gd @@ -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()