diff --git a/assets-source/luneraria/build_grotto.py b/assets-source/luneraria/build_grotto.py new file mode 100644 index 0000000..121768e --- /dev/null +++ b/assets-source/luneraria/build_grotto.py @@ -0,0 +1,158 @@ +"""Boss grotto for Champions of Luneraria: a rock island in the river with a crescent of +cliffs, a cave mouth and violet crystals. Symmetric across the map diagonal, so both +entrances (towards Blau and towards Rot) are equal. Stone uses ENV_* material names. + +Run: ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_grotto.py +Local Godot frame: +x/+z are the map axes; the map diagonal (x = z) is the mirror line. +""" +from pathlib import Path +import bpy, bmesh, json, hashlib, struct +from math import sin, cos, pi, sqrt +from mathutils import Vector, noise + +P = Path(__file__).resolve().parents[2] +SRC = Path(__file__).resolve().parent +OUT = P / 'assets/luneraria' +OUT.mkdir(parents=True, exist_ok=True) +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.context.preferences.filepaths.save_version = 0 +parts = [] + + +def G(x, y, z): + return Vector((x, -z, y)) + + +def material(name, color, rough=.9, metal=0.0, emission=0.0): + m = bpy.data.materials.new(name) + m.diffuse_color = (*color, 1) + m.use_nodes = True + bs = m.node_tree.nodes.get('Principled BSDF') + bs.inputs['Base Color'].default_value = (*color, 1) + bs.inputs['Roughness'].default_value = rough + bs.inputs['Metallic'].default_value = metal + if emission: + bs.inputs['Emission Color'].default_value = (*color, 1) + bs.inputs['Emission Strength'].default_value = emission + return m + + +M = { + 'cliff': material('ENV_cliff', (.133, .153, .138)), + 'soil': material('ENV_soil', (.10, .15, .065), .97), + 'limestone': material('ENV_limestone', (.283, .246, .178)), + 'dark': material('GR_Shadow', (.02, .02, .03), 1.0), + 'crystal': material('GR_Crystal', (.62, .38, 1.0), .15, 0, 3.0), +} + + +def obj(name, vs, fs, kind, smooth=False): + me = bpy.data.meshes.new(name) + me.from_pydata([tuple(v) for v in vs], [], fs) + me.update() + me.materials.append(M[kind]) + for p in me.polygons: + p.use_smooth = smooth + ob = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(ob) + parts.append(ob) + return ob + + +def island(name, radius, top, bottom, seed, kind='soil'): + sides, levels = 64, 6 + vs, fs = [], [] + for li in range(levels): + y = top - (top - bottom) * li / (levels - 1) + for k in range(sides): + a = 2 * pi * k / sides + # Radius depends on |angle - 45 deg| so the island is symmetric across x = z. + m = abs(((a - pi / 4) + pi) % (2 * pi) - pi) + r = radius * (1 + .08 * noise.noise(Vector((cos(m) * 2, li * .5, seed)))) * (1 - .06 * li / levels) + vs.append(G(cos(a) * r, y, sin(a) * r)) + for li in range(levels - 1): + for k in range(sides): + a, b = li * sides + k, li * sides + (k + 1) % sides + fs.append((a, a + sides, b + sides, b)) + fs += [tuple(range(sides)), tuple(reversed(range((levels - 1) * sides, levels * sides)))] + return obj(name, vs, fs, kind) + + +def boulder(name, center, size, seed, kind='cliff'): + bm = bmesh.new() + bmesh.ops.create_icosphere(bm, subdivisions=2, radius=1.0) + c = G(*center) + for v in bm.verts: + d = v.co.normalized() + r = 1 + .32 * noise.noise(d * 1.8 + Vector((seed, seed * .5, seed * .3))) + gx, gy, gz = d.x * r * size[0], d.z * r * size[1], -d.y * r * size[2] + if gy < -.25 * size[1]: + gy = -.25 * size[1] + v.co = c + Vector((gx, -gz, gy)) + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + bm.free() + me.materials.append(M[kind]) + ob = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(ob) + parts.append(ob) + return ob + + +def gem(name, center, height, radius, tilt, kind='crystal'): + cx, cy, cz = center + tip = G(cx + tilt[0], cy + height, cz + tilt[1]) + base = [G(cx + cos(2 * pi * k / 6) * radius, cy + height * .25, cz + sin(2 * pi * k / 6) * radius) for k in range(6)] + root = G(cx, cy - .1, cz) + vs = [tip] + base + [root] + fs = [(0, 1 + (k + 1) % 6, 1 + k) for k in range(6)] + [(7, 1 + k, 1 + (k + 1) % 6) for k in range(6)] + return obj(name, vs, fs, kind) + + +def mirror(p): + return (p[2], p[1], p[0]) + + +island('Island', 7.2, .35, -1.2, 3) +# Crescent of cliffs on the far side from Mid (towards -x-z), open towards both entrances. +ring = [] +for k in range(9): + a = pi + pi / 4 + (k - 4) * .30 + ring.append((cos(a) * 5.6, 0.0, sin(a) * 5.6, 1.5 + (1.2 if abs(k - 4) <= 1 else .5 * (4 - abs(k - 4)) / 4))) +for i, (x, y, z, h) in enumerate(ring): + boulder('Cliff_%d' % i, (x, y + h * .45, z), (1.6, h, 1.4), 10 + min(i, 8 - i)) +# Cave mouth: dark recess under an arch in the middle of the crescent. +a = pi + pi / 4 +boulder('Cave_Shadow', (cos(a) * 4.6, .8, sin(a) * 4.6), (1.4, 1.1, .7), 31, 'dark') +boulder('Arch_Top', (cos(a) * 4.8, 3.1, sin(a) * 4.8), (2.2, .8, 1.3), 32) +# Violet crystals, mirrored pairs so neither entrance looks different. +for i, (x, z, h, r) in enumerate([(-2.2, -3.6, 1.8, .32), (-3.9, -1.2, 1.4, .28), (-1.0, -4.3, 1.1, .22), (-3.2, -3.2, 2.3, .4)]): + for p in {(x, 0.3, z), mirror((x, 0.3, z))}: + gem('Crystal_%d_%d' % (i, int(p[0] * 10)), p, h, r, (.12 * (1 if p[0] < p[2] else -1), .08)) +# Entrance stones: symmetric pair of limestone steps facing Blau (+x) and Rot (+z)... see manifest. +for p in [(4.6, .18, 1.2), mirror((4.6, .18, 1.2))]: + boulder('Step_%d' % int(p[0] * 10), p, (1.1, .25, .8), 40, 'limestone') + +bpy.ops.object.select_all(action='DESELECT') +for ob in parts: + ob.select_set(True) +bpy.context.view_layer.objects.active = parts[0] +bpy.ops.object.join() +grotto = bpy.context.object +grotto.name = 'BossGrotto' +bm = bmesh.new() +bm.from_mesh(grotto.data) +bmesh.ops.triangulate(bm, faces=list(bm.faces)) +bm.to_mesh(grotto.data) +bm.free() +bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'grotto.blend')) +bpy.ops.export_scene.gltf(filepath=str(OUT / 'grotto.glb'), export_format='GLB', use_selection=True, + export_materials='EXPORT', export_yup=True, export_animations=False) +raw = (OUT / 'grotto.glb').read_bytes() +doc = json.loads(raw[20:20 + struct.unpack_from(' + + + + + + + + + + + + + + + + diff --git a/assets/ui/emblems/luneraria.svg.import b/assets/ui/emblems/luneraria.svg.import new file mode 100644 index 0000000..5932c38 --- /dev/null +++ b/assets/ui/emblems/luneraria.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dq03newjr6neo" +path="res://.godot/imported/luneraria.svg-12b34911b283c50c0fc77d4b9fb43e87.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/emblems/luneraria.svg" +dest_files=["res://.godot/imported/luneraria.svg-12b34911b283c50c0fc77d4b9fb43e87.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/docs/luneraria/ANLEITUNG.md b/docs/luneraria/ANLEITUNG.md new file mode 100644 index 0000000..ff0bac8 --- /dev/null +++ b/docs/luneraria/ANLEITUNG.md @@ -0,0 +1,65 @@ +# Installation, Start, Steuerung und Export + +## Start aus dem Quellprojekt (Fedora 44) + +```bash +cd saumwacht-godot +godot --path . # Champions of Luneraria (Hauptszene scenes/luneraria.tscn) +godot --path . -- --legacy # archivierter Saumwacht-Modus (scenes/main.tscn) +``` + +Voraussetzung ist Godot 4.7.2. Blender wird nur zum Neuerzeugen von Assets gebraucht, nicht zum Spielen. + +## Eine Partie + +1. **Neue Partie** → Bund (Glutpakt oder Wurzelbund) und deine Lane (Top, Mid, Bottom) wählen → **Partie beginnen**. Du spielst in Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot. +2. **Planung:** Truppen kaufen (Knöpfe oder 1–5), mit T/M/B einer Lane zuweisen, mit ↑ aufwerten, mit × zurückgeben. Käufe aus derselben Planung werden voll erstattet, ältere mit 75 %. **Startaufstellung** kauft Front, Nahkampf und Fernkampf. **Bereit** (Leertaste) übernimmt alle Befehle verbindlich. +3. **Kampf:** Er läuft automatisch. Deine Truppen rücken mit der eigenen Monsterwelle über ihre Lane vor. Leertaste oder P pausiert, Tempo 1×/2×/4×. +4. **Abrechnung:** Getötete Monster und Gebäude bringen Gold, dazu kommt das Rundeneinkommen. Deine Truppen kehren geheilt zurück. Türme und Kerne behalten ihren Schaden. +5. Die Partie endet erst, wenn ein Ancient fällt. Es gibt kein Rundenlimit. + +## Steuerung + +| Eingabe | Wirkung | +| --- | --- | +| 1–5 | Truppe kaufen (Planung) | +| Leertaste | Bereit / im Kampf Pause | +| P | Pause | +| WASD, Pfeiltasten, mittlere Maustaste ziehen | Kamera verschieben | +| Mausrad | Zoom | +| Tab / Lane-Knöpfe links | Kamera auf die nächste Lane | +| O | Kartenübersicht | +| Minimap klicken/ziehen | Kamera springt dorthin | +| F1 | Optionen und Steuerung | +| M / F11 | Ton / Vollbild | + +## Prüfungen + +```bash +godot --headless --path . res://tests/luneraria/test_lune_core.tscn # Regeln (M1: 91) +godot --path . --resolution 1920x1080 res://tools/luneraria/verify_m1.tscn # echte Bedienung, volle Partie +godot --headless --path . res://tools/luneraria/sim_match.tscn -- --seeds=1,2,3 --limit=80 +godot --path . --resolution 1920x1080 res://tools/luneraria/lune_benchmark.tscn +godot --path . --resolution 1920x1080 res://tools/luneraria/lune_review.tscn # Aufnahmen +# Legacy (Saumwacht) unverändert: +godot --headless --path . res://tests/test_runner.tscn +godot --headless --path . -- --smoke-test +godot --headless --path . -- --faction-test +godot --headless --path . -- --expedition-test +``` + +## Assets neu erzeugen + +```bash +ALSOFT_DRIVERS=null blender -b --python-exit-code 1 --python assets-source/luneraria/build_tower.py +python3 tools/luneraria/build_asset_matrix.py +godot --headless --editor --import --path . +``` + +## Export + +Die Pakete für Champions of Luneraria kommen mit M4. Bis dahin baut `tools/build_demo.py` weiterhin die Saumwacht-Demo 0.15.0. + +## Benutzereinstellungen + +Der neue Projektname legt einen neuen Benutzerordner an (`~/.local/share/godot/app_userdata/Champions of Luneraria`). Beim ersten Start übernimmt `Preferences` einmalig die `preferences.cfg` aus „Saumwacht · Glutpakt“. Die alte Datei bleibt unverändert; fehlt sie, gelten die Standardwerte. diff --git a/docs/luneraria/BALANCE.md b/docs/luneraria/BALANCE.md new file mode 100644 index 0000000..2508685 --- /dev/null +++ b/docs/luneraria/BALANCE.md @@ -0,0 +1,28 @@ +# Balancekonfiguration und Auswertung + +Alle Werte stehen in `scripts/luneraria/core/lune_balance.gd`. Die aktive Standardinstanz ist `data/luneraria/balance_default.tres`, die derzeit alle Klassenwerte übernimmt. + +## Startwerte (M1) und Begründung + +| Wert | Stand | Begründung | +| --- | ---: | --- | +| Startgold | 220 | Wie im Saumwacht-Einstieg; reicht für die Startaufstellung (180/185) plus Rest. | +| Grundeinkommen | 70 + 4 je Runde, Obergrenze +80 | Etwa eine Truppe pro Runde; der Zuwachs hält mit den Wellen mit, ohne endlos zu steigen. | +| Versorgungsgrenze | 12 Truppen je Spieler | Begrenzt aktive Einheiten; später investiert man in Aufwertungen statt in Masse. | +| Verkauf | 75 % (in derselben Planung 100 %) | Aus Saumwacht übernommen; die Rücknahme im selben Planungsschritt ist ein Bedienkomfort. | +| Wellen | 3 Monster je Lane und Team, +1 alle 3 Runden, max. 7 | Pro Runde höchstens 42 Monster auf der Karte. | +| Wellenstärke | HP ×(1 + 0,11·r + 0,0015·r²), Schaden ×(1 + 0,07·r + 0,0015·r²) | Stetig und quadratisch leicht zunehmend, ohne Obergrenze; bei r = 10⁶ endlich (Test). | +| Monsterkopfgeld | Katalogwert ×2 ×(1 + 0,04·r) | Monster sind die Hauptgoldquelle; wächst langsamer als die Stärke. | +| Türme | außen 6.000 / innen 7.500 / Basis 9.000 HP, 90 / 120 Schaden, Reichweite 8,6 m | Eine Welle allein kommt nicht durch. Die Reichweite liegt über der größten Einheitenreichweite (6,5 m + Gebäuderadius), sonst wären Türme ungestraft auszuschießen. | +| Ancient | 12.000 HP | Braucht eine voll durchbrochene Basis und mehrere Runden Druck. | +| Gebäudekopfgeld | 150 / 210 / 270 | Belohnt Durchbrüche gleichmäßig im Team. | +| Kampffenster | 180 s | In 91 Bot-Kampfphasen (8 Seeds) dauerte die längste 130 s, im Mittel 68 s; keine erreichte das Fenster. Es greift nur bei festgefahrenen Kämpfen. | +| Marschtempo | Katalogtempo ×1,6 | Eine Lane (~95 m) ist in ~30 s durchquerbar, damit Angriffe die Basis innerhalb eines Kampfes erreichen. | + +## Auswertung M1 (reine Bot-Partien, 1 Mensch = Bot) + +[sim-8-seeds.json](logs/m1/sim-8-seeds.json): acht Seeds mit gemischten Bünden und fünf Bot-Profilen (ausgewogen, verteidigung, druck, fernkampf, teamhilfe). Die Ergebnisse stehen in [STATUS.md](STATUS.md). + +- Alle acht Partien enden regulär durch einen Ancient-Fall, nach 8–15 Wellen (Blau 5, Rot 3). 83 Kampfphasen enden, weil alle beweglichen Einheiten gefallen sind, 8 durch einen Kernfall, keine durch das Zeitfenster. +- **Exakter Spiegel** (sechsmal derselbe Bund, dasselbe deterministische Profil): Die Partie bleibt über 60 Runden Bit für Bit symmetrisch. Sie wird aber nicht entschieden, weil sich gleich starke Armeen und Wellen gegenseitig aufheben. Das ist der erwartbare Grenzfall identischen Spiels. Kein Timer oder Gebäudeverfall erzwingt ein Ende. Heldenwachstum, Angriffschampions und Boss (M2) liefern die beabsichtigten Asymmetrien. +- Kleine Botstichproben belegen keine ausgewogene Spielerfahrung. Die Bünde und Profile sind noch nicht getrennt ausgewertet (M3). diff --git a/docs/luneraria/QA-MATRIX.md b/docs/luneraria/QA-MATRIX.md new file mode 100644 index 0000000..bd4df7a --- /dev/null +++ b/docs/luneraria/QA-MATRIX.md @@ -0,0 +1,49 @@ +# QA-Matrix + +Testumgebung aller Läufe: Fedora Linux 44 (KDE Plasma, XWayland), AMD Ryzen 5 5600X, NVIDIA GeForce RTX 5060 Ti (Vulkan 1.4.351), Godot 4.7.2 (Fedora-Build), Forward+. **Windows 11 ist nicht geprüft:** Es gibt keine Windows-Hardware in dieser Umgebung. + +Legende: ✅ automatisiert geprüft · 👁 in echten Aufnahmen visuell geprüft · ⏳ offen (Meilenstein) · — nicht zutreffend. + +## Abnahmepunkte aus dem Masterauftrag (§15) + +| # | Anforderung | Stand | Beleg | +| --- | --- | --- | --- | +| 1 | Zwei Teams, sechs Eigentümerrollen, drei Lanes, 16 Türme, 2 Kerne | ✅ M1 | `test_lune_core` · structure | +| 2 | Turmreihenfolge, Basisschutz, Kernsieg; gleichzeitige Kernzerstörung ohne Reihenfolgevorteil | ✅ M1 | `test_lune_core` · gates, victory_and_draw, mirror_fairness | +| 3 | Fortsetzung nach Welle 20, hohe Indizes ohne Final-Wave-Logik, Fehler oder Objektwachstum | ✅ M1 (Wellen bis 10⁶, Runde 21; Menge ≤ 7 je Lane) | `test_lune_core` · endless_waves | +| 4 | Gold exakt einmal, Besitzprüfung, keine Reset-/Despawn-/Beschwörungsfarmen | ✅ M1 (Monster, Gebäude, Truppentod ohne Kopfgeld); Beschwörungen gibt es noch nicht | `test_lune_core` · rewards, commands | +| 5 | Heilung und Persistenz nach der Runde; neue Partie ohne übernommene Werte | ✅ M1 Truppen · ⏳ M2 Helden | `test_lune_core` · persistence; `verify_m1` | +| 6 | Truppen ohne Heldeninventar; Talente, drei Itemplätze, Stapeln | ⏳ M2 | — | +| 7 | Verstärkungskauf n → Einsatz n+1, richtige Lane und richtiges Team | ⏳ M2 | — | +| 8 | Grottenabzug schwächt die Lane für die ganze Kampfphase | ⏳ M2 | — | +| 9 | Beide Expeditionen, Scheitern, Erfolg, genau eine Entsendung, Kopfgeld, Verfügbarkeit ab Runde 10 | ⏳ M2 | — | +| 10 | Laneverlust, Phasenende, Pause, Neustart, Rückkehr ins Menü | ✅ M1 (Pause, Phasenende, Menü, Neustart, Rematch) · ⏳ Laneverlust gezielt | `verify_m1`, `test_lune_core` · victory (timeout) | +| 11 | Bots ohne Sonderressourcen und ohne Sicht auf private Befehle | ✅ M1 Ressourcen · Bots lesen nur den eigenen Entwurf und veröffentlichte Gebäudestände (Code-Durchsicht, `LuneBot`) | `test_lune_core` · commands | +| 12 | Netzwerk: doppelte, veraltete und fremde Befehle; synchroner Phasenwechsel, Disconnect | ⏳ M4 | — | + +## Echte Anwendung (M1) + +`tools/luneraria/verify_m1.tscn`, 38 Prüfungen mit echten Maus- und Tastaturereignissen: + +- Titel und Projektname, Lane- und Bundwahl per Klick, Start. +- Eingabeschutz während der Überblendung: Mausrad, 1 und Leertaste erreichen das Spiel nicht. +- Kauf, Startaufstellung, Rückgängig, Laneeinteilung und die Leertaste übernehmen die Planung; im Kampf wird nichts gekauft. +- Truppen kämpfen auf ihrer Lane; Pause hält die Kampfuhr an; Tempo schaltet um. +- Abrechnung und Rückkehr der Truppen. +- HUD innerhalb des Fensters bei 1280 × 720 und 1920 × 1080. +- Menü, neue Partie ohne Altlasten, eine vollständige Partie bis zum Ancient-Fall mit Live-Aufnahme der Belagerung, Ergebnisbildschirm, Rematch. +- Vier Menü-/Partiewechsel ohne wachsende Objektzahl. + +Nur die Kampfuhr wird dabei beschleunigt (mehr feste Schritte pro Bild), die Regeln bleiben unverändert. + +## Legacy-Regressionen (Saumwacht) + +Unverändert über die Umleitung der alten QA-Schalter: 209 · 33 · 463 · 206 · 1.665 · Menü 20A 68. Logs: `logs/m0-baseline/`, `logs/m1/legacy-*.log`. + +## Plattformen + +| Plattform | Stand | +| --- | --- | +| Fedora 44, Forward+ | ✅ Quellprojekt (Tests, Aufnahmen, Leistung) | +| Fedora 44, Kompatibilitätsmodus (OpenGL) | ⏳ M4 (für Saumwacht 0.15.0 geprüft, für Luneraria noch nicht) | +| Windows 11 | ⏳ nicht geprüft (keine Hardware). Ein exportiertes Paket oder ein Wine-Lauf wäre kein nativer Test. | diff --git a/docs/luneraria/STATUS.md b/docs/luneraria/STATUS.md index 64d4e94..1161f1a 100644 --- a/docs/luneraria/STATUS.md +++ b/docs/luneraria/STATUS.md @@ -1,19 +1,51 @@ # STATUS · Champions of Luneraria -Stand: 27. September 2026. +Stand: 27. September 2026 · Branch `luneraria/m1` · Version im Projekt 0.15.0 (Saumwacht-Demo), Luneraria-Paket erst mit M4. | Meilenstein | Zustand | | --- | --- | -| M0 · Migration vorbereiten | **abgeschlossen**: Bestand, Sicherung (Git-Baseline `c2b4476` + Archiv), Regelwerk, Architektur, Assetmatrix, Baseline-Tests grün | -| M1 · Spielbarer Drei-Lane-Kern | in Arbeit | -| M2 · Mechanikdurchlauf | offen | +| M0 · Migration vorbereiten | **abgeschlossen** | +| M1 · Spielbarer Drei-Lane-Kern | **abgeschlossen** (siehe unten) | +| M2 · Mechanikdurchlauf | in Arbeit | | M3 · Qualität und Balance | offen | | M4 · Netzwerk und Pakete | offen | +## M1 · abgeschlossen + +**Implementiert** +- Autoritative Partie mit sechs Spielerrollen (ein Mensch, fünf Bots), drei spiegelsymmetrischen Lanes, 16 Türmen und zwei Ancients. +- Gebäudesperren für jede Schadensquelle. +- Vorrückende Armeen mit eigenen, unbegrenzt wachsenden Monsterwellen. +- Planung mit atomarer Übernahme, Besitz-IDs, Einmal-Belohnung mit verlustfreier Restverteilung, Rückkehr und Heilung der Truppen, persistenter Gebäudeschaden. +- Partieende nur durch Kernfall; bei gleichzeitigem Kernfall Unentschieden. +- Bots über dieselbe Befehlsschnittstelle. +- 3D-Karte aus Bestandsassets plus neuem Turm (Blender), Einheiten mit den vorhandenen Modellen und Animationen. +- HUD mit Planung, Laneübersicht, Minimap, Ergebnis; Titelmenü auf Champions of Luneraria umgestellt. +- Übernahme der alten Benutzereinstellungen. Legacy-Modus bleibt unter `--legacy` und den alten QA-Schaltern erreichbar. + +**Automatisiert geprüft** +- Regeln 91/91, darunter der exakte Spiegeltest über 12 Runden. +- Echte Anwendung 38/38, mit einer vollständigen Partie bis zum Ancient-Fall. +- Acht Bot-Seeds enden regulär nach 8–15 Wellen. +- Legacy 209 · 33 · 463 · 206 · 1.665 · 68 unverändert. + +**Visuell geprüft:** Menü, Planung (720p/1080p), Kampf Mid/Top, Übersicht, Belagerung eines Ancients, Ergebnis (`screenshots/`). + +**Leistung** (RTX 5060 Ti, Ryzen 5 5600X, Forward+, VSync aus, 2×): 114 bewegliche Einheiten und 18 Gebäude. 1080p: 7,6 ms/Bild (p95 10,5 ms), GPU 3,1 ms, ~3.170 Draw Calls, Simulationsschritt 2,4 ms (vor der Optimierung 15,8 ms). Es gibt keine allgemeine FPS-Zusage. + +**Ungetestet:** Windows 11, Kompatibilitätsmodus für Luneraria, menschliche Spieltests. + +## Bekannt + +- Ein exakt gespiegeltes Match bleibt unentschieden, wird also nicht beendet (Testlimit). Das ist ein Balancebefund für M3. +- Die Figuren der Monsterwellen sind nur über Teamring und Lichtfarbe dem Team zugeordnet. +- Bäume und Felsen stammen aus dem Vergleichspack und sind einfach; das Kartenbild ist funktional, noch nicht endgültig (M3). +- Beim Beenden meldet Godot eine noch belegte Ressource (Hintergrundladen der Menübühne); ohne Folgen im Spiel. + ## Blockiert -- Nativer Windows-11-Test: keine Windows-Hardware in dieser Umgebung. +- Nativer Windows-11-Test: keine Windows-Hardware. ## Nächster Schritt -M1: `LuneBalance`, `LuneMap`, `LuneCombat`, `LuneMatch` und Bots, dann Kartendarstellung, HUD und ein reproduzierbar simulierter Sieg. +M2: Helden (3) mit Erfahrung, Talenten und drei Itemplätzen (6 Items), Angriffschampions (3) mit Buchung n → n+1, Bossgrotte mit Streitfall, Sperre und Entsendung, dazu die HUD-Erweiterung und Bot-Strategien. diff --git a/docs/luneraria/logs/core-tests.json b/docs/luneraria/logs/core-tests.json new file mode 100644 index 0000000..7a0d10a --- /dev/null +++ b/docs/luneraria/logs/core-tests.json @@ -0,0 +1,4 @@ +{ + "checks": 91, + "failures": [] +} \ No newline at end of file diff --git a/docs/luneraria/logs/m1-acceptance.json b/docs/luneraria/logs/m1-acceptance.json new file mode 100644 index 0000000..237f00b --- /dev/null +++ b/docs/luneraria/logs/m1-acceptance.json @@ -0,0 +1,10 @@ +{ + "checks": 38, + "failures": [], + "object_counts": [ + 3505.0, + 3505.0, + 3505.0, + 3505.0 + ] +} \ No newline at end of file diff --git a/docs/luneraria/logs/m1/bench-1280x720.log b/docs/luneraria/logs/m1/bench-1280x720.log new file mode 100644 index 0000000..7269157 --- /dev/null +++ b/docs/luneraria/logs/m1/bench-1280x720.log @@ -0,0 +1,8 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org +Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti + +LUNE_BENCH {"buildings":18,"cpu":"AMD Ryzen 5 5600X 6-Core Processor","draw_calls_mean":3175.86281588448,"engine":"4.7.2-stable (fedora)","frame_ms_mean":7.22646841155234,"frame_ms_p95":10.294,"frames":1108,"gpu":"NVIDIA GeForce RTX 5060 Ti","gpu_ms_mean":2.09521750809914,"mobile_units_at_start":114,"mobile_units_mean_measured":108.60559566787,"renderer":"forward_plus","scene_build_ms":198.002,"sim_speed":"2x (40 steps/s)","sim_step_ms":2.37125,"static_mem_mb":120.703545570374,"video_mem_mb":658.710235595703,"window":"(1280, 720)"} +WARNING: 24 ObjectDB instances were leaked at exit (run with `--verbose` for details). + at: cleanup (core/object/object.cpp:2536) +ERROR: 1 resources still in use at exit (run with --verbose for details). + at: clear (core/io/resource.cpp:822) diff --git a/docs/luneraria/logs/m1/bench-1920x1080.log b/docs/luneraria/logs/m1/bench-1920x1080.log new file mode 100644 index 0000000..45f8e2e --- /dev/null +++ b/docs/luneraria/logs/m1/bench-1920x1080.log @@ -0,0 +1,8 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org +Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti + +LUNE_BENCH {"buildings":18,"cpu":"AMD Ryzen 5 5600X 6-Core Processor","draw_calls_mean":3172.21948912015,"engine":"4.7.2-stable (fedora)","frame_ms_mean":7.56970955534533,"frame_ms_p95":10.505,"frames":1057,"gpu":"NVIDIA GeForce RTX 5060 Ti","gpu_ms_mean":3.06410596431599,"mobile_units_at_start":114,"mobile_units_mean_measured":108.593188268685,"renderer":"forward_plus","scene_build_ms":221.213,"sim_speed":"2x (40 steps/s)","sim_step_ms":2.403405,"static_mem_mb":120.915734291077,"video_mem_mb":765.283081054688,"window":"(1920, 1080)"} +WARNING: 24 ObjectDB instances were leaked at exit (run with `--verbose` for details). + at: cleanup (core/object/object.cpp:2536) +ERROR: 1 resources still in use at exit (run with --verbose for details). + at: clear (core/io/resource.cpp:822) diff --git a/docs/luneraria/logs/m1/legacy-ability.log b/docs/luneraria/logs/m1/legacy-ability.log new file mode 100644 index 0000000..6659999 --- /dev/null +++ b/docs/luneraria/logs/m1/legacy-ability.log @@ -0,0 +1,4 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +ABILITY_EVENT_PASS: 33 checks; 0 failures +EXIT 0 diff --git a/docs/luneraria/logs/m1/legacy-expedition.log b/docs/luneraria/logs/m1/legacy-expedition.log new file mode 100644 index 0000000..dbe7f4e --- /dev/null +++ b/docs/luneraria/logs/m1/legacy-expedition.log @@ -0,0 +1,250 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +ERROR: Attempting to initialize the wrong RID + at: get_or_null (./core/templates/rid_owner.h:240) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "mem" is null. + at: initialize_rid (./core/templates/rid_owner.h:284) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_set_blend_shape_count (servers/rendering/dummy/storage/mesh_storage.h:74) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_add_surface (servers/rendering/dummy/storage/mesh_storage.h:81) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_set_blend_shape_mode (servers/rendering/dummy/storage/mesh_storage.h:110) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Attempting to use an uninitialized RID + at: get_or_null (./core/templates/rid_owner.h:247) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +ERROR: Parameter "m" is null. + at: mesh_set_blend_shape_mode (servers/rendering/dummy/storage/mesh_storage.h:110) + GDScript backtrace (most recent call first): + [0] instantiate_fighter (res://scripts/presentation/asset_library.gd:110) + [1] _place_core (res://scripts/presentation/menu_stage.gd:172) + [2] _ready (res://scripts/presentation/menu_stage.gd:61) + [3] _build_stage (res://scripts/ui/main_menu.gd:97) + [4] _ready (res://scripts/ui/main_menu.gd:69) + [5] _make_overlay (res://scripts/ui/hud.gd:492) + [6] _ready (res://scripts/ui/hud.gd:112) +EXPEDITION_MATCH: Standard gegen Standard; wave 20; cores [669, 669]; outcome -1 +EXPEDITION_MATCH: Standard gegen Lernmodus; wave 10; cores [2038, 0]; outcome 0 +EXPEDITION_MATCH: Leeres Baufeld gegen Standard; wave 7; cores [0, 3000]; outcome 1 +EXPEDITION_PASS: 206 checks; 0 failures; report /home/martin/.local/share/godot/app_userdata/Champions of Luneraria/phase8-verification.json +WARNING: 6 ObjectDB instances were leaked at exit (run with `--verbose` for details). + at: cleanup (core/object/object.cpp:2536) +ERROR: 1 RID allocations of type 'N13RendererDummy9DummyMeshE' were leaked at exit. +EXIT 0 diff --git a/docs/luneraria/logs/m1/legacy-faction.log b/docs/luneraria/logs/m1/legacy-faction.log new file mode 100644 index 0000000..92e3346 --- /dev/null +++ b/docs/luneraria/logs/m1/legacy-faction.log @@ -0,0 +1,8 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +FACTION_MATCH: Wurzelbund gegen Glutpakt wave 20 cores [1244, 1709] +FACTION_MATCH: Glutpakt gegen Wurzelbund wave 20 cores [1709, 1244] +FACTION_MATCH: Wurzelbund Spiegel wave 20 cores [1244, 1244] +FACTION_MATCH: Wurzelbund Kurzpartie wave 8 cores [3000, 3000] +FACTION_PASS: 1665 checks; 0 failures; /home/martin/.local/share/godot/app_userdata/Champions of Luneraria/phase10-verification.json +EXIT 0 diff --git a/docs/luneraria/logs/m1/legacy-smoke.log b/docs/luneraria/logs/m1/legacy-smoke.log new file mode 100644 index 0000000..c2129a6 --- /dev/null +++ b/docs/luneraria/logs/m1/legacy-smoke.log @@ -0,0 +1,5 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +EXPORT_SMOKE_PASS: 463 checks; 0 failures; engine 4.7.2-stable (fedora); cores [3000, 1035] +SMOKE_CAPTURES: /home/martin/.local/share/godot/app_userdata/Champions of Luneraria/phase8-smoke +EXIT 0 diff --git a/docs/luneraria/logs/m1/legacy-test-runner.log b/docs/luneraria/logs/m1/legacy-test-runner.log new file mode 100644 index 0000000..ac5e7b6 --- /dev/null +++ b/docs/luneraria/logs/m1/legacy-test-runner.log @@ -0,0 +1,4 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +PASS: 209 checks across economy, combat and the full match lifecycle. +EXIT 0 diff --git a/docs/luneraria/logs/m1/legacy-verify-menu.log b/docs/luneraria/logs/m1/legacy-verify-menu.log new file mode 100644 index 0000000..f980cad --- /dev/null +++ b/docs/luneraria/logs/m1/legacy-verify-menu.log @@ -0,0 +1,5 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org +Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti + +MENU_VERIFY_PASS: 68 checks; 0 failures +EXIT 0 diff --git a/docs/luneraria/logs/m1/lune-core.log b/docs/luneraria/logs/m1/lune-core.log new file mode 100644 index 0000000..b9f2921 --- /dev/null +++ b/docs/luneraria/logs/m1/lune-core.log @@ -0,0 +1,9 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +LUNE_FULL_MATCH: seed 2 -> Blau after 8 rounds +LUNE_CORE_PASS: 91 checks; 0 failures +WARNING: 10 ObjectDB instances were leaked at exit (run with `--verbose` for details). + at: cleanup (core/object/object.cpp:2536) +ERROR: 1 resources still in use at exit (run with --verbose for details). + at: clear (core/io/resource.cpp:822) +EXIT 0 diff --git a/docs/luneraria/logs/m1/sim-8-seeds.json b/docs/luneraria/logs/m1/sim-8-seeds.json new file mode 100644 index 0000000..9279c54 --- /dev/null +++ b/docs/luneraria/logs/m1/sim-8-seeds.json @@ -0,0 +1,1940 @@ +[ + { + "bunds": [ + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt" + ], + "history": [ + { + "buildings": [ + 9, + 9 + ], + "combat_time": 46.6999999999995, + "end_reason": "cleared", + "gold": [ + 123, + 173, + 123, + 128, + 123, + 128 + ], + "lost": [], + "round": 1 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 53.6999999999991, + "end_reason": "cleared", + "gold": [ + 140, + 105, + 115, + 125, + 125, + 150 + ], + "lost": [], + "round": 2 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 55.499999999999, + "end_reason": "cleared", + "gold": [ + 160, + 170, + 150, + 125, + 160, + 175 + ], + "lost": [], + "round": 3 + }, + { + "buildings": [ + 8, + 9 + ], + "combat_time": 69.1499999999982, + "end_reason": "cleared", + "gold": [ + 182, + 142, + 132, + 182, + 172, + 257 + ], + "lost": [ + "blau_top_outer" + ], + "round": 4 + }, + { + "buildings": [ + 8, + 9 + ], + "combat_time": 74.3499999999979, + "end_reason": "cleared", + "gold": [ + 178, + 183, + 163, + 133, + 153, + 153 + ], + "lost": [], + "round": 5 + }, + { + "buildings": [ + 7, + 8 + ], + "combat_time": 70.0999999999982, + "end_reason": "cleared", + "gold": [ + 203, + 223, + 183, + 243, + 253, + 203 + ], + "lost": [ + "rot_mid_outer", + "blau_top_inner" + ], + "round": 6 + }, + { + "buildings": [ + 7, + 7 + ], + "combat_time": 73.349999999998, + "end_reason": "cleared", + "gold": [ + 270, + 235, + 270, + 170, + 195, + 205 + ], + "lost": [ + "rot_mid_inner" + ], + "round": 7 + }, + { + "buildings": [ + 6, + 7 + ], + "combat_time": 79.8999999999976, + "end_reason": "cleared", + "gold": [ + 245, + 185, + 165, + 275, + 245, + 280 + ], + "lost": [ + "blau_base_1" + ], + "round": 8 + }, + { + "buildings": [ + 4, + 7 + ], + "combat_time": 86.9499999999972, + "end_reason": "core", + "gold": [ + 57, + 106, + 66, + 171, + 176, + 176 + ], + "lost": [ + "blau_base_2", + "blau_ancient" + ], + "round": 9 + } + ], + "note": "Ancient von Blau zerstört.", + "profiles": [ + "verteidigung", + "fernkampf", + "ausgewogen", + "druck", + "teamhilfe", + "verteidigung" + ], + "result": "Sieg Rot", + "rounds": 9, + "seed": 1, + "wall_ms": 7091 + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund" + ], + "history": [ + { + "buildings": [ + 9, + 9 + ], + "combat_time": 46.6999999999995, + "end_reason": "cleared", + "gold": [ + 128, + 123, + 128, + 173, + 128, + 123 + ], + "lost": [], + "round": 1 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 53.6999999999991, + "end_reason": "cleared", + "gold": [ + 125, + 125, + 150, + 105, + 125, + 115 + ], + "lost": [], + "round": 2 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 54.699999999999, + "end_reason": "cleared", + "gold": [ + 125, + 160, + 175, + 165, + 165, + 115 + ], + "lost": [], + "round": 3 + }, + { + "buildings": [ + 9, + 8 + ], + "combat_time": 73.149999999998, + "end_reason": "cleared", + "gold": [ + 182, + 172, + 257, + 137, + 157, + 187 + ], + "lost": [ + "rot_top_outer" + ], + "round": 4 + }, + { + "buildings": [ + 8, + 7 + ], + "combat_time": 81.8999999999975, + "end_reason": "cleared", + "gold": [ + 203, + 223, + 223, + 218, + 183, + 193 + ], + "lost": [ + "blau_mid_outer", + "rot_top_inner" + ], + "round": 5 + }, + { + "buildings": [ + 8, + 7 + ], + "combat_time": 67.3499999999983, + "end_reason": "cleared", + "gold": [ + 158, + 178, + 133, + 138, + 153, + 133 + ], + "lost": [], + "round": 6 + }, + { + "buildings": [ + 7, + 6 + ], + "combat_time": 74.6999999999979, + "end_reason": "cleared", + "gold": [ + 260, + 285, + 310, + 225, + 235, + 255 + ], + "lost": [ + "blau_mid_inner", + "rot_base_1" + ], + "round": 7 + }, + { + "buildings": [ + 7, + 3 + ], + "combat_time": 92.8999999999969, + "end_reason": "core", + "gold": [ + 207, + 197, + 217, + 79, + 105, + 64 + ], + "lost": [ + "rot_bot_outer", + "rot_base_2", + "rot_ancient" + ], + "round": 8 + } + ], + "note": "Ancient von Rot zerstört.", + "profiles": [ + "druck", + "teamhilfe", + "verteidigung", + "fernkampf", + "ausgewogen", + "druck" + ], + "result": "Sieg Blau", + "rounds": 8, + "seed": 2, + "wall_ms": 6499 + }, + { + "bunds": [ + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt" + ], + "history": [ + { + "buildings": [ + 9, + 9 + ], + "combat_time": 41.8999999999998, + "end_reason": "cleared", + "gold": [ + 173, + 128, + 123, + 128, + 123, + 173 + ], + "lost": [], + "round": 1 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 46.6999999999995, + "end_reason": "cleared", + "gold": [ + 105, + 125, + 115, + 135, + 140, + 105 + ], + "lost": [], + "round": 2 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 59.7999999999987, + "end_reason": "cleared", + "gold": [ + 165, + 165, + 115, + 175, + 160, + 170 + ], + "lost": [], + "round": 3 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 61.3999999999986, + "end_reason": "cleared", + "gold": [ + 137, + 157, + 187, + 137, + 182, + 142 + ], + "lost": [], + "round": 4 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 54.899999999999, + "end_reason": "cleared", + "gold": [ + 168, + 133, + 143, + 133, + 178, + 183 + ], + "lost": [], + "round": 5 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 57.8999999999988, + "end_reason": "cleared", + "gold": [ + 163, + 173, + 173, + 173, + 153, + 173 + ], + "lost": [], + "round": 6 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 58.2999999999988, + "end_reason": "cleared", + "gold": [ + 180, + 185, + 225, + 185, + 205, + 185 + ], + "lost": [], + "round": 7 + }, + { + "buildings": [ + 8, + 9 + ], + "combat_time": 61.9499999999986, + "end_reason": "cleared", + "gold": [ + 200, + 200, + 155, + 250, + 255, + 250 + ], + "lost": [ + "blau_bot_outer" + ], + "round": 8 + }, + { + "buildings": [ + 8, + 9 + ], + "combat_time": 71.3499999999981, + "end_reason": "cleared", + "gold": [ + 213, + 218, + 188, + 198, + 168, + 198 + ], + "lost": [], + "round": 9 + }, + { + "buildings": [ + 7, + 9 + ], + "combat_time": 74.2499999999979, + "end_reason": "cleared", + "gold": [ + 215, + 250, + 215, + 300, + 265, + 300 + ], + "lost": [ + "blau_bot_inner" + ], + "round": 10 + }, + { + "buildings": [ + 5, + 9 + ], + "combat_time": 83.7499999999974, + "end_reason": "cleared", + "gold": [ + 228, + 218, + 218, + 348, + 358, + 413 + ], + "lost": [ + "blau_top_outer", + "blau_base_2" + ], + "round": 11 + }, + { + "buildings": [ + 1, + 9 + ], + "combat_time": 86.9499999999972, + "end_reason": "core", + "gold": [ + 101, + 106, + 133, + 348, + 318, + 323 + ], + "lost": [ + "blau_mid_outer", + "blau_top_inner", + "blau_base_1", + "blau_ancient" + ], + "round": 12 + } + ], + "note": "Ancient von Blau zerstört.", + "profiles": [ + "fernkampf", + "ausgewogen", + "druck", + "teamhilfe", + "verteidigung", + "fernkampf" + ], + "result": "Sieg Rot", + "rounds": 12, + "seed": 3, + "wall_ms": 9910 + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund" + ], + "history": [ + { + "buildings": [ + 9, + 9 + ], + "combat_time": 44.5999999999996, + "end_reason": "cleared", + "gold": [ + 128, + 123, + 173, + 123, + 128, + 123 + ], + "lost": [], + "round": 1 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 46.4999999999995, + "end_reason": "cleared", + "gold": [ + 135, + 140, + 105, + 115, + 125, + 125 + ], + "lost": [], + "round": 2 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 55.099999999999, + "end_reason": "cleared", + "gold": [ + 175, + 160, + 170, + 150, + 125, + 160 + ], + "lost": [], + "round": 3 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 63.0999999999986, + "end_reason": "cleared", + "gold": [ + 137, + 182, + 142, + 132, + 132, + 122 + ], + "lost": [], + "round": 4 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 62.2999999999986, + "end_reason": "cleared", + "gold": [ + 133, + 178, + 183, + 163, + 173, + 153 + ], + "lost": [], + "round": 5 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 59.6999999999987, + "end_reason": "cleared", + "gold": [ + 173, + 153, + 173, + 133, + 213, + 148 + ], + "lost": [], + "round": 6 + }, + { + "buildings": [ + 9, + 8 + ], + "combat_time": 72.099999999998, + "end_reason": "cleared", + "gold": [ + 235, + 255, + 235, + 160, + 190, + 165 + ], + "lost": [ + "rot_bot_outer" + ], + "round": 7 + }, + { + "buildings": [ + 8, + 8 + ], + "combat_time": 62.5499999999986, + "end_reason": "cleared", + "gold": [ + 180, + 180, + 180, + 205, + 210, + 215 + ], + "lost": [ + "blau_top_outer" + ], + "round": 8 + }, + { + "buildings": [ + 8, + 7 + ], + "combat_time": 74.8499999999979, + "end_reason": "cleared", + "gold": [ + 268, + 263, + 268, + 238, + 223, + 178 + ], + "lost": [ + "rot_bot_inner" + ], + "round": 9 + }, + { + "buildings": [ + 8, + 7 + ], + "combat_time": 80.2999999999976, + "end_reason": "cleared", + "gold": [ + 235, + 205, + 235, + 210, + 265, + 185 + ], + "lost": [], + "round": 10 + }, + { + "buildings": [ + 7, + 4 + ], + "combat_time": 117.549999999995, + "end_reason": "core", + "gold": [ + 288, + 348, + 303, + 223, + 178, + 178 + ], + "lost": [ + "blau_mid_outer", + "rot_base_2", + "rot_base_1", + "rot_ancient" + ], + "round": 11 + } + ], + "note": "Ancient von Rot zerstört.", + "profiles": [ + "teamhilfe", + "verteidigung", + "fernkampf", + "ausgewogen", + "druck", + "teamhilfe" + ], + "result": "Sieg Blau", + "rounds": 11, + "seed": 4, + "wall_ms": 9299 + }, + { + "bunds": [ + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt" + ], + "history": [ + { + "buildings": [ + 9, + 9 + ], + "combat_time": 42.0999999999997, + "end_reason": "cleared", + "gold": [ + 123, + 128, + 123, + 128, + 173, + 128 + ], + "lost": [], + "round": 1 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 50.2999999999993, + "end_reason": "cleared", + "gold": [ + 115, + 125, + 125, + 150, + 105, + 125 + ], + "lost": [], + "round": 2 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 55.099999999999, + "end_reason": "cleared", + "gold": [ + 150, + 125, + 160, + 175, + 165, + 165 + ], + "lost": [], + "round": 3 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 66.8999999999983, + "end_reason": "cleared", + "gold": [ + 132, + 132, + 122, + 207, + 137, + 157 + ], + "lost": [], + "round": 4 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 60.2999999999987, + "end_reason": "cleared", + "gold": [ + 163, + 173, + 153, + 198, + 168, + 133 + ], + "lost": [], + "round": 5 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 61.8999999999986, + "end_reason": "cleared", + "gold": [ + 133, + 213, + 148, + 178, + 163, + 173 + ], + "lost": [], + "round": 6 + }, + { + "buildings": [ + 9, + 8 + ], + "combat_time": 87.3499999999972, + "end_reason": "cleared", + "gold": [ + 210, + 240, + 215, + 180, + 180, + 185 + ], + "lost": [ + "rot_top_outer" + ], + "round": 7 + }, + { + "buildings": [ + 9, + 8 + ], + "combat_time": 89.3499999999971, + "end_reason": "cleared", + "gold": [ + 205, + 210, + 165, + 165, + 200, + 200 + ], + "lost": [], + "round": 8 + }, + { + "buildings": [ + 8, + 7 + ], + "combat_time": 80.2999999999976, + "end_reason": "cleared", + "gold": [ + 308, + 293, + 303, + 218, + 263, + 268 + ], + "lost": [ + "blau_bot_outer", + "rot_top_inner" + ], + "round": 9 + }, + { + "buildings": [ + 8, + 7 + ], + "combat_time": 84.1499999999974, + "end_reason": "cleared", + "gold": [ + 220, + 195, + 195, + 185, + 190, + 185 + ], + "lost": [], + "round": 10 + }, + { + "buildings": [ + 7, + 6 + ], + "combat_time": 84.8499999999973, + "end_reason": "cleared", + "gold": [ + 363, + 343, + 358, + 323, + 313, + 308 + ], + "lost": [ + "blau_bot_inner", + "rot_base_1" + ], + "round": 11 + }, + { + "buildings": [ + 7, + 6 + ], + "combat_time": 83.6999999999974, + "end_reason": "cleared", + "gold": [ + 267, + 237, + 237, + 232, + 272, + 232 + ], + "lost": [], + "round": 12 + }, + { + "buildings": [ + 6, + 5 + ], + "combat_time": 95.7499999999967, + "end_reason": "cleared", + "gold": [ + 303, + 338, + 333, + 388, + 393, + 393 + ], + "lost": [ + "rot_mid_outer", + "blau_base_2" + ], + "round": 13 + }, + { + "buildings": [ + 6, + 5 + ], + "combat_time": 82.1499999999975, + "end_reason": "cleared", + "gold": [ + 390, + 295, + 305, + 310, + 320, + 375 + ], + "lost": [], + "round": 14 + }, + { + "buildings": [ + 6, + 3 + ], + "combat_time": 130.349999999995, + "end_reason": "core", + "gold": [ + 411, + 511, + 291, + 216, + 311, + 351 + ], + "lost": [ + "rot_base_2", + "rot_ancient" + ], + "round": 15 + } + ], + "note": "Ancient von Rot zerstört.", + "profiles": [ + "ausgewogen", + "druck", + "teamhilfe", + "verteidigung", + "fernkampf", + "ausgewogen" + ], + "result": "Sieg Blau", + "rounds": 15, + "seed": 5, + "wall_ms": 14879 + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund" + ], + "history": [ + { + "buildings": [ + 9, + 9 + ], + "combat_time": 48.2999999999994, + "end_reason": "cleared", + "gold": [ + 128, + 173, + 128, + 123, + 128, + 123 + ], + "lost": [], + "round": 1 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 52.4999999999992, + "end_reason": "cleared", + "gold": [ + 150, + 105, + 125, + 115, + 135, + 140 + ], + "lost": [], + "round": 2 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 60.6999999999987, + "end_reason": "cleared", + "gold": [ + 175, + 165, + 165, + 115, + 175, + 160 + ], + "lost": [], + "round": 3 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 64.4999999999985, + "end_reason": "cleared", + "gold": [ + 207, + 137, + 157, + 187, + 137, + 182 + ], + "lost": [], + "round": 4 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 59.0999999999988, + "end_reason": "cleared", + "gold": [ + 198, + 168, + 133, + 143, + 133, + 178 + ], + "lost": [], + "round": 5 + }, + { + "buildings": [ + 9, + 8 + ], + "combat_time": 58.4999999999988, + "end_reason": "cleared", + "gold": [ + 228, + 213, + 223, + 173, + 173, + 153 + ], + "lost": [ + "rot_mid_outer" + ], + "round": 6 + }, + { + "buildings": [ + 8, + 8 + ], + "combat_time": 72.749999999998, + "end_reason": "cleared", + "gold": [ + 230, + 155, + 165, + 275, + 235, + 255 + ], + "lost": [ + "blau_top_outer" + ], + "round": 7 + }, + { + "buildings": [ + 7, + 7 + ], + "combat_time": 86.0999999999973, + "end_reason": "cleared", + "gold": [ + 285, + 245, + 250, + 230, + 250, + 250 + ], + "lost": [ + "rot_mid_inner", + "blau_top_inner" + ], + "round": 8 + }, + { + "buildings": [ + 7, + 6 + ], + "combat_time": 72.449999999998, + "end_reason": "cleared", + "gold": [ + 218, + 248, + 273, + 243, + 203, + 203 + ], + "lost": [ + "rot_bot_outer" + ], + "round": 9 + }, + { + "buildings": [ + 7, + 6 + ], + "combat_time": 74.8499999999979, + "end_reason": "cleared", + "gold": [ + 195, + 220, + 215, + 270, + 245, + 250 + ], + "lost": [], + "round": 10 + }, + { + "buildings": [ + 7, + 5 + ], + "combat_time": 92.9499999999969, + "end_reason": "cleared", + "gold": [ + 288, + 343, + 278, + 283, + 218, + 278 + ], + "lost": [ + "rot_bot_inner" + ], + "round": 11 + }, + { + "buildings": [ + 7, + 2 + ], + "combat_time": 89.1499999999971, + "end_reason": "core", + "gold": [ + 328, + 288, + 348, + 108, + 163, + 87 + ], + "lost": [ + "rot_base_2", + "rot_base_1", + "rot_ancient" + ], + "round": 12 + } + ], + "note": "Ancient von Rot zerstört.", + "profiles": [ + "verteidigung", + "fernkampf", + "ausgewogen", + "druck", + "teamhilfe", + "verteidigung" + ], + "result": "Sieg Blau", + "rounds": 12, + "seed": 6, + "wall_ms": 10244 + }, + { + "bunds": [ + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt" + ], + "history": [ + { + "buildings": [ + 9, + 9 + ], + "combat_time": 41.2999999999998, + "end_reason": "cleared", + "gold": [ + 123, + 128, + 123, + 173, + 123, + 128 + ], + "lost": [], + "round": 1 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 51.4999999999992, + "end_reason": "cleared", + "gold": [ + 115, + 135, + 140, + 105, + 115, + 125 + ], + "lost": [], + "round": 2 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 52.4999999999992, + "end_reason": "cleared", + "gold": [ + 115, + 175, + 160, + 170, + 150, + 125 + ], + "lost": [], + "round": 3 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 57.6999999999989, + "end_reason": "cleared", + "gold": [ + 187, + 137, + 182, + 142, + 132, + 132 + ], + "lost": [], + "round": 4 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 57.4999999999989, + "end_reason": "cleared", + "gold": [ + 143, + 133, + 178, + 183, + 163, + 173 + ], + "lost": [], + "round": 5 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 61.6999999999986, + "end_reason": "cleared", + "gold": [ + 173, + 173, + 153, + 173, + 133, + 213 + ], + "lost": [], + "round": 6 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 61.0999999999987, + "end_reason": "cleared", + "gold": [ + 225, + 185, + 205, + 185, + 160, + 190 + ], + "lost": [], + "round": 7 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 57.4999999999989, + "end_reason": "cleared", + "gold": [ + 155, + 200, + 205, + 200, + 155, + 160 + ], + "lost": [], + "round": 8 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 72.899999999998, + "end_reason": "cleared", + "gold": [ + 188, + 218, + 193, + 218, + 188, + 173 + ], + "lost": [], + "round": 9 + }, + { + "buildings": [ + 8, + 9 + ], + "combat_time": 72.149999999998, + "end_reason": "cleared", + "gold": [ + 215, + 250, + 240, + 280, + 285, + 265 + ], + "lost": [ + "blau_bot_outer" + ], + "round": 10 + }, + { + "buildings": [ + 5, + 9 + ], + "combat_time": 84.4499999999973, + "end_reason": "cleared", + "gold": [ + 218, + 218, + 238, + 398, + 453, + 408 + ], + "lost": [ + "blau_mid_outer", + "blau_top_outer", + "blau_bot_inner" + ], + "round": 11 + }, + { + "buildings": [ + 0, + 9 + ], + "combat_time": 98.9499999999965, + "end_reason": "core", + "gold": [ + 156, + 101, + 123, + 463, + 563, + 528 + ], + "lost": [ + "blau_mid_inner", + "blau_top_inner", + "blau_base_2", + "blau_base_1", + "blau_ancient" + ], + "round": 12 + } + ], + "note": "Ancient von Blau zerstört.", + "profiles": [ + "druck", + "teamhilfe", + "verteidigung", + "fernkampf", + "ausgewogen", + "druck" + ], + "result": "Sieg Rot", + "rounds": 12, + "seed": 7, + "wall_ms": 10269 + }, + { + "bunds": [ + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund", + "glutpakt", + "wurzelbund" + ], + "history": [ + { + "buildings": [ + 9, + 9 + ], + "combat_time": 41.8999999999998, + "end_reason": "cleared", + "gold": [ + 173, + 123, + 128, + 123, + 128, + 173 + ], + "lost": [], + "round": 1 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 47.4999999999994, + "end_reason": "cleared", + "gold": [ + 105, + 115, + 125, + 125, + 150, + 105 + ], + "lost": [], + "round": 2 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 55.499999999999, + "end_reason": "cleared", + "gold": [ + 170, + 150, + 125, + 160, + 175, + 165 + ], + "lost": [], + "round": 3 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 55.099999999999, + "end_reason": "cleared", + "gold": [ + 142, + 132, + 132, + 122, + 207, + 137 + ], + "lost": [], + "round": 4 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 54.499999999999, + "end_reason": "cleared", + "gold": [ + 183, + 163, + 173, + 153, + 198, + 168 + ], + "lost": [], + "round": 5 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 57.6999999999989, + "end_reason": "cleared", + "gold": [ + 173, + 133, + 213, + 148, + 178, + 163 + ], + "lost": [], + "round": 6 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 58.6999999999988, + "end_reason": "cleared", + "gold": [ + 185, + 160, + 190, + 165, + 180, + 180 + ], + "lost": [], + "round": 7 + }, + { + "buildings": [ + 9, + 9 + ], + "combat_time": 54.899999999999, + "end_reason": "cleared", + "gold": [ + 200, + 155, + 160, + 165, + 165, + 200 + ], + "lost": [], + "round": 8 + }, + { + "buildings": [ + 9, + 8 + ], + "combat_time": 66.5499999999984, + "end_reason": "cleared", + "gold": [ + 268, + 238, + 223, + 178, + 168, + 213 + ], + "lost": [ + "rot_top_outer" + ], + "round": 9 + }, + { + "buildings": [ + 9, + 7 + ], + "combat_time": 87.4999999999972, + "end_reason": "cleared", + "gold": [ + 280, + 280, + 335, + 210, + 200, + 215 + ], + "lost": [ + "rot_top_inner" + ], + "round": 10 + }, + { + "buildings": [ + 9, + 6 + ], + "combat_time": 87.7499999999972, + "end_reason": "cleared", + "gold": [ + 323, + 403, + 303, + 238, + 218, + 228 + ], + "lost": [ + "rot_bot_outer" + ], + "round": 11 + }, + { + "buildings": [ + 9, + 1 + ], + "combat_time": 102.999999999996, + "end_reason": "core", + "gold": [ + 418, + 493, + 458, + 143, + 113, + 118 + ], + "lost": [ + "rot_base_1", + "rot_mid_outer", + "rot_bot_inner", + "rot_base_2", + "rot_ancient" + ], + "round": 12 + } + ], + "note": "Ancient von Rot zerstört.", + "profiles": [ + "fernkampf", + "ausgewogen", + "druck", + "teamhilfe", + "verteidigung", + "fernkampf" + ], + "result": "Sieg Blau", + "rounds": 12, + "seed": 8, + "wall_ms": 10274 + } +] \ No newline at end of file diff --git a/docs/luneraria/logs/m1/sim-8-seeds.log b/docs/luneraria/logs/m1/sim-8-seeds.log new file mode 100644 index 0000000..81286ce --- /dev/null +++ b/docs/luneraria/logs/m1/sim-8-seeds.log @@ -0,0 +1,106 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +R01 cleared 46.7s buildings [9, 9] lost [] gold [123, 173, 123, 128, 123, 128] +R02 cleared 53.7s buildings [9, 9] lost [] gold [140, 105, 115, 125, 125, 150] +R03 cleared 55.5s buildings [9, 9] lost [] gold [160, 170, 150, 125, 160, 175] +R04 cleared 69.1s buildings [8, 9] lost ["blau_top_outer"] gold [182, 142, 132, 182, 172, 257] +R05 cleared 74.3s buildings [8, 9] lost [] gold [178, 183, 163, 133, 153, 153] +R06 cleared 70.1s buildings [7, 8] lost ["rot_mid_outer", "blau_top_inner"] gold [203, 223, 183, 243, 253, 203] +R07 cleared 73.3s buildings [7, 7] lost ["rot_mid_inner"] gold [270, 235, 270, 170, 195, 205] +R08 cleared 79.9s buildings [6, 7] lost ["blau_base_1"] gold [245, 185, 165, 275, 245, 280] +R09 core 86.9s buildings [4, 7] lost ["blau_base_2", "blau_ancient"] gold [57, 106, 66, 171, 176, 176] +SIM_MATCH seed=1 Sieg Rot after 9 rounds (Ancient von Blau zerstört.) in 7091 ms +R01 cleared 46.7s buildings [9, 9] lost [] gold [128, 123, 128, 173, 128, 123] +R02 cleared 53.7s buildings [9, 9] lost [] gold [125, 125, 150, 105, 125, 115] +R03 cleared 54.7s buildings [9, 9] lost [] gold [125, 160, 175, 165, 165, 115] +R04 cleared 73.1s buildings [9, 8] lost ["rot_top_outer"] gold [182, 172, 257, 137, 157, 187] +R05 cleared 81.9s buildings [8, 7] lost ["blau_mid_outer", "rot_top_inner"] gold [203, 223, 223, 218, 183, 193] +R06 cleared 67.3s buildings [8, 7] lost [] gold [158, 178, 133, 138, 153, 133] +R07 cleared 74.7s buildings [7, 6] lost ["blau_mid_inner", "rot_base_1"] gold [260, 285, 310, 225, 235, 255] +R08 core 92.9s buildings [7, 3] lost ["rot_bot_outer", "rot_base_2", "rot_ancient"] gold [207, 197, 217, 79, 105, 64] +SIM_MATCH seed=2 Sieg Blau after 8 rounds (Ancient von Rot zerstört.) in 6499 ms +R01 cleared 41.9s buildings [9, 9] lost [] gold [173, 128, 123, 128, 123, 173] +R02 cleared 46.7s buildings [9, 9] lost [] gold [105, 125, 115, 135, 140, 105] +R03 cleared 59.8s buildings [9, 9] lost [] gold [165, 165, 115, 175, 160, 170] +R04 cleared 61.4s buildings [9, 9] lost [] gold [137, 157, 187, 137, 182, 142] +R05 cleared 54.9s buildings [9, 9] lost [] gold [168, 133, 143, 133, 178, 183] +R06 cleared 57.9s buildings [9, 9] lost [] gold [163, 173, 173, 173, 153, 173] +R07 cleared 58.3s buildings [9, 9] lost [] gold [180, 185, 225, 185, 205, 185] +R08 cleared 61.9s buildings [8, 9] lost ["blau_bot_outer"] gold [200, 200, 155, 250, 255, 250] +R09 cleared 71.3s buildings [8, 9] lost [] gold [213, 218, 188, 198, 168, 198] +R10 cleared 74.2s buildings [7, 9] lost ["blau_bot_inner"] gold [215, 250, 215, 300, 265, 300] +R11 cleared 83.7s buildings [5, 9] lost ["blau_top_outer", "blau_base_2"] gold [228, 218, 218, 348, 358, 413] +R12 core 86.9s buildings [1, 9] lost ["blau_mid_outer", "blau_top_inner", "blau_base_1", "blau_ancient"] gold [101, 106, 133, 348, 318, 323] +SIM_MATCH seed=3 Sieg Rot after 12 rounds (Ancient von Blau zerstört.) in 9910 ms +R01 cleared 44.6s buildings [9, 9] lost [] gold [128, 123, 173, 123, 128, 123] +R02 cleared 46.5s buildings [9, 9] lost [] gold [135, 140, 105, 115, 125, 125] +R03 cleared 55.1s buildings [9, 9] lost [] gold [175, 160, 170, 150, 125, 160] +R04 cleared 63.1s buildings [9, 9] lost [] gold [137, 182, 142, 132, 132, 122] +R05 cleared 62.3s buildings [9, 9] lost [] gold [133, 178, 183, 163, 173, 153] +R06 cleared 59.7s buildings [9, 9] lost [] gold [173, 153, 173, 133, 213, 148] +R07 cleared 72.1s buildings [9, 8] lost ["rot_bot_outer"] gold [235, 255, 235, 160, 190, 165] +R08 cleared 62.5s buildings [8, 8] lost ["blau_top_outer"] gold [180, 180, 180, 205, 210, 215] +R09 cleared 74.8s buildings [8, 7] lost ["rot_bot_inner"] gold [268, 263, 268, 238, 223, 178] +R10 cleared 80.3s buildings [8, 7] lost [] gold [235, 205, 235, 210, 265, 185] +R11 core 117.5s buildings [7, 4] lost ["blau_mid_outer", "rot_base_2", "rot_base_1", "rot_ancient"] gold [288, 348, 303, 223, 178, 178] +SIM_MATCH seed=4 Sieg Blau after 11 rounds (Ancient von Rot zerstört.) in 9299 ms +R01 cleared 42.1s buildings [9, 9] lost [] gold [123, 128, 123, 128, 173, 128] +R02 cleared 50.3s buildings [9, 9] lost [] gold [115, 125, 125, 150, 105, 125] +R03 cleared 55.1s buildings [9, 9] lost [] gold [150, 125, 160, 175, 165, 165] +R04 cleared 66.9s buildings [9, 9] lost [] gold [132, 132, 122, 207, 137, 157] +R05 cleared 60.3s buildings [9, 9] lost [] gold [163, 173, 153, 198, 168, 133] +R06 cleared 61.9s buildings [9, 9] lost [] gold [133, 213, 148, 178, 163, 173] +R07 cleared 87.3s buildings [9, 8] lost ["rot_top_outer"] gold [210, 240, 215, 180, 180, 185] +R08 cleared 89.3s buildings [9, 8] lost [] gold [205, 210, 165, 165, 200, 200] +R09 cleared 80.3s buildings [8, 7] lost ["blau_bot_outer", "rot_top_inner"] gold [308, 293, 303, 218, 263, 268] +R10 cleared 84.1s buildings [8, 7] lost [] gold [220, 195, 195, 185, 190, 185] +R11 cleared 84.8s buildings [7, 6] lost ["blau_bot_inner", "rot_base_1"] gold [363, 343, 358, 323, 313, 308] +R12 cleared 83.7s buildings [7, 6] lost [] gold [267, 237, 237, 232, 272, 232] +R13 cleared 95.7s buildings [6, 5] lost ["rot_mid_outer", "blau_base_2"] gold [303, 338, 333, 388, 393, 393] +R14 cleared 82.1s buildings [6, 5] lost [] gold [390, 295, 305, 310, 320, 375] +R15 core 130.3s buildings [6, 3] lost ["rot_base_2", "rot_ancient"] gold [411, 511, 291, 216, 311, 351] +SIM_MATCH seed=5 Sieg Blau after 15 rounds (Ancient von Rot zerstört.) in 14879 ms +R01 cleared 48.3s buildings [9, 9] lost [] gold [128, 173, 128, 123, 128, 123] +R02 cleared 52.5s buildings [9, 9] lost [] gold [150, 105, 125, 115, 135, 140] +R03 cleared 60.7s buildings [9, 9] lost [] gold [175, 165, 165, 115, 175, 160] +R04 cleared 64.5s buildings [9, 9] lost [] gold [207, 137, 157, 187, 137, 182] +R05 cleared 59.1s buildings [9, 9] lost [] gold [198, 168, 133, 143, 133, 178] +R06 cleared 58.5s buildings [9, 8] lost ["rot_mid_outer"] gold [228, 213, 223, 173, 173, 153] +R07 cleared 72.7s buildings [8, 8] lost ["blau_top_outer"] gold [230, 155, 165, 275, 235, 255] +R08 cleared 86.1s buildings [7, 7] lost ["rot_mid_inner", "blau_top_inner"] gold [285, 245, 250, 230, 250, 250] +R09 cleared 72.4s buildings [7, 6] lost ["rot_bot_outer"] gold [218, 248, 273, 243, 203, 203] +R10 cleared 74.8s buildings [7, 6] lost [] gold [195, 220, 215, 270, 245, 250] +R11 cleared 92.9s buildings [7, 5] lost ["rot_bot_inner"] gold [288, 343, 278, 283, 218, 278] +R12 core 89.1s buildings [7, 2] lost ["rot_base_2", "rot_base_1", "rot_ancient"] gold [328, 288, 348, 108, 163, 87] +SIM_MATCH seed=6 Sieg Blau after 12 rounds (Ancient von Rot zerstört.) in 10244 ms +R01 cleared 41.3s buildings [9, 9] lost [] gold [123, 128, 123, 173, 123, 128] +R02 cleared 51.5s buildings [9, 9] lost [] gold [115, 135, 140, 105, 115, 125] +R03 cleared 52.5s buildings [9, 9] lost [] gold [115, 175, 160, 170, 150, 125] +R04 cleared 57.7s buildings [9, 9] lost [] gold [187, 137, 182, 142, 132, 132] +R05 cleared 57.5s buildings [9, 9] lost [] gold [143, 133, 178, 183, 163, 173] +R06 cleared 61.7s buildings [9, 9] lost [] gold [173, 173, 153, 173, 133, 213] +R07 cleared 61.1s buildings [9, 9] lost [] gold [225, 185, 205, 185, 160, 190] +R08 cleared 57.5s buildings [9, 9] lost [] gold [155, 200, 205, 200, 155, 160] +R09 cleared 72.9s buildings [9, 9] lost [] gold [188, 218, 193, 218, 188, 173] +R10 cleared 72.1s buildings [8, 9] lost ["blau_bot_outer"] gold [215, 250, 240, 280, 285, 265] +R11 cleared 84.4s buildings [5, 9] lost ["blau_mid_outer", "blau_top_outer", "blau_bot_inner"] gold [218, 218, 238, 398, 453, 408] +R12 core 98.9s buildings [0, 9] lost ["blau_mid_inner", "blau_top_inner", "blau_base_2", "blau_base_1", "blau_ancient"] gold [156, 101, 123, 463, 563, 528] +SIM_MATCH seed=7 Sieg Rot after 12 rounds (Ancient von Blau zerstört.) in 10269 ms +R01 cleared 41.9s buildings [9, 9] lost [] gold [173, 123, 128, 123, 128, 173] +R02 cleared 47.5s buildings [9, 9] lost [] gold [105, 115, 125, 125, 150, 105] +R03 cleared 55.5s buildings [9, 9] lost [] gold [170, 150, 125, 160, 175, 165] +R04 cleared 55.1s buildings [9, 9] lost [] gold [142, 132, 132, 122, 207, 137] +R05 cleared 54.5s buildings [9, 9] lost [] gold [183, 163, 173, 153, 198, 168] +R06 cleared 57.7s buildings [9, 9] lost [] gold [173, 133, 213, 148, 178, 163] +R07 cleared 58.7s buildings [9, 9] lost [] gold [185, 160, 190, 165, 180, 180] +R08 cleared 54.9s buildings [9, 9] lost [] gold [200, 155, 160, 165, 165, 200] +R09 cleared 66.5s buildings [9, 8] lost ["rot_top_outer"] gold [268, 238, 223, 178, 168, 213] +R10 cleared 87.5s buildings [9, 7] lost ["rot_top_inner"] gold [280, 280, 335, 210, 200, 215] +R11 cleared 87.7s buildings [9, 6] lost ["rot_bot_outer"] gold [323, 403, 303, 238, 218, 228] +R12 core 103.0s buildings [9, 1] lost ["rot_base_1", "rot_mid_outer", "rot_bot_inner", "rot_base_2", "rot_ancient"] gold [418, 493, 458, 143, 113, 118] +SIM_MATCH seed=8 Sieg Blau after 12 rounds (Ancient von Rot zerstört.) in 10274 ms +WARNING: 6 ObjectDB instances were leaked at exit (run with `--verbose` for details). + at: cleanup (core/object/object.cpp:2536) +ERROR: 1 resources still in use at exit (run with --verbose for details). + at: clear (core/io/resource.cpp:822) +EXIT 0 diff --git a/docs/luneraria/logs/m1/verify-m1.log b/docs/luneraria/logs/m1/verify-m1.log new file mode 100644 index 0000000..3555496 --- /dev/null +++ b/docs/luneraria/logs/m1/verify-m1.log @@ -0,0 +1,15 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org +Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 5060 Ti + +M1_MENU_READY_MS 733 +M1_SHOT planning-1280x720 +M1_SHOT planning-1920x1080 +M1_SHOT combat-mid-live +M1_SHOT settlement +M1_SHOT combat-top +M1_SHOT overview-combat +M1_SHOT core-siege +M1_SHOT result +M1_MATCH_RESULT Ancient von Rot zerstört. after 10 waves +M1_VERIFY_PASS: 38 checks; 0 failures +EXIT 0 diff --git a/docs/luneraria/logs/perf-1280x720.json b/docs/luneraria/logs/perf-1280x720.json new file mode 100644 index 0000000..4a297d5 --- /dev/null +++ b/docs/luneraria/logs/perf-1280x720.json @@ -0,0 +1,20 @@ +{ + "buildings": 18, + "cpu": "AMD Ryzen 5 5600X 6-Core Processor", + "draw_calls_mean": 3175.86281588448, + "engine": "4.7.2-stable (fedora)", + "frame_ms_mean": 7.22646841155234, + "frame_ms_p95": 10.294, + "frames": 1108, + "gpu": "NVIDIA GeForce RTX 5060 Ti", + "gpu_ms_mean": 2.09521750809914, + "mobile_units_at_start": 114, + "mobile_units_mean_measured": 108.60559566787, + "renderer": "forward_plus", + "scene_build_ms": 198.002, + "sim_speed": "2x (40 steps/s)", + "sim_step_ms": 2.37125, + "static_mem_mb": 120.703545570374, + "video_mem_mb": 658.710235595703, + "window": "(1280, 720)" +} \ No newline at end of file diff --git a/docs/luneraria/logs/perf-1920x1080.json b/docs/luneraria/logs/perf-1920x1080.json new file mode 100644 index 0000000..97d04d1 --- /dev/null +++ b/docs/luneraria/logs/perf-1920x1080.json @@ -0,0 +1,20 @@ +{ + "buildings": 18, + "cpu": "AMD Ryzen 5 5600X 6-Core Processor", + "draw_calls_mean": 3172.21948912015, + "engine": "4.7.2-stable (fedora)", + "frame_ms_mean": 7.56970955534533, + "frame_ms_p95": 10.505, + "frames": 1057, + "gpu": "NVIDIA GeForce RTX 5060 Ti", + "gpu_ms_mean": 3.06410596431599, + "mobile_units_at_start": 114, + "mobile_units_mean_measured": 108.593188268685, + "renderer": "forward_plus", + "scene_build_ms": 221.213, + "sim_speed": "2x (40 steps/s)", + "sim_step_ms": 2.403405, + "static_mem_mb": 120.915734291077, + "video_mem_mb": 765.283081054688, + "window": "(1920, 1080)" +} \ No newline at end of file diff --git a/docs/luneraria/screenshots/combat-mid-live.png b/docs/luneraria/screenshots/combat-mid-live.png new file mode 100644 index 0000000..fcf16e8 Binary files /dev/null and b/docs/luneraria/screenshots/combat-mid-live.png differ diff --git a/docs/luneraria/screenshots/combat-mid.png b/docs/luneraria/screenshots/combat-mid.png new file mode 100644 index 0000000..99f88ac Binary files /dev/null and b/docs/luneraria/screenshots/combat-mid.png differ diff --git a/docs/luneraria/screenshots/combat-top.png b/docs/luneraria/screenshots/combat-top.png new file mode 100644 index 0000000..3ec0364 Binary files /dev/null and b/docs/luneraria/screenshots/combat-top.png differ diff --git a/docs/luneraria/screenshots/core-siege.png b/docs/luneraria/screenshots/core-siege.png new file mode 100644 index 0000000..cd849ab Binary files /dev/null and b/docs/luneraria/screenshots/core-siege.png differ diff --git a/docs/luneraria/screenshots/menu-setup.png b/docs/luneraria/screenshots/menu-setup.png new file mode 100644 index 0000000..351ac9e Binary files /dev/null and b/docs/luneraria/screenshots/menu-setup.png differ diff --git a/docs/luneraria/screenshots/menu-title.png b/docs/luneraria/screenshots/menu-title.png new file mode 100644 index 0000000..fbd0a6f Binary files /dev/null and b/docs/luneraria/screenshots/menu-title.png differ diff --git a/docs/luneraria/screenshots/overview-combat.png b/docs/luneraria/screenshots/overview-combat.png new file mode 100644 index 0000000..6411079 Binary files /dev/null and b/docs/luneraria/screenshots/overview-combat.png differ diff --git a/docs/luneraria/screenshots/overview-planning.png b/docs/luneraria/screenshots/overview-planning.png new file mode 100644 index 0000000..4ca13b2 Binary files /dev/null and b/docs/luneraria/screenshots/overview-planning.png differ diff --git a/docs/luneraria/screenshots/planning-1280x720.png b/docs/luneraria/screenshots/planning-1280x720.png new file mode 100644 index 0000000..a7874cb Binary files /dev/null and b/docs/luneraria/screenshots/planning-1280x720.png differ diff --git a/docs/luneraria/screenshots/planning-1920x1080.png b/docs/luneraria/screenshots/planning-1920x1080.png new file mode 100644 index 0000000..f6ac4f3 Binary files /dev/null and b/docs/luneraria/screenshots/planning-1920x1080.png differ diff --git a/docs/luneraria/screenshots/planning.png b/docs/luneraria/screenshots/planning.png new file mode 100644 index 0000000..d438a5c Binary files /dev/null and b/docs/luneraria/screenshots/planning.png differ diff --git a/docs/luneraria/screenshots/result.png b/docs/luneraria/screenshots/result.png new file mode 100644 index 0000000..dd91f05 Binary files /dev/null and b/docs/luneraria/screenshots/result.png differ diff --git a/docs/luneraria/screenshots/settlement.png b/docs/luneraria/screenshots/settlement.png new file mode 100644 index 0000000..b69e866 Binary files /dev/null and b/docs/luneraria/screenshots/settlement.png differ diff --git a/docs/phase20-menu-verification.json b/docs/phase20-menu-verification.json index 1f619b8..d6c4ef4 100644 --- a/docs/phase20-menu-verification.json +++ b/docs/phase20-menu-verification.json @@ -2,7 +2,7 @@ "checks": 68, "engine": "4.7.2-stable (fedora)", "failures": [], - "formations_ready_ms": 895, - "menu_instantiate_ms": 515, + "formations_ready_ms": 1089, + "menu_instantiate_ms": 571, "renderer": "forward_plus" } \ No newline at end of file diff --git a/project.godot b/project.godot index 246567e..a0a052b 100644 --- a/project.godot +++ b/project.godot @@ -14,10 +14,10 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] -config/name="Saumwacht · Glutpakt" -config/description="Glutpakt und Wurzelbund · 20 Wellen gegen die Rivalen-KI" +config/name="Champions of Luneraria" +config/description="3 gegen 3 auf drei Lanes · Autobattler · Partie endet mit dem Fall eines Ancients" config/version="0.15.0" -run/main_scene="res://scenes/main.tscn" +run/main_scene="res://scenes/luneraria.tscn" config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://assets/icons/resources/res_core.png" diff --git a/scenes/luneraria.tscn b/scenes/luneraria.tscn new file mode 100644 index 0000000..e83b59e --- /dev/null +++ b/scenes/luneraria.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/luneraria/lune_main.gd" id="1"] + +[node name="Luneraria" type="Node"] +script = ExtResource("1") diff --git a/scripts/autoload/preferences.gd b/scripts/autoload/preferences.gd index 6307d19..613e43e 100644 --- a/scripts/autoload/preferences.gd +++ b/scripts/autoload/preferences.gd @@ -14,8 +14,22 @@ func _ready() -> void: read_settings() apply() +## The project was renamed from "Saumwacht · Glutpakt"; its user folder changed with it. +const LEGACY_FOLDER := "Saumwacht · Glutpakt" +var migrated_from: String = "" + +func _legacy_path() -> String: + var own: String = ProjectSettings.globalize_path("user://") + var parent: String = own.trim_suffix("/").get_base_dir() + return parent.path_join(LEGACY_FOLDER).path_join("preferences.cfg") + func read_settings() -> void: var config := ConfigFile.new() + if storage_path == "user://preferences.cfg" and not FileAccess.file_exists(storage_path) and FileAccess.file_exists(_legacy_path()): + # One-time copy of the old options; the old file stays untouched. + if config.load(_legacy_path()) == OK and config.save(storage_path) == OK: + migrated_from = _legacy_path() + print("PREFERENCES: übernommen aus ", migrated_from) if config.load(storage_path) != OK: return volume = clampf(float(config.get_value("audio", "volume", .65)), 0.0, 1.0) diff --git a/scripts/luneraria/core/lune_bot.gd b/scripts/luneraria/core/lune_bot.gd index ba39285..6d2371a 100644 --- a/scripts/luneraria/core/lune_bot.gd +++ b/scripts/luneraria/core/lune_bot.gd @@ -57,14 +57,16 @@ static func _needed_role(match_state: LuneMatch, roster: Array, profile: Diction static func _try_upgrade(match_state: LuneMatch, slot: int, roster: Array) -> bool: var best_uid: String = "" + var best_seq: int = 1 << 30 var best_cost: int = 1 << 30 for entry: Dictionary in roster: var upgrade: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", "")) if upgrade.is_empty(): continue var cost: int = int(DataCatalog.get_unit(upgrade).cost) - if cost < best_cost or (cost == best_cost and str(entry.uid) < best_uid): + if cost < best_cost or (cost == best_cost and int(entry.seq) < best_seq): best_cost = cost + best_seq = int(entry.seq) best_uid = str(entry.uid) if best_uid.is_empty(): return false diff --git a/scripts/luneraria/core/lune_combat.gd b/scripts/luneraria/core/lune_combat.gd index ed86487..648d7b5 100644 --- a/scripts/luneraria/core/lune_combat.gd +++ b/scripts/luneraria/core/lune_combat.gd @@ -17,12 +17,30 @@ var end_reason: String = "" var destroyed_cores: Array[int] = [] var events: Array[Dictionary] = [] var _next_id: int = 1 +var _team_count: Array[int] = [0, 0] +## Units without a target look for one every RETARGET steps, staggered by team_index. +const RETARGET: int = 4 var _event_serial: int = 0 var _pending_damage: Dictionary = {} var _pending_heal: Dictionary = {} var _pending_effects: Array = [] var _pending_dots: Array = [] var _pending_shields: Array = [] +# Per-step caches: building gates, march limits and a spatial grid of mobile units. +var _attackable: Dictionary = {} +var _limits: Dictionary = {} +var _grid: Dictionary = {} +const CELL: float = 4.0 +## Optional section timings (microseconds) for performance work; off by default. +var profiling: bool = false +var profile: Dictionary = {} +var _mark: int = 0 + +func _lap(section: String) -> void: + if profiling: + var now: int = Time.get_ticks_usec() + profile[section] = int(profile.get(section, 0)) + now - _mark + _mark = now func _init(match_map: LuneMap, config: LuneBalance, number: int) -> void: map = match_map @@ -38,6 +56,8 @@ func add_building(definition: Dictionary, hp: float) -> LuneUnit: unit.team = int(definition.team) unit.lane = str(definition.lane) unit.building_id = str(definition.id) + unit.team_index = _team_count[unit.team] + _team_count[unit.team] += 1 unit.def = {"id": definition.id, "display_name": definition.id, "max_hp": definition.max_hp, "damage": definition.damage, "attack_range": definition.range, "attack_type": 1, "armor_type": 1, "speed": 0.0, "ability": "", "bounty": definition.bounty, "tier": definition.tier} @@ -60,6 +80,8 @@ func spawn(kind: String, team: int, lane: String, definition: Dictionary, progre unit.def = definition unit.owner_slot = slot unit.persistent_uid = uid + unit.team_index = _team_count[team] + _team_count[team] += 1 unit.progress = progress unit.lateral = lateral unit.position = map.lane_point(team, lane, progress, lateral) @@ -79,7 +101,51 @@ func alive_map() -> Dictionary: return result func building_attackable(unit: LuneUnit, gates: Dictionary) -> bool: - return unit.alive and LuneMap.is_attackable(map.building_def(unit.building_id), gates) + if not unit.alive: + return false + if _attackable.has(unit.building_id): + return _attackable[unit.building_id] + return LuneMap.is_attackable(map.building_def(unit.building_id), gates) + +func _prepare_caches(living: Array[LuneUnit], gates: Dictionary) -> void: + _attackable.clear() + for id: String in buildings: + _attackable[id] = LuneMap.is_attackable(map.building_def(id), gates) + _limits.clear() + for team: int in range(2): + for lane: String in LuneMap.LANES: + var path: LuneMap.LanePath = map.lanes[lane] + var limit: float = path.length + for id: String in buildings: + var building: LuneUnit = buildings[id] + if building.alive and building.kind == "tower" and building.team != team and building.lane == lane: + limit = minf(limit, path.length - float(map.building_def(id).progress) - 1.5) + _limits[team * 3 + LuneMap.LANES.find(lane)] = limit + _grid = {0: {}, 1: {}} + for unit: LuneUnit in living: + if unit.is_mobile(): + var cells: Dictionary = _grid[unit.team] + var key := Vector2i(floori(unit.position.x / CELL), floori(unit.position.y / CELL)) + if not cells.has(key): + cells[key] = [] + (cells[key] as Array).append(unit) + +## Mobile units of one team (or both with team = -1) near a point; the caller checks distance. +func _near_mobile(center: Vector2, radius: float, team: int = -1) -> Array: + var result: Array = [] + var reach: int = ceili(radius / CELL) + var cx: int = floori(center.x / CELL) + var cy: int = floori(center.y / CELL) + for side: int in ([0, 1] if team < 0 else [team]): + var cells: Dictionary = _grid[side] + if cells.is_empty(): + continue + for dx: int in range(-reach, reach + 1): + for dy: int in range(-reach, reach + 1): + var bucket: Variant = cells.get(Vector2i(cx + dx, cy + dy)) + if bucket != null: + result.append_array(bucket) + return result # ---------------------------------------------------------------- step func step() -> void: @@ -93,26 +159,35 @@ func step() -> void: _pending_effects.clear() _pending_dots.clear() _pending_shields.clear() + _mark = Time.get_ticks_usec() var gates: Dictionary = alive_map() var living: Array[LuneUnit] = [] for unit: LuneUnit in units: if unit.alive: living.append(unit) unit.previous_position = unit.position + _prepare_caches(living, gates) + _lap("caches") for unit: LuneUnit in living: var dot: float = unit.advance_status(dt) if dot > 0.0: _queue_damage(unit, dot, gates) + _lap("status") for unit: LuneUnit in living: if unit.is_mobile(): _ability_step(unit, living) + _lap("abilities") for unit: LuneUnit in living: if unit.is_mobile() or (unit.kind == "tower"): - unit.target = _choose_target(unit, living, gates) + var keep: bool = unit.target != null and unit.target.alive + if keep or (steps + unit.team_index) % RETARGET == 0: + unit.target = _choose_target(unit, living, gates) + _lap("targets") var moves: Dictionary = {} for unit: LuneUnit in living: if unit.is_mobile(): moves[unit.id] = _plan_move(unit, living, dt) + _lap("plan_move") for unit: LuneUnit in living: if moves.has(unit.id): var plan: Array = moves[unit.id] @@ -123,8 +198,10 @@ func step() -> void: unit.facing = atan2(heading.x, heading.y) if unit.zone == "lane": _update_progress(unit, float(plan[1])) + _lap("apply_move") for unit: LuneUnit in living: _attack(unit, living, gates, dt) + _lap("attacks") for unit: LuneUnit in living: if _pending_heal.has(unit.id): unit.heal(_pending_heal[unit.id]) @@ -143,6 +220,7 @@ func step() -> void: unit.death_time = elapsed unit.target = null _record_death(unit) + _lap("resolve") _check_end() func _check_end() -> void: @@ -189,8 +267,8 @@ func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary) var radius: float = unit.attack_range() if unit.kind == "tower" else balance.aggro_radius var best: LuneUnit = null var best_distance: float = INF - for other: LuneUnit in living: - if not _hostile(unit, other) or not other.is_mobile(): + for other: LuneUnit in _near_mobile(unit.position, radius, 1 - unit.team): + if not _hostile(unit, other): continue var d: float = unit.position.distance_to(other.position) if d <= radius and (d < best_distance - 0.0001 or (absf(d - best_distance) <= 0.0001 and other.id < best.id)): @@ -199,8 +277,9 @@ func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary) if best != null or unit.kind == "tower": return best # Buildings: nearby attackable ones; at the end of a lane the whole base is in reach. - for other: LuneUnit in living: - if not other.is_building() or not _hostile(unit, other) or not building_attackable(other, gates): + for id: String in buildings: + var other: LuneUnit = buildings[id] + if not _hostile(unit, other) or not building_attackable(other, gates): continue var d: float = unit.position.distance_to(other.position) - balance.building_radius if (d <= radius or unit.reached_base) and d < best_distance: @@ -209,14 +288,15 @@ func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary) return best func _march_limit(unit: LuneUnit) -> float: + var key: int = unit.team * 3 + LuneMap.LANES.find(unit.lane) + if _limits.has(key): + return _limits[key] var path: LuneMap.LanePath = map.lanes[unit.lane] var limit: float = path.length for id: String in buildings: var building: LuneUnit = buildings[id] if building.alive and building.kind == "tower" and building.team != unit.team and building.lane == unit.lane: - var definition: Dictionary = map.building_def(id) - var mine: float = path.length - float(definition.progress) - limit = minf(limit, mine - 1.5) + limit = minf(limit, path.length - float(map.building_def(id).progress) - 1.5) return limit ## Returns [next position, marching progress]. Marching advances the lane parameter @@ -235,7 +315,8 @@ func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array: elif unit.zone == "lane": var limit: float = _march_limit(unit) var anchor: Vector2 = map.lane_point(unit.team, unit.lane, unit.progress, unit.lateral) - if unit.position.distance_to(anchor) > 1.2: + var off: float = unit.position.distance_to(anchor) + if off > 1.2: # Rejoin the formation line after a chase before advancing again. goal = anchor travel = minf(step_length, unit.position.distance_to(anchor)) @@ -243,7 +324,7 @@ func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array: marched = minf(unit.progress + step_length, limit) goal = map.lane_point(unit.team, unit.lane, marched, unit.lateral) travel = unit.position.distance_to(goal) - elif unit.position.distance_to(anchor) > 0.05: + elif off > 0.05: goal = anchor travel = minf(step_length, unit.position.distance_to(anchor)) var next: Vector2 = unit.position @@ -251,14 +332,17 @@ func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array: next += (goal - unit.position).normalized() * minf(travel, unit.position.distance_to(goal)) # Separation from allies so bodies do not stack; opponents may still close to melee range. var push := Vector2.ZERO - for other: LuneUnit in living: - if other == unit or not other.is_mobile() or other.team != unit.team or other.zone != unit.zone: + for other: LuneUnit in _near_mobile(unit.position, 1.1, unit.team): + if other == unit or other.zone != unit.zone: continue var offset: Vector2 = unit.position - other.position var distance: float = offset.length() if distance < 1.1: if distance < 0.0001: + # Mirrored teams sidestep in mirrored directions. offset = Vector2.RIGHT if unit.id < other.id else Vector2.LEFT + if unit.team == 1: + offset = LuneMap.mirror(offset) distance = 0.001 push += offset.normalized() * (1.1 - distance) * 2.0 next += push.limit_length(0.9) * dt @@ -331,7 +415,12 @@ func _start_ability(unit: LuneUnit) -> void: func _nearby(unit: LuneUnit, living: Array[LuneUnit], allied: bool, center: Vector2, radius: float, maximum: int, excluded: LuneUnit = null, health_priority: bool = false) -> Array[LuneUnit]: var result: Array[LuneUnit] = [] - for other: LuneUnit in living: + # Buildings can be splash victims; they are few, so check them directly. + var pool: Array = _near_mobile(center, radius, unit.team if allied else 1 - unit.team) + if not allied: + for id: String in buildings: + pool.append(buildings[id]) + for other: LuneUnit in pool: if not other.alive or other == excluded or other.zone != unit.zone: continue if (other.team == unit.team) != allied: diff --git a/scripts/luneraria/core/lune_map.gd b/scripts/luneraria/core/lune_map.gd index 2225624..1c87958 100644 --- a/scripts/luneraria/core/lune_map.gd +++ b/scripts/luneraria/core/lune_map.gd @@ -48,6 +48,17 @@ class LanePath: return (points[i] - points[i - 1]).normalized() return Vector2.RIGHT + ## Point and unit tangent at s in one pass. + func sample(s: float) -> Array: + var d: float = clampf(s, 0.0, length) + for i: int in range(1, points.size()): + if d <= cumulative[i] or i == points.size() - 1: + var a: Vector2 = points[i - 1] + var b: Vector2 = points[i] + var span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1]) + return [a.lerp(b, clampf((d - cumulative[i - 1]) / span, 0.0, 1.0)), (b - a) / span] + return [points[points.size() - 1], Vector2.RIGHT] + func project(position: Vector2) -> float: var best: float = INF var best_s: float = 0.0 @@ -96,10 +107,10 @@ func progress_for(team: int, lane: String, s: float) -> float: ## Blau frame point, mirrored for Rot so both sides are exact images. func lane_point(team: int, lane: String, progress: float, lateral: float = 0.0) -> Vector2: var path: LanePath = lanes[lane] - var s: float = clampf(progress, 0.0, path.length) - var tangent: Vector2 = path.tangent_at(s) - var blue: Vector2 = path.point_at(s) + Vector2(-tangent.y, tangent.x) * lateral - return blue if team == 0 else mirror(blue) + var sampled: Array = path.sample(progress) + var tangent: Vector2 = sampled[1] + var blue: Vector2 = sampled[0] + Vector2(-tangent.y, tangent.x) * lateral + return blue if team == 0 else Vector2(blue.y, blue.x) func lane_forward(team: int, lane: String, progress: float) -> Vector2: var path: LanePath = lanes[lane] @@ -131,11 +142,13 @@ func _build_buildings() -> void: buildings.append({"id": "%s_ancient" % ("blau" if team == 0 else "rot"), "team": team, "lane": "base", "tier": "core", "progress": -1.0, "position": core_position(team), "max_hp": balance.core_hp, "damage": 0.0, "range": 0.0, "bounty": 0}) +var _index: Dictionary = {} + func building_def(id: String) -> Dictionary: - for entry: Dictionary in buildings: - if entry.id == id: - return entry - return {} + if _index.is_empty(): + for entry: Dictionary in buildings: + _index[entry.id] = entry + return _index.get(id, {}) ## Gate rule shared by every damage source. alive maps building id -> bool. static func is_attackable(definition: Dictionary, alive: Dictionary) -> bool: diff --git a/scripts/luneraria/core/lune_match.gd b/scripts/luneraria/core/lune_match.gd index 1fdbb6d..fac20c8 100644 --- a/scripts/luneraria/core/lune_match.gd +++ b/scripts/luneraria/core/lune_match.gd @@ -25,8 +25,8 @@ var building_hp: Dictionary = {} var combat: LuneCombat var last_settlement: Dictionary = {} var history: Array[Dictionary] = [] -var _next_uid: int = 1 -var _reward_rotation: int = 0 +## Remainder rotation per team so both teams follow the same distribution pattern. +var _reward_rotation: Array[int] = [0, 0] func _init(config: LuneBalance = null) -> void: balance = config if config != null else LuneBalance.default_balance() @@ -42,8 +42,7 @@ func setup(options: Dictionary = {}) -> void: end_note = "" history.clear() last_settlement = {} - _next_uid = 1 - _reward_rotation = 0 + _reward_rotation = [0, 0] players.clear() var human: int = int(options.get("human_slot", 1)) var bunds: Array = options.get("bunds", []) @@ -51,7 +50,7 @@ func setup(options: Dictionary = {}) -> void: var bund: String = str(bunds[slot]) if slot < bunds.size() else ("glutpakt" if (slot + seed_value) % 2 == 0 else "wurzelbund") var player := {"slot": slot, "team": slot / 3, "lane": LuneMap.LANES[slot % 3], "is_human": slot == human, "name": "Du" if slot == human else BOT_NAMES[(slot + seed_value) % BOT_NAMES.size()], "bund": bund, - "gold": balance.start_gold, "roster": [], "ready": slot != human, "earned": 0, "spent": 0} + "gold": balance.start_gold, "roster": [], "ready": slot != human, "earned": 0, "spent": 0, "next_seq": 1} player.draft = _snapshot(player) player.undo = [] players.append(player) @@ -60,7 +59,7 @@ func setup(options: Dictionary = {}) -> void: building_hp[definition.id] = float(definition.max_hp) func _snapshot(player: Dictionary) -> Dictionary: - return {"gold": int(player.gold), "roster": (player.roster as Array).duplicate(true)} + return {"gold": int(player.gold), "roster": (player.roster as Array).duplicate(true), "next_seq": int(player.next_seq)} func slot_of(team: int, lane: String) -> int: return team * 3 + LuneMap.LANES.find(lane) @@ -111,8 +110,10 @@ func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dicti if not LuneMap.LANES.has(lane): return _result(false, "Unbekannte Lane.") draft.gold = int(draft.gold) - cost - roster.append({"uid": "p%d-%d" % [player.slot, _next_uid], "type_id": type_id, "lane": lane, "invested": cost, "fresh": true}) - _next_uid += 1 + # Per-player numbering keeps both teams' ids in the same order; seq sorts numerically. + var seq: int = int(draft.get("next_seq", player.next_seq)) + draft.next_seq = seq + 1 + roster.append({"uid": "p%d-%d" % [player.slot, seq], "seq": seq, "type_id": type_id, "lane": lane, "invested": cost, "fresh": true}) return _result(true, "%s gekauft." % DataCatalog.get_unit(type_id).display_name) "upgrade": var index: int = _find(roster, str(command.get("uid", ""))) @@ -172,6 +173,7 @@ func begin_combat() -> bool: for player: Dictionary in players: player.spent = int(player.spent) + maxi(0, int(player.gold) - int(player.draft.gold)) player.gold = int(player.draft.gold) + player.next_seq = int(player.draft.get("next_seq", player.next_seq)) player.roster = (player.draft.roster as Array).duplicate(true) for entry: Dictionary in player.roster: entry.erase("fresh") @@ -220,7 +222,7 @@ func _spawn_armies() -> void: entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: var ra: int = ROW_ORDER.find(role_of(a.type_id)) var rb: int = ROW_ORDER.find(role_of(b.type_id)) - return str(a.uid) < str(b.uid) if ra == rb else ra < rb) + return int(a.seq) < int(b.seq) if ra == rb else ra < rb) for entry: Dictionary in entries: ordered.append([slot, entry]) for index: int in range(ordered.size()): @@ -301,8 +303,8 @@ func _split(gains: Array[int], team: int, amount: int) -> void: for offset: int in range(3): gains[team * 3 + offset] += share for index: int in range(rest): - gains[team * 3 + (_reward_rotation + index) % 3] += 1 - _reward_rotation = (_reward_rotation + rest) % 3 + gains[team * 3 + (_reward_rotation[team] + index) % 3] += 1 + _reward_rotation[team] = (_reward_rotation[team] + rest) % 3 func _alive_count() -> Array[int]: var counts: Array[int] = [0, 0] @@ -314,9 +316,16 @@ func _alive_count() -> Array[int]: func building_alive(id: String) -> bool: return float(building_hp.get(id, 0.0)) > 0.0 -func towers_alive(team: int, lane: String) -> int: +## Live state for displays: during combat the combat instance is authoritative. +func building_alive_now(id: String) -> bool: + if phase == Phase.COMBAT and combat != null and combat.buildings.has(id): + return (combat.buildings[id] as LuneUnit).alive + return building_alive(id) + +func towers_alive(team: int, lane: String, live: bool = false) -> int: var prefix: String = "blau" if team == 0 else "rot" - return int(building_alive("%s_%s_outer" % [prefix, lane])) + int(building_alive("%s_%s_inner" % [prefix, lane])) + var probe: Callable = building_alive_now if live else building_alive + return int(probe.call("%s_%s_outer" % [prefix, lane])) + int(probe.call("%s_%s_inner" % [prefix, lane])) func core_hp(team: int) -> float: return float(building_hp["blau_ancient" if team == 0 else "rot_ancient"]) diff --git a/scripts/luneraria/core/lune_unit.gd b/scripts/luneraria/core/lune_unit.gd index f792ebb..0a61541 100644 --- a/scripts/luneraria/core/lune_unit.gd +++ b/scripts/luneraria/core/lune_unit.gd @@ -7,6 +7,8 @@ const DAMAGE_MATRIX = [[1.30, 0.75, 1.00], [1.00, 1.30, 0.75], [0.75, 1.00, 1.30 const MOBILE_KINDS: Array[String] = ["troop", "hero", "monster", "champion", "boss"] var id: int = 0 +## Spawn index within the own team; mirrored units share it (used for fair staggering). +var team_index: int = 0 var kind: String = "troop" var team: int = 0 var owner_slot: int = -1 diff --git a/scripts/luneraria/lune_game.gd b/scripts/luneraria/lune_game.gd new file mode 100644 index 0000000..e35c626 --- /dev/null +++ b/scripts/luneraria/lune_game.gd @@ -0,0 +1,224 @@ +extends Node3D +## One Champions of Luneraria match on screen: authoritative LuneMatch, the 3D world, +## unit views and the HUD. Offline, planning waits for the human; pause and speed are local. + +signal exit_requested +signal rematch_requested + +const World = preload("res://scripts/luneraria/view/lune_world.gd") +const UnitView = preload("res://scripts/luneraria/view/lune_unit_view.gd") +const CameraRig = preload("res://scripts/luneraria/view/lune_camera.gd") +const Hud = preload("res://scripts/luneraria/ui/lune_hud.gd") +const SPEEDS: Array[int] = [1, 2, 4] + +var match_state: LuneMatch +var world: Node3D +var camera: Camera3D +var hud: Control +var human_slot: int = 1 +var speed_index: int = 0 +var paused: bool = false +var options: Dictionary = {} +var _units_root: Node3D +var _views: Dictionary = {} +var _accumulator: float = 0.0 +var _last_phase: int = -1 +var _preview: LuneCombat +var _time: float = 0.0 +var _lane_cycle: int = 0 + +func start(setup_options: Dictionary) -> void: + options = setup_options + name = "LuneGame" + human_slot = int(options.get("human_slot", 1)) + match_state = LuneMatch.new() + match_state.setup(options) + world = World.new() + add_child(world) + world.build(match_state.map) + _units_root = Node3D.new() + _units_root.name = "Units" + add_child(_units_root) + camera = CameraRig.new() + camera.reduced_motion = Preferences.reduced_motion + add_child(camera) + var layer := CanvasLayer.new() + add_child(layer) + hud = Hud.new() + hud.match_state = match_state + hud.human_slot = maxi(0, human_slot) + layer.add_child(hud) + hud.command_requested.connect(_on_command) + hud.undo_requested.connect(func() -> void: _feedback(match_state.undo(human_slot))) + hud.ready_requested.connect(_on_ready) + hud.starter_requested.connect(_starter) + hud.speed_requested.connect(func(_v: int) -> void: _cycle_speed()) + hud.pause_requested.connect(_toggle_pause) + hud.lane_focus_requested.connect(focus_lane) + hud.map_focus_requested.connect(func(point: Vector3) -> void: camera.look_at_point(point)) + hud.menu_requested.connect(func() -> void: exit_requested.emit()) + hud.rematch_requested.connect(func() -> void: rematch_requested.emit()) + if human_slot >= 0: + focus_lane(match_state.players[human_slot].lane, true) + else: + camera.overview(true) + _enter_planning() + +func focus_lane(lane: String, instant: bool = false) -> void: + var team: int = int(match_state.players[maxi(0, human_slot)].team) + var path: LuneMap.LanePath = match_state.map.lanes[lane] + # Planning looks at the own deployment and outer tower, combat at the contested middle. + var fraction: float = .2 if match_state.phase == LuneMatch.Phase.PLANNING else .42 + var point: Vector2 = match_state.map.lane_point(team, lane, path.length * fraction) + camera.look_at_point(Vector3(point.x, 0, point.y), 24.0, instant) + +func _feedback(result: Dictionary) -> void: + hud.notify(str(result.message), not bool(result.ok)) + Soundscape.play("place" if result.ok else "error") + _rebuild_preview() + +func _on_command(command: Dictionary) -> void: + _feedback(match_state.submit(human_slot, command)) + +func _starter() -> void: + var player: Dictionary = match_state.players[human_slot] + var ids: Array[String] = match_state.base_ids(player.bund) + for index: int in [1, 0, 2]: + var result: Dictionary = match_state.submit(human_slot, {"type": "buy", "type_id": ids[index]}) + if not result.ok: + _feedback(result) + return + _feedback({"ok": true, "message": "Startaufstellung: Front, Nahkampf und Fernkampf gekauft."}) + +func _on_ready() -> void: + if match_state.phase != LuneMatch.Phase.PLANNING: + return + if human_slot >= 0: + match_state.set_ready(human_slot) + if match_state.all_ready(): + _start_combat() + +func _enter_planning() -> void: + for slot: int in range(6): + if slot != human_slot: + LuneBot.plan(match_state, slot) + _rebuild_preview() + if human_slot < 0: + _start_combat() + +func _start_combat() -> void: + _clear_views() + _preview = null + match_state.begin_combat() + _accumulator = 0.0 + Soundscape.play("wave") + +func _clear_views() -> void: + for view: Node in _units_root.get_children(): + view.free() + _views.clear() + +## Shows the human player's current draft army in its formation during planning. +func _rebuild_preview() -> void: + if match_state.phase != LuneMatch.Phase.PLANNING or human_slot < 0: + return + _clear_views() + _preview = LuneCombat.new(match_state.map, match_state.balance, match_state.round_number) + var player: Dictionary = match_state.players[human_slot] + var per_lane: Dictionary = {} + for entry: Dictionary in player.draft.roster: + var lane: String = str(entry.lane) + var index: int = int(per_lane.get(lane, 0)) + per_lane[lane] = index + 1 + var unit: LuneUnit = _preview.spawn("troop", int(player.team), lane, DataCatalog.get_unit(entry.type_id), maxf(.4, 7.2 - float(index / 4) * 1.5), (float(index % 4) - 1.5) * 1.2, human_slot, str(entry.uid)) + _add_view(unit) + +func _add_view(unit: LuneUnit) -> void: + var view: Node3D = UnitView.new() + _units_root.add_child(view) + view.bind(unit) + _views[unit.id] = view + +func _cycle_speed() -> void: + speed_index = (speed_index + 1) % SPEEDS.size() + hud.set_speed_label(SPEEDS[speed_index], paused) + +func _toggle_pause() -> void: + if match_state.phase == LuneMatch.Phase.COMBAT: + paused = not paused + hud.set_speed_label(SPEEDS[speed_index], paused) + +func _process(delta: float) -> void: + _time += delta + var step: float = match_state.balance.sim_step + if match_state.phase == LuneMatch.Phase.COMBAT and not paused: + _accumulator += minf(delta, .1) * SPEEDS[speed_index] + while _accumulator >= step and match_state.phase == LuneMatch.Phase.COMBAT: + match_state.step_combat() + _accumulator -= step + if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null: + for unit: LuneUnit in match_state.combat.units: + if unit.is_mobile() and not _views.has(unit.id): + _add_view(unit) + if match_state.phase != _last_phase: + var previous: int = _last_phase + _last_phase = match_state.phase + if previous == LuneMatch.Phase.COMBAT: + _after_combat() + var alpha: float = _accumulator / step if match_state.phase == LuneMatch.Phase.COMBAT else 1.0 + var sim_delta: float = 0.0 if paused else delta * SPEEDS[speed_index] + for view: Node3D in _views.values(): + view.sync(alpha, sim_delta if match_state.phase == LuneMatch.Phase.COMBAT else delta) + var hp: Dictionary = match_state.building_hp.duplicate() + if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null: + for id: String in match_state.combat.buildings: + hp[id] = (match_state.combat.buildings[id] as LuneUnit).hp + var max_hp: Dictionary = {} + for definition: Dictionary in match_state.map.buildings: + max_hp[definition.id] = definition.max_hp + world.sync_buildings(hp, max_hp, _time, delta) + hud.minimap.camera_focus = camera.focus + hud.minimap.camera_span = camera.zoom + +func _after_combat() -> void: + if match_state.phase == LuneMatch.Phase.OVER: + paused = false + var won: bool = match_state.winner == int(match_state.players[maxi(0, human_slot)].team) + Soundscape.play("victory" if won else ("settle" if match_state.winner == LuneMatch.DRAW else "defeat")) + camera.look_at_point(Vector3(match_state.map.core_position(0 if match_state.combat.destroyed_cores.has(0) else 1).x, 0, match_state.map.core_position(0 if match_state.combat.destroyed_cores.has(0) else 1).y), 26.0) + return + Soundscape.play("settle") + _enter_planning() + +var input_blocked: Callable = func() -> bool: return false + +func _unhandled_input(event: InputEvent) -> void: + if not input_blocked.call() and camera.handle_world_input(event): + get_viewport().set_input_as_handled() + +## Game keys run before the GUI so Space/Tab keep their meaning even when a button has focus. +func _input(event: InputEvent) -> void: + if input_blocked.call() or not (event is InputEventKey and event.pressed and not event.echo): + return + var handled: bool = true + match event.keycode: + KEY_SPACE: + if match_state.phase == LuneMatch.Phase.PLANNING: + _on_ready() + else: + _toggle_pause() + KEY_P: + _toggle_pause() + KEY_TAB: + _lane_cycle = (_lane_cycle + 1) % 3 + focus_lane(LuneMap.LANES[_lane_cycle]) + KEY_O: + camera.overview() + KEY_1, KEY_2, KEY_3, KEY_4, KEY_5: + if match_state.phase == LuneMatch.Phase.PLANNING and human_slot >= 0: + var ids: Array[String] = match_state.base_ids(match_state.players[human_slot].bund) + _on_command({"type": "buy", "type_id": ids[event.keycode - KEY_1]}) + _: + handled = false + if handled: + get_viewport().set_input_as_handled() diff --git a/scripts/luneraria/lune_game.gd.uid b/scripts/luneraria/lune_game.gd.uid new file mode 100644 index 0000000..d806f9e --- /dev/null +++ b/scripts/luneraria/lune_game.gd.uid @@ -0,0 +1 @@ +uid://60nhdgkc0ww4 diff --git a/scripts/luneraria/lune_main.gd b/scripts/luneraria/lune_main.gd new file mode 100644 index 0000000..dc8b8e5 --- /dev/null +++ b/scripts/luneraria/lune_main.gd @@ -0,0 +1,92 @@ +extends Node +## Entry scene of Champions of Luneraria: title menu, match, return, rematch. +## Legacy QA switches (--smoke-test, --faction-test, --expedition-test) open the archived +## Saumwacht scene unchanged, so its technical regressions keep running. + +const Game = preload("res://scripts/luneraria/lune_game.gd") +const Menu = preload("res://scripts/luneraria/ui/lune_menu.gd") +const LEGACY_SWITCHES: Array[String] = ["--smoke-test", "--faction-test", "--expedition-test", "--ringborke", "--legacy"] + +var menu: MainMenu +var game: Node3D +var settings: Control +var _layer: CanvasLayer +var _last_options: Dictionary = {} + +func _ready() -> void: + for arg: String in OS.get_cmdline_user_args(): + if arg in LEGACY_SWITCHES: + get_tree().change_scene_to_file.call_deferred("res://scenes/main.tscn") + return + DisplayServer.window_set_title("Champions of Luneraria") + get_tree().auto_accept_quit = false + get_tree().root.close_requested.connect(Soundscape.quit_game) + _layer = CanvasLayer.new() + _layer.layer = 10 + add_child(_layer) + menu = Menu.new() + _layer.add_child(menu) + menu.start_requested.connect(func() -> void: start_match({"seed": int(Time.get_unix_time_from_system()) % 100000})) + menu.quit_requested.connect(func() -> void: Soundscape.quit_game()) + menu.settings_requested.connect(toggle_settings) + settings = LuneSettings.new() + _layer.add_child(settings) + settings.visible = false + settings.closed.connect(toggle_settings) + menu.enter() + +func start_match(options: Dictionary) -> void: + var own_team_slots: Array[int] = [0, 1, 2] + var human: int = own_team_slots[LuneMap.LANES.find(menu.lane)] if menu.lane in LuneMap.LANES else 1 + var bunds: Array = [] + var rng := RandomNumberGenerator.new() + rng.seed = int(options.get("seed", 1)) + for slot: int in range(6): + bunds.append(menu.bund if slot == human else DataCatalog.FACTIONS[rng.randi() % DataCatalog.FACTIONS.size()]) + _last_options = {"seed": int(options.get("seed", 1)), "human_slot": human, "bunds": bunds} + _launch(_last_options) + +func _launch(options: Dictionary) -> void: + # The rematch button lives inside the old match; detach it now, free it after the signal. + if is_instance_valid(game): + remove_child(game) + game.queue_free() + game = Game.new() + add_child(game) + game.input_blocked = func() -> bool: return menu.visible or settings.visible + game.start(options) + game.exit_requested.connect(return_to_menu) + game.rematch_requested.connect(func() -> void: + _last_options.seed = int(_last_options.seed) + 1 + _launch(_last_options)) + game.hud.settings_requested.connect(toggle_settings) + menu.leave() + +func return_to_menu() -> void: + if is_instance_valid(game): + remove_child(game) + game.queue_free() + game = null + menu._locked = false + menu.pending_screen = MainMenu.Screen.TITLE + menu.enter() + +func toggle_settings() -> void: + settings.visible = not settings.visible + if is_instance_valid(game): + game.paused = settings.visible or game.paused + if settings.visible: + settings.sync() + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_F1 or (event.keycode == KEY_ESCAPE and settings.visible): + toggle_settings() + get_viewport().set_input_as_handled() + elif event.keycode in [KEY_M, KEY_F11]: + if event.keycode == KEY_M: + Preferences.muted = not Preferences.muted + else: + Preferences.fullscreen = not Preferences.fullscreen + Preferences.commit() + get_viewport().set_input_as_handled() diff --git a/scripts/luneraria/lune_main.gd.uid b/scripts/luneraria/lune_main.gd.uid new file mode 100644 index 0000000..00cbcd9 --- /dev/null +++ b/scripts/luneraria/lune_main.gd.uid @@ -0,0 +1 @@ +uid://ciohupnds7xrb diff --git a/scripts/luneraria/ui/lune_hud.gd b/scripts/luneraria/ui/lune_hud.gd new file mode 100644 index 0000000..9feb0fa --- /dev/null +++ b/scripts/luneraria/ui/lune_hud.gd @@ -0,0 +1,424 @@ +extends Control +## Champions of Luneraria HUD. Presentation only: it reads LuneMatch and sends the human +## player's commands through the owning LuneGame; UI state never creates ownership. + +signal command_requested(command: Dictionary) +signal undo_requested +signal ready_requested +signal starter_requested +signal speed_requested(value: int) +signal pause_requested +signal lane_focus_requested(lane: String) +signal map_focus_requested(point: Vector3) +signal menu_requested +signal rematch_requested +signal settings_requested + +const Minimap = preload("res://scripts/luneraria/ui/lune_minimap.gd") +const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")] +const TEAM_NAMES: Array[String] = ["Blau", "Rot"] +const ROLE_NAMES: Dictionary = {"front": "Front", "melee": "Nahkampf", "ranged": "Fernkampf", "splash": "Fläche", "support": "Unterstützung"} + +var match_state: LuneMatch +var human_slot: int = 1 +var minimap: Control +var _phase: Label +var _round: Label +var _core_bars: Array[ProgressBar] = [] +var _core_labels: Array[Label] = [] +var _gold: Label +var _income: Label +var _identity: Label +var _pause: Button +var _speed: Button +var _lane_rows: Dictionary = {} +var _team_list: Label +var _plan_panel: PanelContainer +var _shop: GridContainer +var _shop_buttons: Dictionary = {} +var _roster: VBoxContainer +var _roster_title: Label +var _ready_button: Button +var _starter: Button +var _undo: Button +var _combat_panel: PanelContainer +var _combat_info: Label +var _report: Label +var _toast: Label +var _toast_left: float = 0.0 +var _over: Control +var _over_title: Label +var _over_body: Label +var _roster_signature: String = "@" + +func _ready() -> void: + theme = SaumwachtTheme.shared() + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_IGNORE + _top_bar() + _lane_panel() + _plan_side() + _bottom() + _game_over() + +func _panel(variation: String = "CaptionPanel") -> PanelContainer: + var panel := PanelContainer.new() + panel.theme_type_variation = variation + panel.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(panel) + return panel + +func _label(text_value: String, variation: String, parent: Node, size: int = 0) -> Label: + var label := Label.new() + label.text = text_value + label.theme_type_variation = variation + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + if size > 0: + label.add_theme_font_size_override("font_size", size) + parent.add_child(label) + return label + +func _button(text_value: String, parent: Node, action: Callable, variation: String = "Button") -> Button: + var button := Button.new() + button.text = text_value + button.theme_type_variation = variation + button.focus_mode = Control.FOCUS_ALL + button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + button.pressed.connect(action) + parent.add_child(button) + return button + +func _bar(color: Color, parent: Node) -> ProgressBar: + var bar := ProgressBar.new() + bar.show_percentage = false + bar.custom_minimum_size = Vector2(190, 10) + var back := StyleBoxFlat.new() + back.bg_color = Color(0, 0, 0, .55) + back.border_color = Color("5c4a33") + back.set_border_width_all(1) + var fill := StyleBoxFlat.new() + fill.bg_color = color + bar.add_theme_stylebox_override("background", back) + bar.add_theme_stylebox_override("fill", fill) + parent.add_child(bar) + return bar + +func _top_bar() -> void: + var top := _panel() + top.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) + top.offset_left = 16 + top.offset_right = -16 + top.offset_top = 12 + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 22) + top.add_child(row) + var title := VBoxContainer.new() + title.add_theme_constant_override("separation", 0) + row.add_child(title) + _label("CHAMPIONS OF LUNERARIA", "EyebrowLabel", title) + _phase = _label("PLANUNG", "SectionLabel", title, 20) + _round = _label("Welle 1", "MutedLabel", title) + for team: int in range(2): + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 3) + row.add_child(box) + _core_labels.append(_label("Ancient %s" % TEAM_NAMES[team], "MutedLabel", box, 15)) + _core_bars.append(_bar(TEAM_COLORS[team], box)) + var economy := VBoxContainer.new() + economy.add_theme_constant_override("separation", 0) + row.add_child(economy) + _gold = _label("0 Gold", "CardTitleLabel", economy, 24) + _income = _label("", "MutedLabel", economy, 15) + _identity = _label("", "MutedLabel", row, 15) + _identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _identity.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _pause = _button("Pause · P", row, func() -> void: pause_requested.emit()) + _speed = _button("1×", row, func() -> void: speed_requested.emit(0)) + _button("Optionen · F1", row, func() -> void: settings_requested.emit()) + _button("Menü", row, func() -> void: menu_requested.emit(), "QuietButton") + +func _lane_panel() -> void: + var panel := _panel() + panel.position = Vector2(16, 112) + panel.custom_minimum_size = Vector2(300, 0) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 6) + panel.add_child(column) + _label("LANES · TÜRME", "SectionLabel", column) + for lane: String in LuneMap.LANES: + var button := _button("", column, func() -> void: lane_focus_requested.emit(lane), "ChoiceButton") + button.custom_minimum_size = Vector2(0, 52) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.tooltip_text = "Kamera auf %s" % LuneMap.LANE_NAMES[lane] + _lane_rows[lane] = button + _team_list = _label("", "MutedLabel", column, 14) + +func _plan_side() -> void: + _plan_panel = _panel("MenuPanel") + _plan_panel.anchor_left = 1.0 + _plan_panel.anchor_right = 1.0 + _plan_panel.anchor_bottom = 1.0 + _plan_panel.offset_left = -440 + _plan_panel.offset_right = -16 + _plan_panel.offset_top = 112 + _plan_panel.offset_bottom = -270 + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 8) + _plan_panel.add_child(column) + _label("TRUPPEN KAUFEN · 1–5", "SectionLabel", column) + _shop = GridContainer.new() + _shop.columns = 5 + _shop.add_theme_constant_override("h_separation", 6) + column.add_child(_shop) + _roster_title = _label("DEINE ARMEE", "SectionLabel", column) + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + column.add_child(scroll) + _roster = VBoxContainer.new() + _roster.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _roster.add_theme_constant_override("separation", 4) + scroll.add_child(_roster) + var actions := HBoxContainer.new() + column.add_child(actions) + _starter = _button("Startaufstellung", actions, func() -> void: starter_requested.emit()) + _starter.tooltip_text = "Kauft Front, Nahkampf und Fernkampf deines Bundes für deine Lane." + _undo = _button("Rückgängig", actions, func() -> void: undo_requested.emit()) + _ready_button = _button("BEREIT · KAMPF ›", column, func() -> void: ready_requested.emit(), "PrimaryButton") + _ready_button.custom_minimum_size.y = 58 + _ready_button.tooltip_text = "Leertaste · Befehle werden jetzt verbindlich übernommen." + _combat_panel = _panel() + _combat_panel.anchor_left = 1.0 + _combat_panel.anchor_right = 1.0 + _combat_panel.offset_left = -440 + _combat_panel.offset_right = -16 + _combat_panel.offset_top = 112 + _combat_info = _label("", "Label", _combat_panel, 16) + +func _bottom() -> void: + var report_panel := _panel() + report_panel.anchor_top = 1.0 + report_panel.anchor_bottom = 1.0 + report_panel.offset_left = 16 + report_panel.offset_top = -120 + report_panel.offset_bottom = -16 + report_panel.offset_right = 760 + var column := VBoxContainer.new() + report_panel.add_child(column) + _report = _label("Planung: Kaufe Truppen für deine Lane und drücke Bereit.", "Label", column, 16) + _report.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _toast = _label("", "MutedLabel", column, 15) + var map_panel := _panel() + map_panel.anchor_left = 1.0 + map_panel.anchor_right = 1.0 + map_panel.anchor_top = 1.0 + map_panel.anchor_bottom = 1.0 + map_panel.offset_left = -270 + map_panel.offset_right = -16 + map_panel.offset_top = -258 + map_panel.offset_bottom = -16 + minimap = Minimap.new() + map_panel.add_child(minimap) + minimap.focus_requested.connect(func(point: Vector3) -> void: map_focus_requested.emit(point)) + +func _game_over() -> void: + _over = ColorRect.new() + (_over as ColorRect).color = Color(.02, .03, .04, .82) + _over.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _over.mouse_filter = Control.MOUSE_FILTER_STOP + _over.visible = false + add_child(_over) + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _over.add_child(center) + var panel := PanelContainer.new() + panel.theme_type_variation = "MenuPanel" + panel.custom_minimum_size = Vector2(620, 0) + center.add_child(panel) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 14) + panel.add_child(column) + _over_title = _label("", "TitleLabel", column, 60) + _over_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _over_body = _label("", "BodyLabel", column) + _over_body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _over_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + var row := HBoxContainer.new() + row.alignment = BoxContainer.ALIGNMENT_CENTER + row.add_theme_constant_override("separation", 12) + column.add_child(row) + _button("NEUE PARTIE", row, func() -> void: rematch_requested.emit(), "PrimaryButton") + _button("Hauptmenü", row, func() -> void: menu_requested.emit()) + +# ---------------------------------------------------------------- refresh +func notify(message: String, error: bool = false) -> void: + _toast.text = message + _toast.add_theme_color_override("font_color", Color("f0a888") if error else Color("9fe0c8")) + _toast_left = 5.0 + +func _process(delta: float) -> void: + _toast_left -= delta + if _toast_left <= 0.0: + _toast.text = "WASD / mittlere Maustaste: Kamera · Mausrad: Zoom · Tab: Lanes · Minimap klicken" + _toast.remove_theme_color_override("font_color") + if match_state != null: + refresh() + +func refresh() -> void: + var player: Dictionary = match_state.players[human_slot] + var planning: bool = match_state.phase == LuneMatch.Phase.PLANNING + var combat: bool = match_state.phase == LuneMatch.Phase.COMBAT + var phase_name: String = "PLANUNG" if planning else ("KAMPF" if combat else "PARTIE BEENDET") + _phase.text = phase_name + _round.text = "Welle %d%s" % [match_state.round_number, (" · %02d:%02d" % [int(match_state.combat.elapsed) / 60, int(match_state.combat.elapsed) % 60]) if combat else ""] + for team: int in range(2): + var hp: float = match_state.core_hp(team) + if combat and match_state.combat.buildings.has("%s_ancient" % ("blau" if team == 0 else "rot")): + hp = (match_state.combat.buildings["%s_ancient" % ("blau" if team == 0 else "rot")] as LuneUnit).hp + _core_bars[team].max_value = match_state.balance.core_hp + _core_bars[team].value = hp + _core_labels[team].text = "Ancient %s · %d" % [TEAM_NAMES[team], ceili(hp)] + _gold.text = "%d Gold" % match_state.gold_of(human_slot) + _income.text = "+%d pro Runde · Armee %d / %d" % [match_state.balance.round_income(match_state.round_number), (player.draft.roster as Array).size() if planning else (player.roster as Array).size(), match_state.balance.supply_cap] + _identity.text = "%s · %s-Lane · %s" % [TEAM_NAMES[int(player.team)], LuneMap.LANE_NAMES[player.lane], DataCatalog.FACTION_NAMES[player.bund]] + _pause.visible = combat + _speed.visible = combat + for lane: String in LuneMap.LANES: + var button: Button = _lane_rows[lane] + var own: String = "●".repeat(match_state.towers_alive(0, lane, true)) + "○".repeat(2 - match_state.towers_alive(0, lane, true)) + var rival: String = "●".repeat(match_state.towers_alive(1, lane, true)) + "○".repeat(2 - match_state.towers_alive(1, lane, true)) + var blue_owner: String = match_state.players[match_state.slot_of(0, lane)].name + var red_owner: String = match_state.players[match_state.slot_of(1, lane)].name + button.text = "%s Blau %s Rot %s\n%s · %s" % [LuneMap.LANE_NAMES[lane].to_upper(), own, rival, blue_owner, red_owner] + var team_lines: PackedStringArray = [] + for team: int in range(2): + var names: PackedStringArray = [] + for slot: int in range(team * 3, team * 3 + 3): + names.append(match_state.players[slot].name) + team_lines.append("%s: %s · Basistürme %d/2" % [TEAM_NAMES[team], ", ".join(names), int(match_state.building_alive_now("%s_base_1" % ("blau" if team == 0 else "rot"))) + int(match_state.building_alive_now("%s_base_2" % ("blau" if team == 0 else "rot")))]) + _team_list.text = "\n".join(team_lines) + _plan_panel.visible = planning + _combat_panel.visible = combat + if planning: + _refresh_plan(player) + elif combat: + var lines: PackedStringArray = ["KAMPF LÄUFT · automatisch"] + for lane: String in LuneMap.LANES: + var counts: Array[int] = [0, 0] + for unit: LuneUnit in match_state.combat.units: + if unit.alive and unit.is_mobile() and unit.lane == lane: + counts[unit.team] += 1 + lines.append("%s Blau %d · Rot %d Einheiten" % [LuneMap.LANE_NAMES[lane], counts[0], counts[1]]) + lines.append("Leertaste/P: Pause · Tempo 1×/2×/4×") + _combat_info.text = "\n".join(lines) + if combat: + _report.text = "Welle %d läuft. Armeen und Monsterwellen rücken über alle drei Lanes vor; Türme fallen in fester Reihenfolge." % match_state.round_number + elif planning and match_state.last_settlement.is_empty(): + _report.text = "Planung: Kaufe Truppen für deine Lane, teile sie auf Top, Mid oder Bottom ein und drücke Bereit." + if not match_state.last_settlement.is_empty() and planning: + var s: Dictionary = match_state.last_settlement + var lost: PackedStringArray = [] + for id: String in s.buildings_lost: + lost.append(_building_name(id)) + _report.text = "Welle %d abgeschlossen (%s, %ds) · dein Ertrag +%d Gold (Kills %d, Einkommen %d)%s" % [s.round, _reason(s.end_reason), int(s.combat_time), int(s.gains[human_slot]) + int(s.income), int(s.gains[human_slot]), int(s.income), ("\nGefallen: " + ", ".join(lost)) if not lost.is_empty() else ""] + _over.visible = match_state.phase == LuneMatch.Phase.OVER + if _over.visible: + var own_team: int = int(player.team) + _over_title.text = "UNENTSCHIEDEN" if match_state.winner == LuneMatch.DRAW else ("SIEG" if match_state.winner == own_team else "NIEDERLAGE") + _over_body.text = "%s\nNach %d Wellen." % [match_state.end_note, match_state.round_number] + minimap.match_state = match_state + minimap.queue_redraw() + +func _reason(reason: String) -> String: + return {"cleared": "alle Einheiten gefallen", "timeout": "Zeitfenster abgelaufen", "core": "Ancient zerstört"}.get(reason, reason) + +func _building_name(id: String) -> String: + var parts: PackedStringArray = id.split("_") + var team: String = "Blau" if parts[0] == "blau" else "Rot" + if parts[1] == "ancient": + return "Ancient " + team + if parts[1] == "base": + return "Basisturm %s %s" % [team, parts[2]] + return "%s %s-%s" % [team, LuneMap.LANE_NAMES[parts[1]], "Außenturm" if parts[2] == "outer" else "Innenturm"] + +func _refresh_plan(player: Dictionary) -> void: + var draft: Dictionary = player.draft + var ids: Array[String] = match_state.base_ids(player.bund) + if _shop_buttons.is_empty() or not _shop_buttons.has(ids[0]): + for child: Node in _shop.get_children(): + child.free() + _shop_buttons.clear() + for index: int in range(ids.size()): + var id: String = ids[index] + var data: Dictionary = DataCatalog.get_unit(id) + var button := Button.new() + button.icon = AssetLibrary.icon(id) + button.expand_icon = true + button.custom_minimum_size = Vector2(76, 96) + button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + button.vertical_icon_alignment = VERTICAL_ALIGNMENT_TOP + button.text = "%d G" % int(data.cost) + button.focus_mode = Control.FOCUS_ALL + button.tooltip_text = "%s [%d]\n%s · %d HP · %d Schaden/s · %.1f m\n%s" % [data.display_name, index + 1, ROLE_NAMES[match_state.role_of(id)], data.max_hp, data.damage, data.attack_range, data.ability_description] + button.pressed.connect(func() -> void: command_requested.emit({"type": "buy", "type_id": id})) + _shop.add_child(button) + _shop_buttons[id] = button + for id: String in _shop_buttons: + var cost: int = int(DataCatalog.get_unit(id).cost) + (_shop_buttons[id] as Button).disabled = int(draft.gold) < cost or (draft.roster as Array).size() >= match_state.balance.supply_cap + _undo.disabled = (player.undo as Array).is_empty() + _starter.disabled = not (draft.roster as Array).is_empty() + _ready_button.disabled = bool(player.ready) + _ready_button.text = "BEREIT · KAMPF ›" if not player.ready else "WARTE AUF ANDERE …" + var signature: String = JSON.stringify(draft) + if signature == _roster_signature: + return + _roster_signature = signature + for child: Node in _roster.get_children(): + child.free() + _roster_title.text = "DEINE ARMEE · %d / %d" % [(draft.roster as Array).size(), match_state.balance.supply_cap] + if (draft.roster as Array).is_empty(): + _label("Noch keine Truppen. Kaufe oben oder nutze die Startaufstellung.", "MutedLabel", _roster, 15).autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + for entry: Dictionary in draft.roster: + var data: Dictionary = DataCatalog.get_unit(entry.type_id) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + _roster.add_child(row) + var icon := TextureRect.new() + icon.texture = AssetLibrary.icon(entry.type_id) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.custom_minimum_size = Vector2(40, 40) + row.add_child(icon) + var name_label := _label(str(data.display_name), "Label", row, 15) + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + name_label.clip_text = true + for lane: String in LuneMap.LANES: + var lane_button := Button.new() + lane_button.text = LuneMap.LANE_NAMES[lane].left(1) + lane_button.toggle_mode = true + lane_button.button_pressed = entry.lane == lane + lane_button.theme_type_variation = "ChoiceButton" + lane_button.custom_minimum_size = Vector2(34, 34) + lane_button.tooltip_text = "Auf %s einteilen" % LuneMap.LANE_NAMES[lane] + var uid: String = str(entry.uid) + lane_button.pressed.connect(func() -> void: command_requested.emit({"type": "assign", "uid": uid, "lane": lane})) + row.add_child(lane_button) + var upgrade_id: String = str(data.get("upgrade_id", "")) + var uid_value: String = str(entry.uid) + var up := Button.new() + up.text = "↑ %d" % int(DataCatalog.get_unit(upgrade_id).cost) if not upgrade_id.is_empty() else "max" + up.disabled = upgrade_id.is_empty() or int(draft.gold) < int(DataCatalog.get_unit(upgrade_id).get("cost", 0)) + up.tooltip_text = ("Aufwerten zu %s" % DataCatalog.get_unit(upgrade_id).display_name) if not upgrade_id.is_empty() else "Vollständig aufgewertet" + up.pressed.connect(func() -> void: command_requested.emit({"type": "upgrade", "uid": uid_value})) + row.add_child(up) + var sell := Button.new() + var refund: int = int(entry.invested) if bool(entry.get("fresh", false)) else floori(float(entry.invested) * match_state.balance.sell_refund) + sell.text = "×" + sell.tooltip_text = "Zurückgeben für %d Gold" % refund + sell.pressed.connect(func() -> void: command_requested.emit({"type": "sell", "uid": uid_value})) + row.add_child(sell) + +func set_speed_label(value: int, paused: bool) -> void: + _speed.text = "%d×" % value + _pause.text = "Weiter · P" if paused else "Pause · P" diff --git a/scripts/luneraria/ui/lune_hud.gd.uid b/scripts/luneraria/ui/lune_hud.gd.uid new file mode 100644 index 0000000..7dc2cab --- /dev/null +++ b/scripts/luneraria/ui/lune_hud.gd.uid @@ -0,0 +1 @@ +uid://d0qt7wmu3odob diff --git a/scripts/luneraria/ui/lune_menu.gd b/scripts/luneraria/ui/lune_menu.gd new file mode 100644 index 0000000..f60a3c8 --- /dev/null +++ b/scripts/luneraria/ui/lune_menu.gd @@ -0,0 +1,81 @@ +extends MainMenu +## The Phase 20A menu shell retitled for Champions of Luneraria. Match options here are +## the new mode's options (own Bund, lane, seed); they never touch the legacy GameState. + +var bund: String = "wurzelbund" +var lane: String = "mid" +var lane_choice: ChoiceGroup + +func _ready() -> void: + super._ready() + title_label.text = "CHAMPIONS" + subtitle_label.text = "OF LUNERARIA" + subtitle_label.theme_type_variation = "TitleLabel" + subtitle_label.add_theme_font_size_override("font_size", 54) + title_label.add_theme_font_size_override("font_size", 64) + var tagline := Label.new() + tagline.text = "DREI LANES · 3 GEGEN 3 · AUTOBATTLER" + tagline.theme_type_variation = "SubtitleLabel" + brand_names.add_child(tagline) + lore_label.text = "Führe deine Armee über drei Lanes. Die Partie endet erst, wenn ein Ancient fällt." + emblem_rect.texture = load("res://assets/ui/emblems/luneraria.svg") + version_label.text = "Prototyp %s · 1 Mensch + 5 Bots" % str(ProjectSettings.get_setting("application/config/version", "")) + _title_caption.text = "Glutpakt und Wurzelbund an den Ufern der Grünen Kluft" + _retarget_setup() + +func _retarget_setup() -> void: + # Replace the Saumwacht-only options (match length, rival, AI level, timed build). + faction_choice.item_selected.disconnect(faction_choice.item_selected.get_connections()[0].callable) + faction_choice.item_selected.connect(func(index: int) -> void: + bund = DataCatalog.FACTIONS[index] + sync()) + var body: VBoxContainer = mode_choice.get_parent() + var at: int = mode_choice.get_index() - 1 + for node: Node in [mode_choice, length_summary, _advanced_button.get_parent(), _advanced]: + node.visible = false + body.get_child(at).visible = false + var heading := Label.new() + heading.text = "DEINE LANE" + heading.theme_type_variation = "SectionLabel" + body.add_child(heading) + body.move_child(heading, at) + lane_choice = ChoiceGroup.new(true) + lane_choice.add_option("Top", "oberer Korridor") + lane_choice.add_option("Mid", "direkter Weg") + lane_choice.add_option("Bottom", "unterer Korridor") + body.add_child(lane_choice) + body.move_child(lane_choice, at + 1) + lane_choice.item_selected.connect(func(index: int) -> void: + lane = LuneMap.LANES[index] + sync()) + var rules := Label.new() + rules.theme_type_variation = "MutedLabel" + rules.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + rules.text = "Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot. Kein Rundenlimit: Es gewinnt, wer den gegnerischen Ancient zerstört." + body.add_child(rules) + body.move_child(rules, at + 2) + +func sync() -> void: + super.sync() + if lane_choice == null: + return + faction_choice.select(DataCatalog.FACTIONS.find(bund)) + lane_choice.select(LuneMap.LANES.find(lane)) + _style_heading.text = "SPIELWEISE · " + DataCatalog.FACTION_NAMES[bund].to_upper() + var lines: PackedStringArray = [] + for fact: String in FactionChoice.PROFILES[bund].facts: + lines.append("• " + fact) + _style_facts.text = "\n".join(lines) + _timed_hint.text = "Planung wartet auf dich · Leertaste startet den Kampf" + var names: PackedStringArray = [] + for entry: Array in Stage.SETUPS[bund].figures: + names.append(str(DataCatalog.get_unit(entry[0]).display_name)) + _setup_caption.text = "IM BILD " + " · ".join(names) + if screen == Screen.SETUP and _active and stage.current_shot() != bund: + stage.set_shot_named(bund, reduced_motion) + +func _setup_shot() -> String: + return bund + +func _on_title_primary() -> void: + show_screen(Screen.SETUP) diff --git a/scripts/luneraria/ui/lune_menu.gd.uid b/scripts/luneraria/ui/lune_menu.gd.uid new file mode 100644 index 0000000..7eb8f33 --- /dev/null +++ b/scripts/luneraria/ui/lune_menu.gd.uid @@ -0,0 +1 @@ +uid://dfkwcwigd7gci diff --git a/scripts/luneraria/ui/lune_minimap.gd b/scripts/luneraria/ui/lune_minimap.gd new file mode 100644 index 0000000..4d611b3 --- /dev/null +++ b/scripts/luneraria/ui/lune_minimap.gd @@ -0,0 +1,66 @@ +extends Control +## Minimap drawn from the authoritative match state. Click or drag to move the camera. + +signal focus_requested(point: Vector3) +const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")] +var match_state: LuneMatch +var camera_focus := Vector3.ZERO +var camera_span: float = 34.0 + +func _ready() -> void: + custom_minimum_size = Vector2(236, 236) + mouse_filter = Control.MOUSE_FILTER_STOP + focus_mode = Control.FOCUS_NONE + tooltip_text = "Minimap · Klicken oder ziehen, um die Kamera zu bewegen" + +func to_map(v: Vector2) -> Vector2: + var extent: float = LuneMap.HALF_EXTENT + 4.0 + return (v + Vector2(extent, extent)) / (extent * 2.0) * size + +func to_world(p: Vector2) -> Vector3: + var extent: float = LuneMap.HALF_EXTENT + 4.0 + var v: Vector2 = p / size * extent * 2.0 - Vector2(extent, extent) + return Vector3(v.x, 0, v.y) + +func _gui_input(event: InputEvent) -> void: + if (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT) or (event is InputEventMouseMotion and event.button_mask & MOUSE_BUTTON_MASK_LEFT): + focus_requested.emit(to_world(event.position)) + accept_event() + +func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, size), Color(.04, .06, .06, .92)) + draw_rect(Rect2(Vector2.ZERO, size), Color("5c4a33"), false, 1.5) + if match_state == null: + return + var a: Vector2 = to_map(Vector2(-40, -40)) + var b: Vector2 = to_map(Vector2(40, 40)) + draw_line(a, b, Color(.12, .38, .42, .8), 7.0) + for lane: String in LuneMap.LANES: + var path: LuneMap.LanePath = match_state.map.lanes[lane] + var points := PackedVector2Array() + for p: Vector2 in path.points: + points.append(to_map(p)) + draw_polyline(points, Color(.62, .56, .42), 4.0, true) + var grotto: Vector2 = to_map(LuneMap.GROTTO) + draw_circle(grotto, 5.0, Color(.55, .35, .75, .85)) + var hp: Dictionary = {} + if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null: + for unit: LuneUnit in match_state.combat.units: + if unit.is_mobile() and unit.alive: + draw_circle(to_map(unit.position), 1.8 if unit.kind == "monster" else 2.4, TEAM_COLORS[unit.team].lightened(.2 if unit.kind == "monster" else 0.0)) + for definition: Dictionary in match_state.map.buildings: + var alive: bool = match_state.building_alive(definition.id) + if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null and match_state.combat.buildings.has(definition.id): + alive = (match_state.combat.buildings[definition.id] as LuneUnit).alive + var at: Vector2 = to_map(definition.position) + var radius: float = 6.5 if definition.tier == "core" else 4.0 + var color: Color = TEAM_COLORS[int(definition.team)] + if alive: + draw_circle(at, radius, color) + draw_arc(at, radius, 0, TAU, 16, Color(1, 1, 1, .6), 1.0) + else: + draw_arc(at, radius, 0, TAU, 16, color.darkened(.3), 1.5) + draw_line(at - Vector2(radius, radius) * .6, at + Vector2(radius, radius) * .6, color.darkened(.3), 1.5) + var center: Vector2 = to_map(Vector2(camera_focus.x, camera_focus.z)) + var span: float = camera_span / ((LuneMap.HALF_EXTENT + 4.0) * 2.0) * size.x + draw_rect(Rect2(center - Vector2(span * .9, span * .55), Vector2(span * 1.8, span * 1.1)), Color(1, 1, 1, .55), false, 1.2) diff --git a/scripts/luneraria/ui/lune_minimap.gd.uid b/scripts/luneraria/ui/lune_minimap.gd.uid new file mode 100644 index 0000000..ddadb2a --- /dev/null +++ b/scripts/luneraria/ui/lune_minimap.gd.uid @@ -0,0 +1 @@ +uid://cxf4d7s5ts31s diff --git a/scripts/luneraria/ui/lune_settings.gd b/scripts/luneraria/ui/lune_settings.gd new file mode 100644 index 0000000..f8d2e9c --- /dev/null +++ b/scripts/luneraria/ui/lune_settings.gd @@ -0,0 +1,68 @@ +class_name LuneSettings +extends ColorRect +## Options and controls for Champions of Luneraria; the same Preferences as before. + +signal closed + +var _volume: HSlider +var _checks: Dictionary = {} + +func _ready() -> void: + theme = SaumwachtTheme.shared() + color = Color(.02, .03, .04, .9) + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_STOP + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(center) + var panel := PanelContainer.new() + panel.theme_type_variation = "MenuPanel" + panel.custom_minimum_size.x = 760 + center.add_child(panel) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 12) + panel.add_child(column) + var heading := _label("Optionen & Steuerung", "HeadingLabel", column) + heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _label("Ein laufender Kampf pausiert, solange dieses Fenster geöffnet ist.", "MutedLabel", column).horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _label("LAUTSTÄRKE", "SectionLabel", column) + _volume = HSlider.new() + _volume.max_value = 100 + _volume.custom_minimum_size.y = 28 + column.add_child(_volume) + _volume.value_changed.connect(func(value: float) -> void: + Preferences.volume = value / 100.0 + Preferences.commit()) + var grid := GridContainer.new() + grid.columns = 2 + grid.add_theme_constant_override("h_separation", 28) + column.add_child(grid) + for entry: Array in [["muted", "Ton stumm [M]"], ["fullscreen", "Vollbild [F11]"], ["reduced_motion", "Bewegung reduzieren"]]: + var check := CheckButton.new() + check.text = entry[1] + grid.add_child(check) + var key: String = entry[0] + check.toggled.connect(func(value: bool) -> void: + Preferences.set(key, value) + Preferences.commit()) + _checks[key] = check + column.add_child(HSeparator.new()) + _label("STEUERUNG", "SectionLabel", column) + _label("Planung: 1–5 Truppe kaufen · Lane-Knöpfe T/M/B teilen ein · Leertaste Bereit\nKampf: Leertaste oder P Pause · Tempo 1×/2×/4× · Kämpfe laufen automatisch\nKamera: WASD / Pfeile / mittlere Maustaste · Mausrad Zoom · Tab nächste Lane · O Übersicht\nMinimap anklicken springt dorthin · F1 Optionen · M Ton · F11 Vollbild", "Label", column) + var back := Button.new() + back.text = "ZURÜCK [Esc]" + back.theme_type_variation = "PrimaryButton" + back.pressed.connect(func() -> void: closed.emit()) + column.add_child(back) + +func _label(text_value: String, variation: String, parent: Node) -> Label: + var label := Label.new() + label.text = text_value + label.theme_type_variation = variation + parent.add_child(label) + return label + +func sync() -> void: + _volume.set_value_no_signal(Preferences.volume * 100.0) + for key: String in _checks: + (_checks[key] as CheckButton).set_pressed_no_signal(bool(Preferences.get(key))) diff --git a/scripts/luneraria/ui/lune_settings.gd.uid b/scripts/luneraria/ui/lune_settings.gd.uid new file mode 100644 index 0000000..60a101d --- /dev/null +++ b/scripts/luneraria/ui/lune_settings.gd.uid @@ -0,0 +1 @@ +uid://bky12ooi60og8 diff --git a/scripts/luneraria/view/lune_camera.gd b/scripts/luneraria/view/lune_camera.gd new file mode 100644 index 0000000..6703815 --- /dev/null +++ b/scripts/luneraria/view/lune_camera.gd @@ -0,0 +1,76 @@ +extends Camera3D +## Overview camera: orthographic three-quarter view over the whole map, pan, zoom and +## lane focus. Blau's base sits bottom-left, Rot's top-right, as on the minimap. + +var focus := Vector3.ZERO +var yaw: float = 0.0 +var pitch: float = deg_to_rad(-56.0) +var zoom: float = 34.0 +var reduced_motion: bool = false +var _goal_focus := Vector3.ZERO +var _goal_zoom: float = 34.0 +var _dragging: bool = false + +func _ready() -> void: + projection = PROJECTION_ORTHOGONAL + near = .1 + far = 400.0 + current = true + _goal_focus = focus + _apply() + +func look_at_point(point: Vector3, new_zoom: float = -1.0, instant: bool = false) -> void: + _goal_focus = Vector3(clampf(point.x, -40, 40), 0, clampf(point.z, -40, 40)) + if new_zoom > 0.0: + _goal_zoom = new_zoom + if instant or reduced_motion: + focus = _goal_focus + zoom = _goal_zoom + _apply() + +func overview(instant: bool = false) -> void: + look_at_point(Vector3.ZERO, 46.0, instant) + +func _apply() -> void: + size = zoom + var back := Vector3(0, 0, 1).rotated(Vector3.RIGHT, pitch).rotated(Vector3.UP, yaw) + position = focus + back * 80.0 + look_at(focus, Vector3.UP) + +func _process(delta: float) -> void: + var pan := Vector2(Input.get_axis("ui_left", "ui_right"), Input.get_axis("ui_up", "ui_down")) + if Input.is_physical_key_pressed(KEY_A): pan.x -= 1 + if Input.is_physical_key_pressed(KEY_D): pan.x += 1 + if Input.is_physical_key_pressed(KEY_W): pan.y -= 1 + if Input.is_physical_key_pressed(KEY_S): pan.y += 1 + if pan != Vector2.ZERO and not _text_focus(): + var right := Vector3(cos(yaw), 0, -sin(yaw)) + var forward := Vector3(-sin(yaw), 0, -cos(yaw)) + _goal_focus += (right * pan.x - forward * pan.y) * zoom * .9 * delta + var t: float = 1.0 if reduced_motion else minf(1.0, delta * 7.0) + focus = focus.lerp(_goal_focus, t) + zoom = lerpf(zoom, _goal_zoom, t) + _apply() + +func _text_focus() -> bool: + var owner: Control = get_viewport().gui_get_focus_owner() + return owner is LineEdit or owner is TextEdit + +## World input only; the HUD consumes its own clicks before they reach here. +func handle_world_input(event: InputEvent) -> bool: + if event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + _goal_zoom = maxf(12.0, _goal_zoom - 2.5) + return true + if event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _goal_zoom = minf(60.0, _goal_zoom + 2.5) + return true + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_MIDDLE: + _dragging = event.pressed + return true + if event is InputEventMouseMotion and _dragging: + var right := Vector3(cos(yaw), 0, -sin(yaw)) + var forward := Vector3(-sin(yaw), 0, -cos(yaw)) + _goal_focus -= (right * event.relative.x - forward * event.relative.y) * zoom / 700.0 + return true + return false diff --git a/scripts/luneraria/view/lune_camera.gd.uid b/scripts/luneraria/view/lune_camera.gd.uid new file mode 100644 index 0000000..923c1cc --- /dev/null +++ b/scripts/luneraria/view/lune_camera.gd.uid @@ -0,0 +1 @@ +uid://doa7ve4i37a5q diff --git a/scripts/luneraria/view/lune_unit_view.gd b/scripts/luneraria/view/lune_unit_view.gd new file mode 100644 index 0000000..c6342d2 --- /dev/null +++ b/scripts/luneraria/view/lune_unit_view.gd @@ -0,0 +1,134 @@ +extends Node3D +## Visual twin of one LuneUnit. Reads the authoritative state and drives the existing +## figure animations from real combat events; it never writes back into the simulation. + +const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")] + +var unit: LuneUnit +var model: Node3D +var skeletal: bool = false +var _fill: MeshInstance3D +var _bars: Node3D +var _ring: MeshInstance3D +var _last_attack: int = 0 +var _last_ability: int = 0 +var _attack_flash: float = 0.0 +var _ability_flash: float = 0.0 +var _time: float = 0.0 +var _last_visual: Vector3 +var _height: float = 1.6 + +static func _flat(color: Color, energy: float = 0.0) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = color + if energy > 0.0: + material.emission_enabled = true + material.emission = color + material.emission_energy_multiplier = energy + return material + +func bind(source: LuneUnit) -> void: + unit = source + name = "Unit_%d" % unit.id + var id: String = str(unit.def.get("id", "")) + model = AssetLibrary.instantiate_fighter(id) + if model == null: + model = EnemyVisualsFallback.make(TEAM_COLORS[unit.team]) + add_child(model) + skeletal = model.has_method("update_combat") + if model.has_method("get_aabb"): + _height = maxf(1.0, model.get_aabb().end.y) + var scale_factor: float = float(unit.def.get("view_scale", 1.0)) + model.scale = Vector3.ONE * scale_factor + _height *= scale_factor + _ring = MeshInstance3D.new() + var torus := TorusMesh.new() + torus.inner_radius = .42 * scale_factor + torus.outer_radius = .52 * scale_factor + torus.rings = 20 + torus.ring_segments = 4 + _ring.mesh = torus + _ring.material_override = _flat(TEAM_COLORS[unit.team], 1.4 if unit.kind != "monster" else .6) + _ring.scale = Vector3(1, .25, 1) + _ring.position.y = .05 + _ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(_ring) + _bars = Node3D.new() + _bars.position.y = _height + .25 + add_child(_bars) + var back := MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = Vector2(.9, .1) + back.mesh = quad + var back_material := _flat(Color(0, 0, 0, .7)) + back_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + back_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + back_material.no_depth_test = true + back.material_override = back_material + back.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _bars.add_child(back) + _fill = MeshInstance3D.new() + var fill_quad := QuadMesh.new() + fill_quad.size = Vector2(.86, .07) + fill_quad.center_offset = Vector3(.43, 0, 0) + _fill.mesh = fill_quad + var fill_material := _flat(TEAM_COLORS[unit.team].lightened(.15)) + fill_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + fill_material.billboard_keep_scale = true + fill_material.no_depth_test = true + fill_material.render_priority = 1 + _fill.material_override = fill_material + _fill.position = Vector3(-.43, 0, 0) + _fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _bars.add_child(_fill) + _last_attack = unit.attack_serial + _last_ability = unit.ability_serial + position = Vector3(unit.position.x, 0, unit.position.y) + rotation.y = unit.facing + _last_visual = position + +func sync(alpha: float, delta: float) -> void: + if unit == null: + return + _time += delta + var at: Vector2 = unit.previous_position.lerp(unit.position, clampf(alpha, 0.0, 1.0)) + position = Vector3(at.x, 0, at.y) + rotation.y = lerp_angle(rotation.y, unit.facing, minf(1.0, delta * 12.0)) + var moved: float = position.distance_to(_last_visual) + _last_visual = position + if unit.attack_serial != _last_attack: + _last_attack = unit.attack_serial + _attack_flash = .28 + if unit.ability_serial != _last_ability: + _last_ability = unit.ability_serial + _ability_flash = .5 + _attack_flash = maxf(0.0, _attack_flash - delta) + _ability_flash = maxf(0.0, _ability_flash - delta) + if skeletal: + model.rotation.y = 0.0 + model.update_combat(delta, moved, unit.in_range and unit.alive, unit.attack_progress, unit.hit_count, _ability_flash, unit.hit_flash, unit.alive) + elif model.has_method("pose"): + model.pose(_time + float(unit.id % 17) * .23, moved > .002, _attack_flash, _ability_flash, unit.hit_flash) + var fraction: float = clampf(unit.hp / maxf(1.0, unit.max_hp), 0.0, 1.0) + _fill.scale.x = maxf(.001, fraction) + _bars.visible = unit.alive and fraction < .999 + _ring.visible = unit.alive + # Rigid figures have no death clip; skeletal ones stay down in their death pose. + if not skeletal: + visible = unit.alive + +class EnemyVisualsFallback: + static func make(color: Color) -> Node3D: + var body := MeshInstance3D.new() + var capsule := CapsuleMesh.new() + capsule.radius = .35 + capsule.height = 1.4 + body.mesh = capsule + body.position.y = .7 + var material := StandardMaterial3D.new() + material.albedo_color = color + body.material_override = material + var root := Node3D.new() + root.add_child(body) + return root diff --git a/scripts/luneraria/view/lune_unit_view.gd.uid b/scripts/luneraria/view/lune_unit_view.gd.uid new file mode 100644 index 0000000..a9ace62 --- /dev/null +++ b/scripts/luneraria/view/lune_unit_view.gd.uid @@ -0,0 +1 @@ +uid://ctolavjaa60ir diff --git a/scripts/luneraria/view/lune_world.gd b/scripts/luneraria/view/lune_world.gd new file mode 100644 index 0000000..72b1a74 --- /dev/null +++ b/scripts/luneraria/view/lune_world.gd @@ -0,0 +1,303 @@ +extends Node3D +## 3D presentation of LuneMap built from existing assets: Kluft terrain shaders, the +## Saumkern models as Ancients, compare-pack rocks and trees, and the new lane tower. +## Purely visual; the simulation never reads anything from this node. + +const Lighting = preload("res://scripts/presentation/lighting.gd") +const Sanctuary = preload("res://scripts/presentation/sanctuary.gd") +const RiverShader = preload("res://scripts/presentation/river.gdshader") +const TOWER := "res://assets/luneraria/tower.glb" +const ROCK := "res://assets/compare/compare-rock.glb" +const TREE := "res://assets/compare/compare-tree.glb" +const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")] +const TEAM_NAMES: Array[String] = ["Blau", "Rot"] + +var map: LuneMap +var building_nodes: Dictionary = {} +var _terrain: Dictionary = {} +var _crystal_materials: Array[StandardMaterial3D] = [] + +func build(match_map: LuneMap) -> void: + map = match_map + name = "LuneWorld" + _environment() + _ground() + _river() + for lane: String in LuneMap.LANES: + _lane_ribbon(map.lanes[lane]) + for team: int in range(2): + _base_plaza(team) + _decoration() + for team: int in range(2): + var crystal := StandardMaterial3D.new() + crystal.albedo_color = TEAM_COLORS[team] + crystal.emission_enabled = true + crystal.emission = TEAM_COLORS[team] + crystal.emission_energy_multiplier = 2.2 + crystal.roughness = .15 + _crystal_materials.append(crystal) + for definition: Dictionary in map.buildings: + _building(definition) + +func terrain(key: String, color: Color) -> ShaderMaterial: + if not _terrain.has(key): + var source := StandardMaterial3D.new() + source.resource_name = key + source.albedo_color = color + _terrain[key] = Sanctuary.terrain_material(source) + return _terrain[key] + +func to3(v: Vector2, y: float = 0.0) -> Vector3: + return Vector3(v.x, y, v.y) + +func _environment() -> void: + var environment := WorldEnvironment.new() + var settings := Environment.new() + Lighting.configure(settings) + # The overview camera sits ~80 m away; distance fog would wash out the whole board. + settings.fog_enabled = false + environment.environment = settings + add_child(environment) + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-55, -30, 0) + sun.light_color = Color("ffe2bc") + sun.light_energy = 1.7 + sun.shadow_enabled = true + sun.directional_shadow_max_distance = 90.0 + sun.shadow_blur = 1.8 + add_child(sun) + var fill := DirectionalLight3D.new() + fill.rotation_degrees = Vector3(-35, 150, 0) + fill.light_color = Color("a8c4e8") + fill.light_energy = .45 + add_child(fill) + +func _ground() -> void: + var ground := MeshInstance3D.new() + var plane := PlaneMesh.new() + plane.size = Vector2(LuneMap.HALF_EXTENT * 2.0 + 30.0, LuneMap.HALF_EXTENT * 2.0 + 30.0) + plane.subdivide_width = 32 + plane.subdivide_depth = 32 + ground.mesh = plane + ground.material_override = terrain("ENV_soil", Color(.10, .15, .065)) + ground.name = "Ground" + add_child(ground) + +func _river() -> void: + # The river follows the symmetry axis x = z and passes under all three lanes. + var tool := SurfaceTool.new() + tool.begin(Mesh.PRIMITIVE_TRIANGLES) + var a := Vector2(-40, -40) + var b := Vector2(40, 40) + var side := Vector2(1, -1).normalized() * 3.2 + var steps: int = 40 + for i: int in range(steps): + # Godot front faces wind clockwise seen from above. + for quad: Array in [[i, -1], [i + 1, 1], [i + 1, -1], [i, -1], [i, 1], [i + 1, 1]]: + var t: float = float(quad[0]) / steps + var wobble: float = sin(t * 18.0) * .6 + var p: Vector2 = a.lerp(b, t) + side * (float(quad[1]) + wobble * .15) + tool.set_normal(Vector3.UP) + tool.add_vertex(to3(p, .012)) + var river := MeshInstance3D.new() + river.mesh = tool.commit() + var water := ShaderMaterial.new() + water.shader = RiverShader + river.material_override = water + river.name = "River" + add_child(river) + +func _lane_ribbon(path: LuneMap.LanePath) -> void: + var tool := SurfaceTool.new() + tool.begin(Mesh.PRIMITIVE_TRIANGLES) + var steps: int = int(ceil(path.length / .8)) + var half: float = map.balance.lane_half_width + .6 + var previous_left := Vector3.ZERO + var previous_right := Vector3.ZERO + for i: int in range(steps + 1): + var s: float = path.length * float(i) / steps + var point: Vector2 = path.point_at(s) + var tangent: Vector2 = (path.point_at(minf(path.length, s + .5)) - path.point_at(maxf(0.0, s - .5))).normalized() + var normal := Vector2(-tangent.y, tangent.x) + var left: Vector3 = to3(point + normal * half, .03) + var right: Vector3 = to3(point - normal * half, .03) + if i > 0: + for vertex: Vector3 in [previous_left, right, left, previous_left, previous_right, right]: + tool.set_normal(Vector3.UP) + tool.set_uv(Vector2(vertex.x, vertex.z) * .25) + tool.add_vertex(vertex) + previous_left = left + previous_right = right + var ribbon := MeshInstance3D.new() + ribbon.mesh = tool.commit() + ribbon.material_override = terrain("ENV_paving", Color(.19, .185, .15)) + ribbon.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(ribbon) + +func _base_plaza(team: int) -> void: + var plaza := MeshInstance3D.new() + var disc := CylinderMesh.new() + disc.top_radius = 9.5 + disc.bottom_radius = 9.8 + disc.height = .12 + disc.radial_segments = 48 + plaza.mesh = disc + plaza.material_override = terrain("ENV_limestone", Color(.25, .225, .17)) + plaza.position = to3(map.core_position(team), .02) + add_child(plaza) + var rim := MeshInstance3D.new() + var ring := TorusMesh.new() + ring.inner_radius = 9.35 + ring.outer_radius = 9.6 + ring.rings = 64 + rim.mesh = ring + var glow := StandardMaterial3D.new() + glow.albedo_color = TEAM_COLORS[team] + glow.emission_enabled = true + glow.emission = TEAM_COLORS[team] + glow.emission_energy_multiplier = 1.2 + rim.material_override = glow + rim.position = to3(map.core_position(team), .1) + rim.scale = Vector3(1, .3, 1) + add_child(rim) + +func _far_from_lanes(point: Vector2, margin: float) -> bool: + for lane: String in LuneMap.LANES: + var path: LuneMap.LanePath = map.lanes[lane] + if path.point_at(path.project(point)).distance_to(point) < margin: + return false + for team: int in range(2): + if map.core_position(team).distance_to(point) < 12.0: + return false + if absf(point.x - point.y) < 5.0: + return false + return true + +func _decoration() -> void: + # Scatter one half deterministically and mirror it, so decoration never favours a team. + var rng := RandomNumberGenerator.new() + rng.seed = 90210 + var tree: PackedScene = load(TREE) + var rock: PackedScene = load(ROCK) + var placed: Array[Vector2] = [] + var tries: int = 0 + while placed.size() < 70 and tries < 4000: + tries += 1 + var point := Vector2(rng.randf_range(-LuneMap.HALF_EXTENT - 4, LuneMap.HALF_EXTENT + 4), rng.randf_range(-LuneMap.HALF_EXTENT - 4, LuneMap.HALF_EXTENT + 4)) + if point.y < point.x or not _far_from_lanes(point, 5.2): + continue + var clear: bool = true + for other: Vector2 in placed: + if other.distance_to(point) < 2.6: + clear = false + break + if not clear: + continue + placed.append(point) + var is_tree: bool = rng.randf() < .62 + var size: float = rng.randf_range(1.1, 1.9) if is_tree else rng.randf_range(.8, 1.6) + var yaw: float = rng.randf() * TAU + for mirrored: bool in [false, true]: + var at: Vector2 = LuneMap.mirror(point) if mirrored else point + var holder := Node3D.new() + holder.position = to3(at) + holder.rotation.y = yaw if not mirrored else -yaw + holder.scale = Vector3.ONE * size + var prop: Node3D = (tree if is_tree else rock).instantiate() + # The compare-pack props were exported with their height along -Z; stand them up. + prop.rotation.x = PI * .5 + _dress(prop) + holder.add_child(prop) + add_child(holder) + +var _vertex_colored: StandardMaterial3D + +func _dress(node: Node) -> void: + if node is MeshInstance3D: + for surface: int in range(node.mesh.get_surface_count()): + var source := node.mesh.surface_get_material(surface) as StandardMaterial3D + if source != null and source.resource_name.begins_with("ENV_") and not source.resource_name.contains("brass"): + node.set_surface_override_material(surface, terrain(source.resource_name, source.albedo_color)) + elif source != null and source.resource_name.begins_with("CV_"): + # Compare-pack props carry their colours as vertex colours. + if _vertex_colored == null: + _vertex_colored = StandardMaterial3D.new() + _vertex_colored.vertex_color_use_as_albedo = true + _vertex_colored.roughness = .86 + node.set_surface_override_material(surface, _vertex_colored) + for child: Node in node.get_children(): + _dress(child) + +func _building(definition: Dictionary) -> void: + var root := Node3D.new() + root.name = str(definition.id) + root.position = to3(definition.position) + add_child(root) + var team: int = int(definition.team) + if definition.tier == "core": + var core: Node3D = AssetLibrary.instantiate_fighter("core_team%d" % team) + if core != null: + core.scale = Vector3.ONE * 1.35 + root.add_child(core) + if core.has_method("trigger_activate"): + core.trigger_activate() + root.set_meta("core", core) + else: + var tower: Node3D = (load(TOWER) as PackedScene).instantiate() + tower.scale = Vector3.ONE * (1.18 if definition.tier == "base" else 1.0) + _dress(tower) + _tint_crystal(tower, _crystal_materials[team]) + # Towers face the lane they guard. + var toward: Vector2 = map.core_position(1 - team) - Vector2(definition.position) + tower.rotation.y = atan2(toward.x, toward.y) + root.add_child(tower) + root.set_meta("tower", tower) + var light := OmniLight3D.new() + light.position = Vector3(0, 5.2 if definition.tier != "core" else 2.4, 0) + light.light_color = TEAM_COLORS[team] + light.light_energy = 1.2 + light.omni_range = 6.0 + root.add_child(light) + root.set_meta("light", light) + var bar := Label3D.new() + bar.billboard = BaseMaterial3D.BILLBOARD_ENABLED + bar.no_depth_test = true + bar.font_size = 34 + bar.outline_size = 8 + bar.pixel_size = .01 + bar.modulate = TEAM_COLORS[team].lightened(.25) + bar.position = Vector3(0, 7.4 if definition.tier != "core" else 4.4, 0) + root.add_child(bar) + root.set_meta("bar", bar) + building_nodes[definition.id] = root + +func _tint_crystal(node: Node, crystal: StandardMaterial3D) -> void: + if node is MeshInstance3D: + for surface: int in range(node.mesh.get_surface_count()): + var source: Material = node.mesh.surface_get_material(surface) + if source != null and source.resource_name == "TW_Crystal": + node.set_surface_override_material(surface, crystal) + for child: Node in node.get_children(): + _tint_crystal(child, crystal) + +## Health text and destroyed state from the authoritative building HP. +func sync_buildings(hp: Dictionary, max_hp: Dictionary, time: float, delta: float) -> void: + for id: String in building_nodes: + var root: Node3D = building_nodes[id] + var value: float = float(hp.get(id, 0.0)) + var bar: Label3D = root.get_meta("bar") + var alive: bool = value > 0.0 + bar.visible = alive and value < float(max_hp[id]) - .5 + bar.text = "%d%%" % ceili(value / float(max_hp[id]) * 100.0) + (root.get_meta("light") as OmniLight3D).visible = alive + if root.has_meta("tower"): + var tower: Node3D = root.get_meta("tower") + tower.position.y = move_toward(tower.position.y, 0.0 if alive else -3.2, delta * 2.0) + tower.rotation.z = move_toward(tower.rotation.z, 0.0 if alive else .22, delta * .3) + elif root.has_meta("core"): + var core: Node3D = root.get_meta("core") + if core.has_method("set_core_health"): + core.set_core_health(value / float(max_hp[id])) + if core.has_method("pose"): + core.pose(time, false, 0, 0, 0, delta) + core.visible = alive diff --git a/scripts/luneraria/view/lune_world.gd.uid b/scripts/luneraria/view/lune_world.gd.uid new file mode 100644 index 0000000..c4f76cd --- /dev/null +++ b/scripts/luneraria/view/lune_world.gd.uid @@ -0,0 +1 @@ +uid://i4b3g0pv7e0x diff --git a/scripts/ui/main_menu.gd b/scripts/ui/main_menu.gd index 36945c7..d9de8ad 100644 --- a/scripts/ui/main_menu.gd +++ b/scripts/ui/main_menu.gd @@ -50,6 +50,13 @@ var _advanced_button: Button var _advanced_summary: Label var _timed_hint: Label var _title_caption: Label +## Exposed so a derived menu (Champions of Luneraria) can retitle the shell. +var title_label: Label +var subtitle_label: Label +var lore_label: Label +var emblem_rect: TextureRect +var version_label: Label +var brand_names: VBoxContainer var _style_heading: Label var _style_facts: Label var _setup_caption: Label @@ -178,14 +185,16 @@ func _build_title() -> void: var brand := HBoxContainer.new() brand.add_theme_constant_override("separation", 22) column.add_child(brand) - _emblem("res://assets/ui/emblems/saumwacht.svg", 112, brand) + emblem_rect = _emblem("res://assets/ui/emblems/saumwacht.svg", 112, brand) var names := VBoxContainer.new() names.alignment = BoxContainer.ALIGNMENT_CENTER names.add_theme_constant_override("separation", 0) brand.add_child(names) - _label("SAUMWACHT", "TitleLabel", names) - _label("DIE GRÜNE KLUFT", "SubtitleLabel", names) + brand_names = names + title_label = _label("SAUMWACHT", "TitleLabel", names) + subtitle_label = _label("DIE GRÜNE KLUFT", "SubtitleLabel", names) var lore := _label("Stelle deine Wächter auf, schütze den Saumkern und überstehe den Rivalen.", "LoreLabel", column) + lore_label = lore lore.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART lore.custom_minimum_size.x = 560 var gap := Control.new() @@ -208,6 +217,7 @@ func _build_title() -> void: var quit := _button("Spiel beenden", "QuietButton", column, func() -> void: quit_requested.emit()) quit.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN var version := _label("Demo %s · Einzelspieler" % str(ProjectSettings.get_setting("application/config/version", "")), "MutedLabel", _title_screen) + version_label = version version.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT) version.offset_left = -360 version.offset_right = -40 @@ -470,7 +480,7 @@ func show_screen(next: int, instant: bool = false) -> void: var from: Control = _title_screen if screen == Screen.TITLE else (_setup_screen if screen == Screen.SETUP else null) var to: Control = _title_screen if next == Screen.TITLE else _setup_screen screen = next - stage.set_shot_named("title" if next == Screen.TITLE else GameState.factions[0], instant or reduced_motion) + stage.set_shot_named("title" if next == Screen.TITLE else _setup_shot(), instant or reduced_motion) _title_scrim.visible = next == Screen.TITLE _setup_scrim.visible = next == Screen.SETUP if _fade != null and _fade.is_valid(): @@ -496,6 +506,10 @@ func show_screen(next: int, instant: bool = false) -> void: from.modulate.a = 1.0) _focus_default.call_deferred() +## Stage shot for the setup screen; derived menus pick their own Bund source. +func _setup_shot() -> String: + return GameState.factions[0] + func _focus_default() -> void: if not is_visible_in_tree(): return diff --git a/tests/luneraria/test_lune_core.gd b/tests/luneraria/test_lune_core.gd index bb1b92e..931e615 100644 --- a/tests/luneraria/test_lune_core.gd +++ b/tests/luneraria/test_lune_core.gd @@ -28,6 +28,7 @@ func run() -> void: _test_rewards() _test_persistence_and_restart() _test_full_match() + _test_mirror_fairness() var report := {"checks": checks, "failures": failures} var file := FileAccess.open("res://docs/luneraria/logs/core-tests.json", FileAccess.WRITE) file.store_string(JSON.stringify(report, "\t")) @@ -295,3 +296,26 @@ func _test_full_match() -> void: var cores_down: int = int(state.core_hp(0) <= 0.0) + int(state.core_hp(1) <= 0.0) check(cores_down >= 1, "the result follows an actually destroyed Ancient") print("LUNE_FULL_MATCH: seed 2 -> %s after %d rounds" % ["draw" if state.winner == LuneMatch.DRAW else ("Blau" if state.winner == 0 else "Rot"), state.history.size()]) + +func _test_mirror_fairness() -> void: + # Identical Bund and deterministic bot profile on all six slots: the match must stay an + # exact mirror image. Any drift would reveal an order- or id-dependent team advantage. + for bund: String in ["glutpakt", "wurzelbund"]: + var state := LuneMatch.new() + state.setup({"seed": 3, "human_slot": -1, "bunds": [bund, bund, bund, bund, bund, bund]}) + var mirrored: bool = true + for round_index: int in range(12): + for slot: int in range(6): + LuneBot.plan(state, slot, "verteidigung") + state.begin_combat() + state.finish_combat() + for definition: Dictionary in state.map.buildings: + if definition.team == 0: + var twin: String = str(definition.id).replace("blau", "rot") + mirrored = mirrored and is_equal_approx(float(state.building_hp[definition.id]), float(state.building_hp[twin])) + for slot: int in range(3): + mirrored = mirrored and int(state.players[slot].gold) == int(state.players[slot + 3].gold) + if state.phase == LuneMatch.Phase.OVER: + break + check(mirrored, "%s mirror match stays exactly symmetric for 12 rounds (buildings and gold)" % bund) + check(state.phase != LuneMatch.Phase.OVER or state.winner == LuneMatch.DRAW, "%s mirror match never produces a one-sided winner" % bund) diff --git a/tests/luneraria/test_lune_core.gd.uid b/tests/luneraria/test_lune_core.gd.uid new file mode 100644 index 0000000..021e7c6 --- /dev/null +++ b/tests/luneraria/test_lune_core.gd.uid @@ -0,0 +1 @@ +uid://bko3fb3fg67th diff --git a/tools/luneraria/lune_benchmark.gd b/tools/luneraria/lune_benchmark.gd new file mode 100644 index 0000000..3f386e9 --- /dev/null +++ b/tools/luneraria/lune_benchmark.gd @@ -0,0 +1,74 @@ +extends Node +## Frame and simulation cost in a real match scene at representative maximum occupation: +## six armies at the supply cap and a round-13 wave (synthetic fixture, gold granted to the +## fixture only). VSync off; figures apply to this machine only. +## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/lune_benchmark.tscn +const Game = preload("res://scripts/luneraria/lune_game.gd") +func _ready() -> void: + call_deferred("run") +func run() -> void: + Soundscape.enabled = false + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) + var t0: int = Time.get_ticks_usec() + var game: Node3D = Game.new() + add_child(game) + game.start({"seed": 5, "human_slot": 0}) + var build_ms: float = (Time.get_ticks_usec() - t0) / 1000.0 + var state: LuneMatch = game.match_state + state.round_number = 13 + for slot: int in range(6): + state.players[slot].draft.gold = 100000 + state.players[slot].draft.roster.clear() + LuneBot.plan(state, slot, "ausgewogen") + game._on_ready() + game.camera.look_at_point(Vector3(-10, 0, 10), 34.0, true) + for i in 30: await get_tree().process_frame + RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true) + var frames: Array[float] = [] + var gpu: Array[float] = [] + var sim: Array[float] = [] + var draws: Array[float] = [] + var mobile: Array[int] = [] + var last: int = Time.get_ticks_usec() + var end: int = Time.get_ticks_msec() + 8000 + game.speed_index = 1 + while Time.get_ticks_msec() < end and state.phase == LuneMatch.Phase.COMBAT: + var s0: int = Time.get_ticks_usec() + var before: int = state.combat.steps + await RenderingServer.frame_post_draw + var now: int = Time.get_ticks_usec() + frames.append((now - last) / 1000.0) + last = now + gpu.append(RenderingServer.viewport_get_measured_render_time_gpu(get_viewport().get_viewport_rid())) + draws.append(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)) + mobile.append(state.combat.mobile_count()) + # Pure simulation cost of one fixed step at this occupation, measured separately. + var probe := LuneMatch.new() + probe.setup({"seed": 5, "human_slot": -1}) + probe.round_number = 13 + for slot: int in range(6): + probe.players[slot].draft.gold = 100000 + LuneBot.plan(probe, slot, "ausgewogen") + probe.begin_combat() + var start_units: int = probe.combat.mobile_count() + var s1: int = Time.get_ticks_usec() + for i in 200: + probe.step_combat() + var step_ms: float = (Time.get_ticks_usec() - s1) / 1000.0 / 200.0 + frames.sort() + var mean := func(v: Array) -> float: + var t: float = 0.0 + for x in v: t += float(x) + return t / maxf(1, v.size()) + var report := {"window": get_window().size, "renderer": RenderingServer.get_current_rendering_method(), "gpu": RenderingServer.get_video_adapter_name(), + "cpu": OS.get_processor_name(), "engine": Engine.get_version_info().string, "scene_build_ms": build_ms, + "mobile_units_at_start": start_units, "mobile_units_mean_measured": mean.call(mobile), "buildings": 18, + "frames": frames.size(), "frame_ms_mean": mean.call(frames), "frame_ms_p95": frames[int(frames.size() * .95)] if frames.size() > 0 else -1, + "gpu_ms_mean": mean.call(gpu), "draw_calls_mean": mean.call(draws), "sim_step_ms": step_ms, "sim_speed": "2x (40 steps/s)", + "video_mem_mb": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0, + "static_mem_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0} + var file := FileAccess.open("res://docs/luneraria/logs/perf-%dx%d.json" % [get_window().size.x, get_window().size.y], FileAccess.WRITE) + file.store_string(JSON.stringify(report, "\t")) + file.close() + print("LUNE_BENCH ", JSON.stringify(report)) + get_tree().quit() diff --git a/tools/luneraria/lune_benchmark.tscn b/tools/luneraria/lune_benchmark.tscn new file mode 100644 index 0000000..9155656 --- /dev/null +++ b/tools/luneraria/lune_benchmark.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/luneraria/lune_benchmark.gd" id="1"] + +[node name="LuneBench" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/lune_review.gd b/tools/luneraria/lune_review.gd new file mode 100644 index 0000000..75a068f --- /dev/null +++ b/tools/luneraria/lune_review.gd @@ -0,0 +1,40 @@ +extends Node +## Real-app captures: menu, planning, lane combat, overview, destroyed core and result. +## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/lune_review.tscn +var main: Node +var out := "res://docs/luneraria/screenshots/" +func _ready() -> void: + call_deferred("run") +func shot(name: String, frames: int = 12) -> void: + for i in frames: await get_tree().process_frame + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png(out + name + ".png") + print("LUNE_SHOT ", name) +func run() -> void: + Soundscape.enabled = false + main = load("res://scenes/luneraria.tscn").instantiate() + add_child(main) + var menu: MainMenu = main.menu + while not (menu.stage.is_ready("glutpakt") and menu.stage.is_ready("wurzelbund")): + await get_tree().process_frame + await shot("menu-title", 30) + menu.new_game_button.pressed.emit() + await get_tree().create_timer(1.8).timeout + await shot("menu-setup") + main.start_match({"seed": 4}) + await get_tree().create_timer(.6).timeout + var game: Node3D = main.game + game.hud._starter.pressed.emit() + await shot("planning", 20) + game.camera.overview(true) + await shot("overview-planning", 20) + game._on_ready() + game.focus_lane("mid", true) + await get_tree().create_timer(9.0).timeout + await shot("combat-mid") + game.focus_lane("top", true) + await shot("combat-top") + game.camera.overview(true) + await shot("overview-combat") + print("LUNE_REVIEW_DONE") + get_tree().quit() diff --git a/tools/luneraria/lune_review.tscn b/tools/luneraria/lune_review.tscn new file mode 100644 index 0000000..c527b9d --- /dev/null +++ b/tools/luneraria/lune_review.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/luneraria/lune_review.gd" id="1"] + +[node name="LuneReview" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/verify_m1.gd b/tools/luneraria/verify_m1.gd new file mode 100644 index 0000000..9734392 --- /dev/null +++ b/tools/luneraria/verify_m1.gd @@ -0,0 +1,246 @@ +extends Node +## M1 acceptance in the real application with real mouse and key events: menu, planning, +## commit, combat, pause/speed, settlement, return to menu, restart, a complete match to an +## Ancient's fall, result screen, rematch, window sizes and repeated menu/match cycles. +## Only the combat clock is fast-forwarded (more fixed steps per frame); rules are untouched. +## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/verify_m1.tscn +var main: Node +var checks: int = 0 +var failures: Array[String] = [] +var out := "res://docs/luneraria/screenshots/" + +func check(ok: bool, label: String) -> void: + checks += 1 + if not ok: + failures.append(label) + printerr("M1_FAIL: ", label) + +func _ready() -> void: + call_deferred("run") + +func frames(count: int = 3) -> void: + for i: int in range(count): + await get_tree().process_frame + +func shot(name: String) -> void: + await frames(8) + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png(out + name + ".png") + print("M1_SHOT ", name) + +func click(control: Control) -> void: + var at: Vector2 = control.get_global_rect().get_center() + var motion := InputEventMouseMotion.new() + motion.position = at + get_viewport().push_input(motion, true) + await get_tree().process_frame + for pressed: bool in [true, false]: + var event := InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.position = at + event.pressed = pressed + get_viewport().push_input(event, true) + await get_tree().process_frame + +func key(code: Key) -> void: + for pressed: bool in [true, false]: + var event := InputEventKey.new() + event.keycode = code + event.physical_keycode = code + event.pressed = pressed + get_viewport().push_input(event) + await get_tree().process_frame + +var core_shot_taken: bool = false + +func fast_combat(game: Node3D, steps_per_frame: int = 40, capture: String = "") -> void: + var captured: bool = capture.is_empty() + while game.match_state.phase == LuneMatch.Phase.COMBAT: + for i: int in range(steps_per_frame): + if game.match_state.phase != LuneMatch.Phase.COMBAT: + break + # Slow down near an Ancient's fall to capture the siege in the live combat view. + if not core_shot_taken and _weak_core(game) >= 0: + core_shot_taken = true + var team: int = _weak_core(game) + var at: Vector2 = game.match_state.map.core_position(team) + game.camera.look_at_point(Vector3(at.x, 0, at.y), 22.0, true) + await shot("core-siege") + game.match_state.step_combat() + await get_tree().process_frame + if not captured and game.match_state.phase == LuneMatch.Phase.COMBAT and game.match_state.combat.elapsed > 25.0: + captured = true + await shot(capture) + +func _weak_core(game: Node3D) -> int: + var combat: LuneCombat = game.match_state.combat + for team: int in range(2): + var core: LuneUnit = combat.buildings["blau_ancient" if team == 0 else "rot_ancient"] + if core.alive and core.hp < core.max_hp * .3: + return team + return -1 + +func inside(control: Control, label: String) -> void: + var view := Rect2(Vector2.ZERO, get_viewport().get_visible_rect().size) + check(control.is_visible_in_tree() and view.encloses(control.get_global_rect()), label) + +func run() -> void: + Soundscape.enabled = false + var started: int = Time.get_ticks_msec() + main = load("res://scenes/luneraria.tscn").instantiate() + add_child(main) + var menu: MainMenu = main.menu + check(ProjectSettings.get_setting("application/config/name") == "Champions of Luneraria", "project renamed to Champions of Luneraria") + check(menu.title_label.text == "CHAMPIONS" and menu.subtitle_label.text.contains("LUNERARIA"), "menu shows the new title") + while not (menu.stage.is_ready("glutpakt") and menu.stage.is_ready("wurzelbund")): + await get_tree().process_frame + print("M1_MENU_READY_MS ", Time.get_ticks_msec() - started) + await click(menu.new_game_button) + while menu.is_transitioning(): + await get_tree().process_frame + await click(menu.lane_choice.get_button(0)) + check(menu.lane == "top", "lane choice by click") + await click(menu.faction_choice.cards[0]) + check(menu.bund == "glutpakt", "Bund choice by click") + await click(menu.start_button) + var game: Node3D = main.game + check(is_instance_valid(game) and game.human_slot == 0 and game.match_state.players[0].bund == "glutpakt", "start button launches a match for Blau Top with the chosen Bund") + var focus_before: Vector3 = game.camera._goal_focus + # Clicks and game keys during the fade must not reach the board. + var event := InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_WHEEL_UP + event.pressed = true + event.position = Vector2(900, 500) + get_viewport().push_input(event, true) + await key(KEY_1) + await key(KEY_SPACE) + check(game.match_state.players[0].draft.roster.is_empty() and game.match_state.phase == LuneMatch.Phase.PLANNING and is_equal_approx(game.camera._goal_zoom, 24.0), "no click or key reaches the game during the menu fade") + await get_tree().create_timer(.5).timeout + check(not menu.visible, "menu hides after the fade") + var state: LuneMatch = game.match_state + var hud: Control = game.hud + check(state.players.size() == 6 and state.players.filter(func(p: Dictionary) -> bool: return p.is_human).size() == 1, "one human and five bots") + check(state.round_number == 1 and state.phase == LuneMatch.Phase.PLANNING, "match opens in planning") + var gold0: int = state.gold_of(0) + await click(hud._starter) + check(state.players[0].draft.roster.size() == 3 and state.gold_of(0) == gold0 - 180, "Startaufstellung buys three Glutpakt troops for 180 gold") + await key(KEY_1) + check(state.players[0].draft.roster.size() == 3, "key 1 without enough gold is rejected") + await click(hud._undo) + check(state.players[0].draft.roster.size() == 2, "undo removes the last purchase") + await frames(4) + var lane_button: Button = null + for child: Node in hud._roster.get_children(): + for grand: Node in child.get_children(): + if grand is Button and grand.text == "M": + lane_button = grand + break + if lane_button != null: + break + await click(lane_button) + check(state.players[0].draft.roster.any(func(e: Dictionary) -> bool: return e.lane == "mid"), "lane assignment button moves a troop to Mid") + await click(hud._shop_buttons["g01"]) + check(state.players[0].draft.roster.size() == 3, "shop click buys a troop") + for size: Vector2i in [Vector2i(1280, 720), Vector2i(1920, 1080)]: + get_window().size = size + await frames(6) + inside(hud._ready_button, "Bereit inside %dx%d" % [size.x, size.y]) + inside(hud.minimap, "minimap inside %dx%d" % [size.x, size.y]) + inside(hud._gold, "gold readout inside %dx%d" % [size.x, size.y]) + await shot("planning-%dx%d" % [size.x, size.y]) + var draft_uids: Array = state.players[0].draft.roster.map(func(e: Dictionary) -> String: return e.uid) + await key(KEY_SPACE) + check(state.phase == LuneMatch.Phase.COMBAT, "Space commits planning and starts combat") + check(state.players[0].roster.map(func(e: Dictionary) -> String: return e.uid) == draft_uids, "committed roster equals the draft") + var troops_on_top: int = 0 + var troops_on_mid: int = 0 + for unit: LuneUnit in state.combat.units: + if unit.owner_slot == 0: + troops_on_top += int(unit.lane == "top") + troops_on_mid += int(unit.lane == "mid") + check(troops_on_top == 2 and troops_on_mid == 1, "troops fight on their assigned lanes") + check(not state.submit(0, {"type": "buy", "type_id": "g01"}).ok, "no purchases during combat") + await get_tree().create_timer(1.0).timeout + await key(KEY_P) + var paused_time: float = state.combat.elapsed + await get_tree().create_timer(.5).timeout + check(game.paused and is_equal_approx(state.combat.elapsed, paused_time), "P pauses the combat clock") + await key(KEY_P) + await click(hud._speed) + check(game.SPEEDS[game.speed_index] == 2, "speed button switches to 2x") + game.focus_lane("mid", true) + await shot("combat-mid-live") + await fast_combat(game) + check(state.phase == LuneMatch.Phase.PLANNING and state.round_number == 2, "combat settles into planning round 2") + check(state.players[0].roster.size() == 3, "troops return after combat") + check(not state.last_settlement.is_empty() and int(state.last_settlement.income) > 0, "settlement pays income") + await frames(4) + check(hud._report.text.begins_with("Welle 1 abgeschlossen"), "HUD reports the settlement") + await shot("settlement") + # Menu and restart: a fresh match without previous progress. + await click(find_menu_button(hud)) + await frames(4) + check(menu.visible and not is_instance_valid(game), "Menü returns to the title and frees the match") + await click(menu.new_game_button) + while menu.is_transitioning(): + await get_tree().process_frame + await click(menu.start_button) + await get_tree().create_timer(.5).timeout + game = main.game + state = game.match_state + hud = game.hud + check(state.round_number == 1 and state.players[0].roster.is_empty() and state.gold_of(0) == state.balance.start_gold, "new match starts without previous progress") + # A complete match: the human's planning uses the same bot planner through the same commands. + var rounds: int = 0 + var shots_taken: Dictionary = {} + while state.phase != LuneMatch.Phase.OVER and rounds < 60: + LuneBot.plan(state, game.human_slot, "druck") + game._on_ready() + if not shots_taken.has("lane") and state.round_number >= 3: + shots_taken.lane = true + game.focus_lane("top", true) + await fast_combat(game, 40, "combat-top") + elif not shots_taken.has("overview") and state.round_number >= 5: + shots_taken.overview = true + game.camera.overview(true) + await fast_combat(game, 40, "overview-combat") + else: + await fast_combat(game) + rounds += 1 + check(state.phase == LuneMatch.Phase.OVER, "a complete match ends by an Ancient's fall (%d rounds)" % rounds) + var destroyed: Array = state.combat.destroyed_cores + check(destroyed.size() >= 1 and state.core_hp(destroyed[0]) <= 0.0, "the losing Ancient is actually destroyed") + check(core_shot_taken, "the siege of the falling Ancient was captured live") + await get_tree().create_timer(.8).timeout + await shot("result") + check(hud._over.visible and hud._over_title.text in ["SIEG", "NIEDERLAGE", "UNENTSCHIEDEN"], "result screen shows the outcome") + print("M1_MATCH_RESULT ", state.end_note, " after ", state.round_number, " waves") + var rematch: Button = null + for node: Node in hud._over.find_children("*", "Button", true, false): + if (node as Button).text == "NEUE PARTIE": + rematch = node + await click(rematch) + await frames(3) + check(is_instance_valid(main.game) and main.game != game and main.game.match_state.round_number == 1, "rematch starts a fresh match") + # Repeated menu/match cycles must not leak objects. + var objects: Array[float] = [] + for cycle: int in range(4): + main.return_to_menu() + await frames(6) + main.start_match({"seed": 100 + cycle}) + await get_tree().create_timer(.5).timeout + await frames(4) + objects.append(Performance.get_monitor(Performance.OBJECT_COUNT)) + check(objects[3] <= objects[1] * 1.03 + 50, "no growing object count over menu/match cycles (%s)" % str(objects)) + var report := {"checks": checks, "failures": failures, "object_counts": objects} + var file := FileAccess.open("res://docs/luneraria/logs/m1-acceptance.json", FileAccess.WRITE) + file.store_string(JSON.stringify(report, "\t")) + file.close() + print("M1_VERIFY_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()]) + get_tree().quit(0 if failures.is_empty() else 1) + +func find_menu_button(hud: Control) -> Button: + for node: Node in hud.find_children("*", "Button", true, false): + if (node as Button).text == "Menü" and (node as Button).is_visible_in_tree(): + return node + return null diff --git a/tools/luneraria/verify_m1.tscn b/tools/luneraria/verify_m1.tscn new file mode 100644 index 0000000..4c3c28e --- /dev/null +++ b/tools/luneraria/verify_m1.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/luneraria/verify_m1.gd" id="1"] + +[node name="VerifyM1" type="Node"] +script = ExtResource("1")