M2: Helden, Items, Talente, Angriffschampions und Bossgrotte

Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen,
drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall,
ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das
Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt
aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73,
M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Martin Stickelbroeck 2026-09-27 22:33:11 +02:00
parent c27a0097c3
commit f53859a298
69 changed files with 6052 additions and 92 deletions

Binary file not shown.

BIN
assets/luneraria/grotto.glb Normal file

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://g2wd2l3wkvb7"
path="res://.godot/imported/grotto.glb-415ef169549c859f990847e218fcf14a.scn"
[deps]
source_file="res://assets/luneraria/grotto.glb"
dest_files=["res://.godot/imported/grotto.glb-415ef169549c859f990847e218fcf14a.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View file

@ -0,0 +1,18 @@
{
"assets": [
{
"id": "boss_grotto",
"resource_path": "res://assets/luneraria/grotto.glb",
"source": "assets-source/luneraria/grotto.blend",
"triangles": 1888,
"sha256": "92e7a4b4c98ebafb167523fb2816d91e997add201bf223ab6e9c8326bf77daf9",
"materials": [
"ENV_soil",
"ENV_cliff",
"GR_Shadow",
"GR_Crystal",
"ENV_limestone"
]
}
]
}

View file

@ -15,12 +15,12 @@ Erzeugt mit `python3 tools/luneraria/build_asset_matrix.py` aus den Laufzeitmani
| e07 | Bruchhirsch | res://assets/models/enemy_e07.glb | assets-source/phase4/blend/e07.blend | starre Gelenkgruppen | 33800 | Boss Welle 5 | Reserve (Wellen-Elite ab höheren Indizes) | Riftbrut | HD, starre Teile | offen |
| e08 | Riegelkoloss | res://assets/models/enemy_e08.glb | assets-source/phase4/blend/e08.blend | starre Gelenkgruppen | 81240 | Boss Welle 8 | Reserve (Wellen-Elite) | Riftbrut | HD, starre Teile | offen |
| e09 | Maskenrichter | res://assets/models/enemy_e09.glb | assets-source/phase4/blend/e09.blend | starre Gelenkgruppen | 57310 | Boss Welle 15 | Reserve | Riftbrut | HD, starre Teile | offen |
| e10 | Schlundkrone | res://assets/models/enemy_e10.glb | assets-source/phase4/blend/e10.blend | starre Gelenkgruppen | 64694 | Boss Welle 20 | M2: neutraler Grottenboss und entsandter Boss | Riftbrut / Grotte | HD, starre Teile | Bossdarstellung, Skalierung |
| e10 | Schlundkrone | res://assets/models/enemy_e10.glb | assets-source/phase4/blend/e10.blend | starre Gelenkgruppen | 64694 | Boss Welle 20 | neutraler Grottenboss und entsandter Boss (×1,7) | Riftbrut / Grotte | HD, starre Teile | umgesetzt (M2) |
| g01 | Kohlenbeißer | res://assets/models/unit_g01.glb | assets-source/phase4/blend/g01.blend | starre Gelenkgruppen | 79780 | Glutpakt Nahkampf | Truppe Nahkampf | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g02 | Schlackenklaue | res://assets/models/unit_g02.glb | assets-source/phase4/blend/g02.blend | starre Gelenkgruppen | 75624 | Glutpakt Nahkampf+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g03 | Schlackenwacht | res://assets/models/unit_g03.glb | assets-source/phase4/blend/g03.blend | starre Gelenkgruppen | 67232 | Glutpakt Tank | Truppe Front | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g04 | Basaltbastion | res://assets/characters/basaltbastion.glb | assets-source/phase15/basaltbastion.blend | 17 Knochen · 9 Clips | 17824 | Glutpakt Tank+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase15) | keiner |
| g05 | Funkenlanze | res://assets/characters/funkenlanze.glb | assets-source/phase18/funkenlanze.blend | 26 Knochen · 9 Clips | 24036 | Glutpakt Fernkampf | Truppe Fernkampf; M2 zusätzlich Held „Funkenlanze“ (vorläufig, mit Heldenmarkierung) | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase18) | Heldenkennzeichnung in der Darstellung |
| g05 | Funkenlanze | res://assets/characters/funkenlanze.glb | assets-source/phase18/funkenlanze.blend | 26 Knochen · 9 Clips | 24036 | Glutpakt Fernkampf | Truppe Fernkampf; zusätzlich Held „Funkenlanze“ (vorläufig; Goldring, Namensschild, ×1,25) | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase18) | umgesetzt (M2) |
| g06 | Glasballiste | res://assets/characters/glasballiste.glb | assets-source/phase19/glasballiste.blend | 20 Knochen · 9 Clips | 15784 | Glutpakt Fernkampf+ | Truppenaufwertung (Belagerung) | Glutkonstrukt / Glutpakt | gewichtetes Skelett (phase19) | keiner |
| g07 | Aschenwerfer | res://assets/models/unit_g07.glb | assets-source/phase4/blend/g07.blend | starre Gelenkgruppen | 81492 | Glutpakt Fläche | Truppe Fläche | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
| g08 | Kraterrufer | res://assets/models/unit_g08.glb | assets-source/phase4/blend/g08.blend | starre Gelenkgruppen | 77130 | Glutpakt Fläche+ | Truppenaufwertung | Glutkonstrukt / Glutpakt | HD, starre Teile | keiner |
@ -48,24 +48,25 @@ Erzeugt mit `python3 tools/luneraria/build_asset_matrix.py` aus den Laufzeitmani
| n09 | Maskenkantor | res://assets/models/unit_n09.glb | assets-source/phase4/blend/n09.blend | starre Gelenkgruppen | 1314 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| n10 | Vielstimmenkantor | res://assets/models/unit_n10.glb | assets-source/phase4/blend/n10.blend | starre Gelenkgruppen | 1734 | nicht spielbar, niedrige Detailstufe | Reserve für spätere Völker | Nebelchor (nicht spielbar) | niedrige Detailstufe | Neuaufbau nötig vor Nutzung |
| s01 | Nadelspäher | res://assets/models/unit_s01.glb | assets-source/phase4/blend/s01.blend | starre Gelenkgruppen | 10340 | Söldner | Reserve Angriffschampion | Söldner | HD, starre Teile | offen |
| s02 | Rostschieber | res://assets/models/unit_s02.glb | assets-source/phase4/blend/s02.blend | starre Gelenkgruppen | 12040 | Söldner | M2: Angriffschampion Schutz | Söldner | HD, starre Teile | keiner |
| s03 | Dunstsänger | res://assets/models/unit_s03.glb | assets-source/phase4/blend/s03.blend | starre Gelenkgruppen | 7832 | Söldner | M2: Angriffschampion Druck auf Unterstützer | Söldner | HD, starre Teile | keiner |
| s04 | Keilbrecher | res://assets/models/unit_s04.glb | assets-source/phase4/blend/s04.blend | starre Gelenkgruppen | 10458 | Söldner | M2: Angriffschampion Belagerung | Söldner | HD, starre Teile | keiner |
| s02 | Rostschieber | res://assets/models/unit_s02.glb | assets-source/phase4/blend/s02.blend | starre Gelenkgruppen | 12040 | Söldner | Angriffschampion Schutz (Rostschieber) | Söldner | HD, starre Teile | umgesetzt (M2) |
| s03 | Dunstsänger | res://assets/models/unit_s03.glb | assets-source/phase4/blend/s03.blend | starre Gelenkgruppen | 7832 | Söldner | Angriffschampion Druck auf Unterstützer (Dunstsänger) | Söldner | HD, starre Teile | umgesetzt (M2) |
| s04 | Keilbrecher | res://assets/models/unit_s04.glb | assets-source/phase4/blend/s04.blend | starre Gelenkgruppen | 10458 | Söldner | Angriffschampion Belagerung (Keilbrecher) | Söldner | HD, starre Teile | umgesetzt (M2) |
| s05 | Risszehrer | res://assets/models/unit_s05.glb | assets-source/phase4/blend/s05.blend | starre Gelenkgruppen | 10440 | Söldner | Reserve Angriffschampion | Söldner | HD, starre Teile | offen |
| w01 | Dornhopser | res://assets/models/unit_w01.glb | assets-source/phase4/blend/w01.blend | starre Gelenkgruppen | 61376 | Wurzelbund Nahkampf | Truppe Nahkampf | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w02 | Rankenschnapper | res://assets/models/unit_w02.glb | assets-source/phase4/blend/w02.blend | starre Gelenkgruppen | 78020 | Wurzelbund Nahkampf+ | Truppenaufwertung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w03 | Korkrücken | res://assets/models/unit_w03.glb | assets-source/phase4/blend/w03.blend | starre Gelenkgruppen | 84786 | Wurzelbund Tank | Truppe Front | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w04 | Ringborke | res://assets/characters/ringborke.glb | assets-source/phase11/ringborke.blend | 27 Knochen · 9 Clips | 118064 | Wurzelbund Tank+ | Truppenaufwertung; M2 zusätzlich Held „Ringborke“ (vorläufig) | Wurzelwesen / Wurzelbund | gewichtetes Skelett (phase11) | Heldenkennzeichnung |
| w04 | Ringborke | res://assets/characters/ringborke.glb | assets-source/phase11/ringborke.blend | 27 Knochen · 9 Clips | 118064 | Wurzelbund Tank+ | Truppenaufwertung; zusätzlich Held „Ringborke“ (vorläufig) | Wurzelwesen / Wurzelbund | gewichtetes Skelett (phase11) | umgesetzt (M2) |
| w05 | Samenschleuder | res://assets/models/unit_w05.glb | assets-source/phase4/blend/w05.blend | starre Gelenkgruppen | 50536 | Wurzelbund Fernkampf | Truppe Fernkampf | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w06 | Zapfenkrone | res://assets/models/unit_w06.glb | assets-source/phase4/blend/w06.blend | starre Gelenkgruppen | 62588 | Wurzelbund Fernkampf+ | Truppenaufwertung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w07 | Sporenkelch | res://assets/models/unit_w07.glb | assets-source/phase4/blend/w07.blend | starre Gelenkgruppen | 69164 | Wurzelbund Fläche | Truppe Fläche | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w08 | Pilzkathedrale | res://assets/models/unit_w08.glb | assets-source/phase4/blend/w08.blend | starre Gelenkgruppen | 103748 | Wurzelbund Fläche+ | Truppenaufwertung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w09 | Tauhüter | res://assets/models/unit_w09.glb | assets-source/phase4/blend/w09.blend | starre Gelenkgruppen | 52040 | Wurzelbund Heiler | Truppe Unterstützung | Wurzelwesen / Wurzelbund | HD, starre Teile | keiner |
| w10 | Quellgeflecht | res://assets/characters/quellgeflecht.glb | assets-source/phase16/quellgeflecht.blend | 17 Knochen · 9 Clips | 14276 | Wurzelbund Heiler+ | Truppenaufwertung; M2 zusätzlich Held „Quellgeflecht“ (vorläufig) | Wurzelwesen / Wurzelbund | gewichtetes Skelett (phase16) | Heldenkennzeichnung |
| w10 | Quellgeflecht | res://assets/characters/quellgeflecht.glb | assets-source/phase16/quellgeflecht.blend | 17 Knochen · 9 Clips | 14276 | Wurzelbund Heiler+ | Truppenaufwertung; zusätzlich Held „Quellgeflecht“ (vorläufig) | Wurzelwesen / Wurzelbund | gewichtetes Skelett (phase16) | umgesetzt (M2) |
| worker | Funkenleser | res://assets/characters/arbeiter.glb | assets-source/phase12/arbeiter.blend | 27 Knochen · 10 Clips | 7184 | Arbeiter/Funken | nicht im neuen Modus (Arbeitsannahme E); Modell bleibt erhalten | — | gewichtetes Skelett (phase12) | keiner |
| menu-stage | Menü-Diorama | res://assets/environment/menu-stage.glb | assets-source/phase20/build_menu_stage.py | statisch | 48660 | Hauptmenü Phase 20A | Hauptmenü (Titel angepasst) | — | Kluft-Shader | keiner |
| verdant-rift | Grüne Kluft (2-Bretter-Arena) | res://assets/environment/verdant-rift.glb | assets-source/phase10/verdant-rift.blend | statisch | — | Spielfeld Saumwacht | Legacy-Modus; Materialien/Shader für die neue Karte wiederverwendet | — | triplanare Kluft-Shader | keine neue Nutzung als Karte |
| compare-rock / compare-tree | Fels, Baum | res://assets/compare/ | assets-source/phase14/build_compare_props.py | statisch | 360 / 852 | Vergleichsszene | Kartendekoration Dschungel | — | einfach | keiner |
| Türme | — | (fehlt) | — | — | — | — | 16 Lanetürme/Basistürme | — | — | neu: Blender-Generator (M1) |
| Bossgrotte | — | (fehlt) | — | — | — | — | Grottenbau nahe Zentrum | — | — | neu (M2) |
| lane_tower | Laneturm | res://assets/luneraria/tower.glb | assets-source/luneraria/build_tower.py | statisch | 648 | — | 16 Lanetürme und Basistürme, Kristall zur Laufzeit in Teamfarbe | — | ENV_*-Kluftshader, Kristall emissiv | neu (M1) |
| boss_grotto | Bossgrotte | res://assets/luneraria/grotto.glb | assets-source/luneraria/build_grotto.py | statisch | 1888 | — | Felsinsel mit Höhle auf der Symmetrieachse, zwei gespiegelte Zugänge | — | ENV_*-Kluftshader, violette Kristalle | neu (M2) |
| luneraria-emblem | Emblem Luneraria | res://assets/ui/emblems/luneraria.svg | SVG | — | — | — | Titel und Menü | — | Vektor | neu (M1) |
| Emblem/Theme | Saumwacht-Theme | scripts/ui/theme/, assets/ui/ | Code / SVG | — | — | Menü 20A | Theme weiterverwendet, Titel/Emblem Luneraria | — | Vektor | Titelschrift/Emblem ergänzen |

View file

@ -26,3 +26,48 @@ Alle Werte stehen in `scripts/luneraria/core/lune_balance.gd`. Die aktive Standa
- 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).
## M2 · Helden, Items, Champions, Boss
Werte in `scripts/luneraria/core/lune_content.gd`, Begründungen im [Regelwerk](REGELWERK.md) §4.
| Wert | Stand | Begründung |
| --- | ---: | --- |
| Heldenpreis | 260 | Mehr als das Startgold: Ein Held kommt frühestens in Runde 2 und ersetzt keine frühe Armee. |
| Heldenwerte | Ringborke 2.200 HP / 34, Quellgeflecht 1.300 / 28, Funkenlanze 950 / 46 | Aus den Truppenwerten der Aufwertungsstufe mit etwa 1,4-fachem Leben abgeleitet. |
| Stufen | 10; +9 % Leben, +7 % Schaden je Stufe | In 12 Bot-Partien erreichen Helden bis Partieende Stufe 3–6, Talentstufe 2 also erst spät. |
| Items | 180–260 | Ein Item kostet etwa eine aufgewertete Truppe. |
| Champions | 150/150/180, Leben ×2,2, Schaden ×1,6 der Söldnerwerte, Kopfgeld 70 + 6·r | Einmalige Welle etwa zum Preis von zwei Truppen. |
| Grottenboss | 4.200 HP × Wellenstärke, 55 Schaden | Nur mit großer Expedition schaffbar (Szenarien unten). |
| Entsandter Boss | 5.200 HP × Wellenstärke, 70 Schaden, Kopfgeld 520 + 60·r | Das Kopfgeld liegt deutlich über einem Turm (150–270) und über einem Monster (~10–30). |
### Bot-Serie (12 Seeds, [sim-12-seeds.json](logs/m2/sim-12-seeds.json))
- Alle 12 Partien enden regulär nach 7–10 Wellen (Blau 7, Rot 5), keine Kampfphase im Zeitfenster; die längste dauerte 128 s.
- Bossversuche: 36 gescheitert, 11 erobert, 7 entsandt (die übrigen Partien endeten vorher).
- **Befund:** Der Bossjäger-Bot versucht es in jeder Partie ab Runde 5, scheitert bis Runde 7 und siegt in Runde 8. Das Muster ist zu gleichförmig; der Bot schätzt seine Stärke nicht ein (M3).
- Siegquote des Teams mit dem jeweiligen Profil: fernkampf 11/12, druck 9/12, teamhilfe 7/12, verteidigung 5/12, bossjagd 3/12, ausgewogen 1/12. **Vorsicht:** Jede Partie enthält jedes Profil genau einmal, und die Paare fernkampf/ausgewogen, druck/bossjagd und verteidigung/teamhilfe stehen immer in verschiedenen Teams. Die Zahlen sind daher nicht unabhängig; eine saubere Auswertung mit variierter Teamzusammensetzung folgt in M3.
### Bossstrategie im direkten Vergleich ([boss_scenarios.gd](../../tools/luneraria/boss_scenarios.gd))
Identischer Stand nach Runde 7, danach vier Runden mit gewöhnlichen Bots ohne Bossjagd. Wert = eingesetzter Armeewert (Blau / Rot). ΔHP und ΔGold jeweils Blau / Rot über die vier Runden.
| Seed | Szenario | Wert | Grotte | Mid-Türme Blau verloren | Bossrunde | Gebäudeschaden Rot durch Boss | Kopfgeld für Rot | ΔHP Gebäude | ΔGold | Sieger |
| --- | --- | ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| 3 | kein_versuch | 0 | none | 0 | — | 0 | — | -1.436 / -50.521 | 284 / -235 | Blau |
| 3 | gescheitert | 210 | failed | 0 | — | 0 | — | -1.754 / -50.257 | 284 / -235 | Blau |
| 3 | erfolgreich | 1000 | won | 2 | 9 | 33.219 | 1060 | -15.023 / -35.384 | 543 / 794 | Blau |
| 3 | umkaempft | 1000 / 995 | failed | 0 | — | 0 | — | -1.435 / -51.276 | 494 / -185 | Blau |
| 3 | ueberinvestition | 2495 | won | 2 | 9 | 25.392 | 1060 | -68.295 / -25.392 | -250 / 437 | Rot |
| 7 | kein_versuch | 0 | none | 0 | — | 0 | — | -9.222 / -5.573 | 174 / 139 | offen |
| 7 | gescheitert | 180 | failed | 0 | — | 0 | — | -9.548 / -4.660 | 174 / 139 | offen |
| 7 | erfolgreich | 930 | won | 2 | 9 | 22.793 | 1060 | -67.737 / -22.793 | -302 / 319 | Rot |
| 7 | umkaempft | 930 / 865 | failed | 0 | — | 0 | — | -9.855 / -4.660 | 174 / 139 | offen |
| 7 | ueberinvestition | 2580 | won | 2 | 9 | 20.685 | 1060 | -67.737 / -20.685 | -320 / 277 | Rot |
**Deutung:**
- Eine gescheiterte kleine Expedition kostet wenig.
- In der umkämpften Grotte reiben sich beide Expeditionen auf; kein Team erhält den Anspruch.
- Eine erfolgreiche Eroberung zieht die Mid-Lane so weit ab, dass Blau dort beide Türme verliert. Der Boss richtet danach 20.000–33.000 HP Gebäudeschaden an, stirbt und bringt Rot 1.060 Gold.
- Überinvestition kippt die Partie in beiden Seeds zu Rot, und auch die normale Eroberung verliert bei Seed 7.
- Die Bossstrategie ist damit riskant und nicht dominant. Nach diesen Stichproben wiegt der Boss die geschwächte Lane eher zu wenig auf: ein Balancepunkt für M3 (z. B. mehr Bosswirkung oder schwächerer Grottenboss). Es gibt weder eine kostenlose Sofortrückkehr noch eine Doppelbelohnung (Tests); das Goldrisiko steht im Kopfgeld offen angekündigt.

View file

@ -54,3 +54,19 @@ Alle Zahlen stehen zentral in `data/luneraria/balance_default.tres` (Klasse `Lun
| Laneverlust | Kein Spieler scheidet aus. Er baut weiter auf, verteidigt und unterstützt. |
| Pause | Offline darf die Planung auf den Menschen warten; Pause und Tempo 1×/2×/4× gelten nur lokal. |
| QA-Grenze | Testläufe dürfen eine Rundenobergrenze haben. Eine dort nicht beendete Partie wird als „Testlimit erreicht / Partie nicht abgeschlossen“ protokolliert, nie als Sieg oder Unentschieden. |
## 4 · Detailentscheidungen M2 (anpassbar, Werte in `scripts/luneraria/core/lune_content.gd`)
| Thema | Entscheidung |
| --- | --- |
| Helden | Drei Helden aus vorhandenen Skelettmodellen, als **vorläufige Rollenzuordnung**: Ringborke (Front/Schutz), Quellgeflecht (Heilung), Funkenlanze (Fernkampf). Jeder Spieler kann einen aktiven Helden anwerben, unabhängig vom Bund, für 260 Gold. Damit ist ein Held frühestens ab Runde 2 erschwinglich. Ein Held aus derselben Planung lässt sich voll erstatten, danach bleibt er bis Partieende. |
| Erfahrung | Nicht an den letzten Treffer gebunden. Jeder Held, der an einer Kampfphase teilnimmt, erhält 40 + 6 × Runde. Jeder gegnerische Tod im Umkreis von 12 m (Gebäude 14 m) wird zu gleichen Teilen auf alle Helden in der Nähe verteilt: Monster 12 + Runde, Champion 30 + 2 × Runde, Boss 220, Truppe/Held 15, Gebäude 60. So erhalten auch Heiler in der zweiten Reihe Erfahrung. Stufen 1–10, je Stufe +9 % Leben und +7 % Schaden. |
| Talente | Auf Stufe 3 und 6 je eine endgültige Wahl zwischen zwei Richtungen: Eigen- gegen Gruppenschutz oder Einzelziel gegen Fläche, je Held eigene Optionen. |
| Items | Sechs dauerhafte Items, drei Plätze. Jedes Item höchstens einmal pro Held. Kristallsplitter setzt Heldenstufe 3 voraus. Verkauf bringt 50 %, ein Kauf aus derselben Planung wird voll erstattet. Boni verschiedener Items addieren sich; die Schadensminderung ist auf 35 %, das Angriffstempo auf +50 % begrenzt. |
| Reihenfolge im Kampf | Primärtreffer → Grundfähigkeit → Talent → Item-Effekt. Item- und Talenteffekte lösen keine weiteren Treffer-Effekte aus, Heilungen lösen nichts aus; dadurch sind Schleifen ausgeschlossen. Normale Truppen haben kein Inventar. |
| Angriffschampions | Rostschieber (Schutz, 150 Gold), Dunstsänger (Druck auf Unterstützer, 150), Keilbrecher (Belagerung, ×2,5 gegen Gebäude, 180). Ein Kauf in Runde n wird für Runde n+1 auf eine Lane gebucht, höchstens zwei Buchungen pro Runde. Bis zum Ende der Planung lässt sich eine Buchung voll erstatten, danach ist sie weder verschiebbar noch erstattbar. Buchungen werden zu Beginn von n+1 öffentlich angekündigt. Der Champion kämpft einmal und verschwindet. Die Stärke skaliert mit der Einsatzrunde, das Kopfgeld beträgt 70 + 6 × Runde und wird wie Monstergold verteilt. |
| Grotte · Zuweisung | Die Zuweisung „G“ gilt für genau eine Runde. Die Einheiten fehlen ihrer Lane die ganze Kampfphase, starten an festen, gespiegelten Grotteneingängen und kehren bei der Abrechnung auf die Lane ihres Besitzers zurück. In Sperrrunden ist die Zuweisung abgelehnt. |
| Grotte · Kampf | Der neutrale Boss ruht, solange Expeditionen beider Teams leben; die Expeditionen kämpfen zuerst gegeneinander. Erst wenn höchstens ein Team übrig ist, erwacht der Boss mit vollen Lebenspunkten. Tod aller Expeditionen oder Ablauf des Zeitfensters: kein Anspruch. Der nächste Versuch trifft einen vollständig neuen Boss. Todesfälle in der Grotte bringen kein Gold. |
| Anspruch | Genau ein Anspruch pro Sieg; ein wiederholtes Abrechnungssignal ändert nichts. Ziel-Lane: die Lane des beitragenden Spielers mit dem höchsten eingesetzten Armeewert in der Grotte (Held einschließlich Items); bei Gleichstand der niedrigere Slot. Dieser Spieler kann die Lane in der folgenden Planung ändern; ohne Eingabe gilt seine eigene Lane. |
| Entsendung | Automatisch in der folgenden Runde, für das siegreiche Team, gegen den gegnerischen Ancient. Gebäudesperren gelten unverändert. Stärke und Kopfgeld (520 + 60 × Runde) richten sich nach der Einsatzrunde. Das Kopfgeld geht bei seinem Tod gleichmäßig an das verteidigende Team. Überlebt er, verschwindet er ohne Belohnung. |
| Sperre | `next_available_round = Siegrunde + 4`. Beispiel: Sieg in Runde 6, Einsatz in Runde 7, gesperrt in 7–9, bekämpfbar ab Runde 10. Die Sperre hängt am Grottensieg, nicht am Tod des entsandten Bosses. |

View file

@ -2,9 +2,9 @@
"checks": 38,
"failures": [],
"object_counts": [
3505.0,
3505.0,
3505.0,
3505.0
3850.0,
3850.0,
3850.0,
3850.0
]
}

View file

@ -0,0 +1,44 @@
{
"boss_log": [
{
"event": "failed",
"round": 5
},
{
"event": "failed",
"round": 6
},
{
"event": "failed",
"round": 7
},
{
"chooser": 4,
"event": "won",
"round": 8,
"team": 1
},
{
"event": "deployed",
"lane": "top",
"round": 9,
"team": 1
},
{
"chooser": 4,
"event": "won",
"round": 12,
"team": 1
}
],
"checks": 15,
"failures": [],
"rounds": 12,
"seen": [
"grotto",
"hero_field",
"hero_items",
"announce",
"boss"
]
}

View file

@ -0,0 +1,4 @@
{
"checks": 73,
"failures": []
}

View file

@ -0,0 +1,85 @@
[
{
"blau_gold_delta": 284,
"blau_hp_after_r8": 0.0,
"blau_hp_delta": -1436.5,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "none",
"phase": "vorbei",
"rot_gold_delta": -235,
"rot_hp_delta": -50521.0198,
"rot_sent_value": 0,
"scenario": "kein_versuch",
"sent_value": 0,
"winner": 0
},
{
"blau_gold_delta": 284,
"blau_hp_after_r8": -317.199999999997,
"blau_hp_delta": -1753.7,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "failed",
"phase": "vorbei",
"rot_gold_delta": -235,
"rot_hp_delta": -50257.2862,
"rot_sent_value": 0,
"scenario": "gescheitert",
"sent_value": 210,
"winner": 0
},
{
"blau_gold_delta": 543,
"blau_hp_after_r8": -13227.8841,
"blau_hp_delta": -15022.5341,
"blau_mid_towers_lost_r8": 2,
"boss_bounty_to_rot": 1060,
"boss_killed": true,
"boss_round": 9,
"claim_team": 0,
"grotto": "won",
"phase": "vorbei",
"rot_gold_delta": 794,
"rot_hp_delta": -35384.1102,
"rot_hp_lost_boss_round": 33218.6196,
"rot_sent_value": 0,
"scenario": "erfolgreich",
"sent_value": 1000,
"winner": 0
},
{
"blau_gold_delta": 494,
"blau_hp_after_r8": -401.375,
"blau_hp_delta": -1434.51880000001,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "failed",
"phase": "vorbei",
"rot_gold_delta": -185,
"rot_hp_delta": -51276.0262,
"rot_sent_value": 995,
"scenario": "umkaempft",
"sent_value": 1000,
"winner": 0
},
{
"blau_gold_delta": -250,
"blau_hp_after_r8": -36433.06827,
"blau_hp_delta": -68294.9678,
"blau_mid_towers_lost_r8": 2,
"boss_bounty_to_rot": 1060,
"boss_killed": true,
"boss_round": 9,
"claim_team": 0,
"grotto": "won",
"phase": "vorbei",
"rot_gold_delta": 437,
"rot_hp_delta": -25391.9446,
"rot_hp_lost_boss_round": 25391.9446,
"rot_sent_value": 0,
"scenario": "ueberinvestition",
"sent_value": 2495,
"winner": 1
}
]

View file

@ -0,0 +1,85 @@
[
{
"blau_gold_delta": 174,
"blau_hp_after_r8": -3771.781,
"blau_hp_delta": -9222.031,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "none",
"phase": "läuft",
"rot_gold_delta": 139,
"rot_hp_delta": -5573.1798,
"rot_sent_value": 0,
"scenario": "kein_versuch",
"sent_value": 0,
"winner": -2
},
{
"blau_gold_delta": 174,
"blau_hp_after_r8": -4097.3806,
"blau_hp_delta": -9547.6306,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "failed",
"phase": "läuft",
"rot_gold_delta": 139,
"rot_hp_delta": -4659.805,
"rot_sent_value": 0,
"scenario": "gescheitert",
"sent_value": 180,
"winner": -2
},
{
"blau_gold_delta": -302,
"blau_hp_after_r8": -16247.2932,
"blau_hp_delta": -67736.578,
"blau_mid_towers_lost_r8": 2,
"boss_bounty_to_rot": 1060,
"boss_killed": true,
"boss_round": 9,
"claim_team": 0,
"grotto": "won",
"phase": "vorbei",
"rot_gold_delta": 319,
"rot_hp_delta": -22792.746,
"rot_hp_lost_boss_round": 22792.746,
"rot_sent_value": 0,
"scenario": "erfolgreich",
"sent_value": 930,
"winner": 1
},
{
"blau_gold_delta": 174,
"blau_hp_after_r8": -4404.387,
"blau_hp_delta": -9854.637,
"blau_mid_towers_lost_r8": 0,
"claim_team": -1,
"grotto": "failed",
"phase": "läuft",
"rot_gold_delta": 139,
"rot_hp_delta": -4659.805,
"rot_sent_value": 865,
"scenario": "umkaempft",
"sent_value": 930,
"winner": -2
},
{
"blau_gold_delta": -320,
"blau_hp_after_r8": -38337.2773,
"blau_hp_delta": -67736.578,
"blau_mid_towers_lost_r8": 2,
"boss_bounty_to_rot": 1060,
"boss_killed": true,
"boss_round": 9,
"claim_team": 0,
"grotto": "won",
"phase": "vorbei",
"rot_gold_delta": 277,
"rot_hp_delta": -20684.7692,
"rot_hp_lost_boss_round": 20684.7692,
"rot_sent_value": 0,
"scenario": "ueberinvestition",
"sent_value": 2580,
"winner": 1
}
]

View file

@ -0,0 +1,9 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
LUNE_FULL_MATCH: seed 2 -> Blau after 7 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

View file

@ -0,0 +1,8 @@
Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org
LUNE_M2_PASS: 65 checks; 0 failures
WARNING: 14 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

File diff suppressed because it is too large Load diff

View file

@ -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 792
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 7 waves
M1_VERIFY_PASS: 38 checks; 0 failures
EXIT 0

View file

@ -0,0 +1,16 @@
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
M2APP_SHOT m2-hud-hero-round1
M2APP_SHOT m2-grotto-fight
M2APP_SHOT m2-hero-field
M2APP_SHOT m2-hero-items
M2APP_SHOT m2-announcements
M2APP_SHOT m2-boss-deployed
M2APP_BOSS_LOG [{ "round": 5, "event": "failed" }, { "round": 6, "event": "failed" }, { "round": 7, "event": "failed" }, { "round": 8, "event": "won", "team": 1, "chooser": 4 }, { "round": 9, "event": "deployed", "team": 1, "lane": "top" }, { "round": 12, "event": "won", "team": 1, "chooser": 4 }]
M2APP_VERIFY_PASS: 15 checks; 0 failures
WARNING: 8 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cxfgbyo316gip"
path="res://.godot/imported/combat-mid-live.png-1baea78d38cdbbdf1b2c012c8459c83c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/combat-mid-live.png"
dest_files=["res://.godot/imported/combat-mid-live.png-1baea78d38cdbbdf1b2c012c8459c83c.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

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdpr2aef1mtyw"
path="res://.godot/imported/combat-mid.png-47877f493d55e3cb715252d7b2b177f7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/combat-mid.png"
dest_files=["res://.godot/imported/combat-mid.png-47877f493d55e3cb715252d7b2b177f7.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bcesqxwj7xds5"
path="res://.godot/imported/combat-top.png-409d757457196da3b4d7dde0737a858f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/combat-top.png"
dest_files=["res://.godot/imported/combat-top.png-409d757457196da3b4d7dde0737a858f.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bdnafrcapn3qp"
path="res://.godot/imported/core-siege.png-6a2f95e61f8e13a0d0145605efd83a90.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/core-siege.png"
dest_files=["res://.godot/imported/core-siege.png-6a2f95e61f8e13a0d0145605efd83a90.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bdogy4irokv2y"
path="res://.godot/imported/menu-setup.png-1991aae81ee557037e9c72f26ad60780.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/menu-setup.png"
dest_files=["res://.godot/imported/menu-setup.png-1991aae81ee557037e9c72f26ad60780.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

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dpmpwywqtf8gb"
path="res://.godot/imported/menu-title.png-c5fe9b889c2b85b3983f3c07b80447e5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/menu-title.png"
dest_files=["res://.godot/imported/menu-title.png-c5fe9b889c2b85b3983f3c07b80447e5.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c4cj5wabcwwpo"
path="res://.godot/imported/overview-combat.png-047b1bec10668f137599feea356858c9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/overview-combat.png"
dest_files=["res://.godot/imported/overview-combat.png-047b1bec10668f137599feea356858c9.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

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://1vimfsrdmwes"
path="res://.godot/imported/overview-planning.png-36712c5e2948fd3a6d16bb3a7612d671.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/overview-planning.png"
dest_files=["res://.godot/imported/overview-planning.png-36712c5e2948fd3a6d16bb3a7612d671.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 KiB

After

Width:  |  Height:  |  Size: 650 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://irf7f3pko78d"
path="res://.godot/imported/planning-1280x720.png-4961a39d8036e60c703f21f94b767a05.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/planning-1280x720.png"
dest_files=["res://.godot/imported/planning-1280x720.png-4961a39d8036e60c703f21f94b767a05.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://eg2x2tnm2r4a"
path="res://.godot/imported/planning-1920x1080.png-9474e0516c4f03723ac6abf839ff75d9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/planning-1920x1080.png"
dest_files=["res://.godot/imported/planning-1920x1080.png-9474e0516c4f03723ac6abf839ff75d9.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

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cv4mo6gqrgr7l"
path="res://.godot/imported/planning.png-ea15c73d57d5147150129852dec1ced2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/planning.png"
dest_files=["res://.godot/imported/planning.png-ea15c73d57d5147150129852dec1ced2.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 595 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://coio2a87vbvp4"
path="res://.godot/imported/result.png-9d37465fb26c73ab171191d98bea3f04.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/result.png"
dest_files=["res://.godot/imported/result.png-9d37465fb26c73ab171191d98bea3f04.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cunkl1amyr2p2"
path="res://.godot/imported/settlement.png-fde2624ce641379c962c75deb3114891.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://docs/luneraria/screenshots/settlement.png"
dest_files=["res://.godot/imported/settlement.png-fde2624ce641379c962c75deb3114891.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

View file

@ -1,14 +1,23 @@
class_name LuneBot
extends RefCounted
## Plans through LuneMatch.submit exactly like the human: same costs, same caps, same phase.
## Reads only public state (committed buildings, round) and its own draft, never other drafts.
## Reads only public state (committed buildings, round, boss state, announcements) and its
## own draft, never other players' drafts.
const PROFILES: Dictionary = {
"ausgewogen": {"front": .30, "melee": .25, "ranged": .25, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0},
"verteidigung": {"front": .40, "melee": .15, "ranged": .20, "splash": .10, "support": .15, "upgrade_from": 5, "support_lane": 0.0},
"druck": {"front": .20, "melee": .35, "ranged": .25, "splash": .15, "support": .05, "upgrade_from": 8, "support_lane": 0.0},
"fernkampf": {"front": .25, "melee": .10, "ranged": .40, "splash": .15, "support": .10, "upgrade_from": 6, "support_lane": 0.0},
"teamhilfe": {"front": .30, "melee": .20, "ranged": .25, "splash": .10, "support": .15, "upgrade_from": 6, "support_lane": .35},
"ausgewogen": {"front": .30, "melee": .25, "ranged": .25, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0,
"hero": "held_ringborke", "hero_round": 3, "items": ["basaltschild", "glutklinge", "rankenmantel"], "talents": [0, 1], "champion": ""},
"verteidigung": {"front": .40, "melee": .15, "ranged": .20, "splash": .10, "support": .15, "upgrade_from": 5, "support_lane": 0.0,
"hero": "held_ringborke", "hero_round": 4, "items": ["rankenmantel", "basaltschild", "quellamulett"], "talents": [0, 0], "champion": ""},
"druck": {"front": .20, "melee": .35, "ranged": .25, "splash": .15, "support": .05, "upgrade_from": 8, "support_lane": 0.0,
"hero": "held_funkenlanze", "hero_round": 3, "items": ["glutklinge", "funkenfokus", "kristallsplitter"], "talents": [1, 1], "champion": "ch_belagerung"},
"fernkampf": {"front": .25, "melee": .10, "ranged": .40, "splash": .15, "support": .10, "upgrade_from": 6, "support_lane": 0.0,
"hero": "held_funkenlanze", "hero_round": 4, "items": ["funkenfokus", "glutklinge", "kristallsplitter"], "talents": [0, 0], "champion": "ch_druck"},
"teamhilfe": {"front": .30, "melee": .20, "ranged": .25, "splash": .10, "support": .15, "upgrade_from": 6, "support_lane": .35,
"hero": "held_quellgeflecht", "hero_round": 3, "items": ["quellamulett", "rankenmantel", "basaltschild"], "talents": [1, 0], "champion": "ch_schutz"},
"bossjagd": {"front": .35, "melee": .25, "ranged": .20, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0,
"hero": "held_ringborke", "hero_round": 3, "items": ["glutklinge", "basaltschild", "rankenmantel"], "talents": [1, 1], "champion": "",
"boss_from": 5, "boss_troops": 7},
}
static func profile_for(match_state: LuneMatch, slot: int) -> String:
@ -23,6 +32,17 @@ static func plan(match_state: LuneMatch, slot: int, profile_name: String = "") -
var roles: Dictionary = {}
for id: String in ids:
roles[match_state.role_of(id)] = id
_plan_hero(match_state, slot, profile)
# Heroes get one planned item every second round before the army is topped up.
var hero: Dictionary = player.draft.hero
if not hero.is_empty() and (hero.items as Array).size() < LuneContent.ITEM_SLOTS and match_state.round_number % 2 == 0:
for item_id: String in profile.items:
if not (hero.items as Array).has(item_id):
match_state.submit(slot, {"type": "item_buy", "item_id": item_id})
break
# Champion bots invest in a pushing wave every third round before topping up the army.
if match_state.round_number % 3 == 0:
_plan_champion(match_state, slot, profile)
for guard: int in range(40):
var draft: Dictionary = player.draft
var roster: Array = draft.roster
@ -39,8 +59,62 @@ static func plan(match_state: LuneMatch, slot: int, profile_name: String = "") -
acted = _try_upgrade(match_state, slot, roster)
if not acted:
break
_plan_items(match_state, slot, profile)
_plan_boss(match_state, slot, profile)
match_state.set_ready(slot)
static func _plan_hero(match_state: LuneMatch, slot: int, profile: Dictionary) -> void:
var player: Dictionary = match_state.players[slot]
var hero: Dictionary = player.draft.hero
if hero.is_empty():
if match_state.round_number >= int(profile.hero_round):
match_state.submit(slot, {"type": "hero_buy", "hero_id": profile.hero})
return
var level: int = LuneContent.level_for(int(hero.xp))
var talents: Array = profile.talents
for tier: int in range(LuneContent.TALENT_LEVELS.size()):
if (hero.talents as Array).size() == tier and level >= LuneContent.TALENT_LEVELS[tier]:
match_state.submit(slot, {"type": "talent", "tier": tier, "choice": talents[tier]})
static func _plan_items(match_state: LuneMatch, slot: int, profile: Dictionary) -> void:
var hero: Dictionary = match_state.players[slot].draft.hero
if hero.is_empty():
return
for item_id: String in profile.items:
# Keep a reserve for the army: items only from surplus gold.
if int(match_state.players[slot].draft.gold) >= int(LuneContent.ITEMS[item_id].cost) + 60:
match_state.submit(slot, {"type": "item_buy", "item_id": item_id})
static func _plan_champion(match_state: LuneMatch, slot: int, profile: Dictionary) -> void:
var champion: String = str(profile.get("champion", ""))
if champion.is_empty() or match_state.round_number < 3:
return
var player: Dictionary = match_state.players[slot]
if int(player.draft.gold) >= int(LuneContent.CHAMPIONS[champion].cost) + 80:
match_state.submit(slot, {"type": "champion_book", "champion_id": champion, "lane": player.lane})
## Only boss-hunting bots go to the grotto, and only with a strong own army; the boss state
## and lock are public information.
static func _plan_boss(match_state: LuneMatch, slot: int, profile: Dictionary) -> void:
var player: Dictionary = match_state.players[slot]
var claim: Dictionary = match_state.boss.claim
if not claim.is_empty() and int(claim.chooser_slot) == slot and int(claim.deploy_round) == match_state.round_number:
var lane: String = _weakest_lane(match_state, 1 - int(player.team))
match_state.submit(slot, {"type": "boss_lane", "lane": lane})
if not profile.has("boss_from") or match_state.round_number < int(profile.boss_from) or not match_state.grotto_available():
return
var roster: Array = player.draft.roster
if roster.size() < int(profile.boss_troops):
return
var picks: Array = roster.duplicate()
picks.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return int(a.seq) < int(b.seq) if int(a.invested) == int(b.invested) else int(a.invested) > int(b.invested))
for index: int in range(mini(int(profile.boss_troops) - 1, picks.size())):
match_state.submit(slot, {"type": "assign", "uid": picks[index].uid, "lane": LuneMatch.GROTTO})
var hero: Dictionary = player.draft.hero
if not hero.is_empty():
match_state.submit(slot, {"type": "assign", "uid": hero.uid, "lane": LuneMatch.GROTTO})
static func _needed_role(match_state: LuneMatch, roster: Array, profile: Dictionary) -> String:
var counts: Dictionary = {}
for entry: Dictionary in roster:

View file

@ -16,8 +16,15 @@ var finished: bool = false
var end_reason: String = ""
var destroyed_cores: Array[int] = []
var events: Array[Dictionary] = []
## Neutral grotto boss (team 2) and the resulting claim for this combat phase.
const NEUTRAL: int = 2
var grotto_boss: LuneUnit = null
var grotto_resolved: bool = true
var grotto_claim_team: int = -1
var grotto_active_team: int = -1
var grotto_outcome: String = "none"
var _next_id: int = 1
var _team_count: Array[int] = [0, 0]
var _team_count: Array[int] = [0, 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
@ -94,6 +101,20 @@ func spawn(kind: String, team: int, lane: String, definition: Dictionary, progre
units.append(unit)
return unit
func spawn_at(kind: String, team: int, zone: String, definition: Dictionary, at: Vector2, slot: int = -1, uid: String = "") -> LuneUnit:
var unit: LuneUnit = spawn(kind, team, "mid", definition, 0.0, 0.0, slot, uid)
unit.zone = zone
unit.lane = zone
unit.position = at
unit.previous_position = at
return unit
func add_grotto_boss(definition: Dictionary) -> LuneUnit:
grotto_boss = spawn_at("boss", NEUTRAL, "grotto", definition, map.grotto_boss_point())
grotto_boss.dormant = true
grotto_resolved = false
return grotto_boss
func alive_map() -> Dictionary:
var result: Dictionary = {}
for id: String in buildings:
@ -121,7 +142,7 @@ func _prepare_caches(living: Array[LuneUnit], gates: Dictionary) -> void:
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: {}}
_grid = {0: {}, 1: {}, 2: {}}
for unit: LuneUnit in living:
if unit.is_mobile():
var cells: Dictionary = _grid[unit.team]
@ -130,13 +151,16 @@ func _prepare_caches(living: Array[LuneUnit], gates: Dictionary) -> void:
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:
static func hostile_teams(team: int) -> Array:
return [0, 1] if team == NEUTRAL else [1 - team, NEUTRAL]
## Mobile units of the given teams near a point; the caller checks distance.
func _near_mobile(center: Vector2, radius: float, teams: Array) -> 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]):
for side: int in teams:
var cells: Dictionary = _grid[side]
if cells.is_empty():
continue
@ -220,17 +244,37 @@ func step() -> void:
unit.death_time = elapsed
unit.target = null
_record_death(unit)
_update_grotto()
_lap("resolve")
_check_end()
## Expeditions fight each other first; the boss wakes when at most one team remains.
func _update_grotto() -> void:
if grotto_resolved or grotto_boss == null:
return
var teams_alive: Array[int] = []
for unit: LuneUnit in units:
if unit.alive and unit.zone == "grotto" and unit.team != NEUTRAL and not teams_alive.has(unit.team):
teams_alive.append(unit.team)
if grotto_boss.dormant and teams_alive.size() <= 1:
grotto_boss.dormant = false
grotto_active_team = teams_alive[0] if teams_alive.size() == 1 else -1
if not grotto_boss.alive:
grotto_resolved = true
grotto_claim_team = grotto_active_team
grotto_outcome = "success" if grotto_claim_team >= 0 else "failed"
elif teams_alive.is_empty():
grotto_resolved = true
grotto_outcome = "failed"
func _check_end() -> void:
if not destroyed_cores.is_empty():
finished = true
end_reason = "core"
return
var mobile_alive: bool = false
var mobile_alive: bool = not grotto_resolved
for unit: LuneUnit in units:
if unit.alive and unit.is_mobile():
if unit.alive and unit.is_mobile() and unit.zone == "lane":
mobile_alive = true
break
if not mobile_alive:
@ -252,11 +296,26 @@ func _record_death(unit: LuneUnit) -> void:
event.building_id = unit.building_id
if unit.kind == "core" and not destroyed_cores.has(unit.team):
destroyed_cores.append(unit.team)
# Participation XP: every opposing hero near the death shares it, whoever landed the hit.
var xp: int = 0
match unit.kind:
"monster": xp = 12 + round_number
"champion": xp = 30 + 2 * round_number
"boss": xp = LuneContent.BOSS_XP
"troop", "hero": xp = 15
"tower", "core": xp = LuneContent.BUILDING_XP
var heroes: Array[String] = []
var radius: float = LuneContent.XP_RADIUS + (2.0 if unit.is_building() else 0.0)
for other: LuneUnit in units:
if other.kind == "hero" and other.alive and other.zone == unit.zone and other.team != unit.team and other.position.distance_to(unit.position) <= radius:
heroes.append(other.persistent_uid)
event.xp = xp
event.xp_heroes = heroes
events.append(event)
# ---------------------------------------------------------------- targeting and movement
func _hostile(unit: LuneUnit, other: LuneUnit) -> bool:
return other.alive and other.team != unit.team and other.zone == unit.zone
return other.alive and other.team != unit.team and other.zone == unit.zone and not other.dormant and not unit.dormant
func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> LuneUnit:
var current: LuneUnit = unit.target
@ -267,10 +326,25 @@ 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 _near_mobile(unit.position, radius, 1 - unit.team):
var priority: String = str(unit.def.get("priority", ""))
if priority == "building":
# Siege champions go for the nearest attackable building before anything else.
for id: String in buildings:
var building: LuneUnit = buildings[id]
if _hostile(unit, building) and building_attackable(building, gates):
var reach_d: float = unit.position.distance_to(building.position) - balance.building_radius
if reach_d <= balance.aggro_radius and reach_d < best_distance:
best = building
best_distance = reach_d
if best != null:
return best
for other: LuneUnit in _near_mobile(unit.position, radius, hostile_teams(unit.team)):
if not _hostile(unit, other):
continue
var d: float = unit.position.distance_to(other.position)
# Pressure champions treat support and ranged enemies as 6 m closer.
if priority == "support" and other.role() in ["support", "ranged"]:
d -= 6.0
if d <= radius and (d < best_distance - 0.0001 or (absf(d - best_distance) <= 0.0001 and other.id < best.id)):
best = other
best_distance = d
@ -312,6 +386,12 @@ func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array:
if gap > 0.0:
goal = unit.target.position
travel = minf(step_length, gap + 0.02)
elif unit.zone == "grotto":
if unit.kind != "boss" and grotto_boss != null and grotto_boss.alive:
var gap_boss: float = unit.position.distance_to(grotto_boss.position) - unit.attack_range()
if gap_boss > 0.0:
goal = grotto_boss.position
travel = minf(step_length, gap_boss + 0.02)
elif unit.zone == "lane":
var limit: float = _march_limit(unit)
var anchor: Vector2 = map.lane_point(unit.team, unit.lane, unit.progress, unit.lateral)
@ -332,7 +412,7 @@ 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 _near_mobile(unit.position, 1.1, unit.team):
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
@ -385,13 +465,19 @@ func _attack(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary, dt: flo
var damage: float = float(unit.def.get("damage", 0.0)) * (1.0 + unit.damage_bonus())
if unit.hit_count % 4 == 0:
if unit.ability() == "g05":
damage *= 1.5
damage *= 2.1 if unit.has_talent("durchschlag") else 1.5
elif unit.ability() == "g06":
damage *= 1.75
if unit.hit_count % 3 == 0 and unit.has_talent("stammschlag"):
damage *= 1.6
if target.is_building():
damage *= float(unit.def.get("siege_factor", 1.0))
_queue_damage(target, target.mitigated(damage, int(unit.def.get("attack_type", 0))), gates)
_on_hit(unit, target, living, gates)
# Item effects come last and never trigger further on-hit effects.
var item_splash: float = float(unit.def.get("item_splash", 0.0))
if item_splash > 0.0:
_splash(unit, target, living, gates, item_splash, 1.5, 2)
func _queue_damage(target: LuneUnit, amount: float, gates: Dictionary) -> void:
if amount <= 0.0 or not target.alive:
@ -410,20 +496,20 @@ func _start_ability(unit: LuneUnit) -> void:
match unit.ability():
"w03", "w04": unit.ability_ready = 5.0
"w09", "w10": unit.ability_ready = 4.0
"s02": unit.grant_shield(unit.max_hp * 0.15)
"s02", "ch_schutz": unit.grant_shield(unit.max_hp * 0.15)
"e08": unit.grant_shield(unit.max_hp * 0.20)
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] = []
# 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)
var pool: Array = _near_mobile(center, radius, [unit.team] if allied else hostile_teams(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:
if (other.team == unit.team) != allied or other.dormant:
continue
if allied and other.is_building():
continue
@ -440,6 +526,13 @@ func _nearby(unit: LuneUnit, living: Array[LuneUnit], allied: bool, center: Vect
return result
func _ability_step(unit: LuneUnit, living: Array[LuneUnit]) -> void:
var regen: float = float(unit.def.get("regen", 0.0))
if regen > 0.0 and unit.hp < unit.max_hp:
_queue_heal(unit, unit.max_hp * regen * balance.sim_step)
if unit.has_talent("schutzwurzeln") and unit.combat_time + 0.000001 >= unit.aura_ready:
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.0, 4):
_pending_effects.append([ally, "bark_guard", "reduction", 0.10, 4.5])
unit.aura_ready = unit.combat_time + 4.0
if unit.combat_time + 0.000001 < unit.ability_ready:
return
var ability: String = unit.ability()
@ -451,10 +544,27 @@ func _ability_step(unit: LuneUnit, living: Array[LuneUnit]) -> void:
success = true
"w09", "w10":
var upgraded: bool = ability == "w10"
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.5 if upgraded else 4.0, 3 if upgraded else 1, null, true):
var radius: float = 4.5 if upgraded else 4.0
var count: int = 3 if upgraded else 1
var amount: float = 80.0 if upgraded else 65.0
if unit.has_talent("tiefe_quelle"):
count = 1
amount = 190.0
elif unit.has_talent("weite_quelle"):
radius = 5.5
count = 5
amount = 65.0
for ally: LuneUnit in _nearby(unit, living, true, unit.position, radius, count, null, true):
if ally.hp < ally.max_hp:
_queue_heal(ally, 80.0 if upgraded else 65.0)
_queue_heal(ally, amount)
if unit.has_talent("quellschild"):
_pending_shields.append([ally, 70.0, 4.0])
success = true
"ch_schutz":
var allies: Array[LuneUnit] = _nearby(unit, living, true, unit.position, 4.0, 4)
for ally: LuneUnit in allies:
_pending_shields.append([ally, 120.0, 6.0])
success = not allies.is_empty()
"g09", "g10":
var upgraded: bool = ability == "g10"
var allies: Array[LuneUnit] = _nearby(unit, living, true, unit.position, 4.0 if upgraded else 3.0, 4 if upgraded else 3, null, true)
@ -486,6 +596,12 @@ func _ability_step(unit: LuneUnit, living: Array[LuneUnit]) -> void:
func _on_hit(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> void:
var ability: String = unit.ability()
var hits: int = unit.hit_count
if unit.has_talent("wurzelbeben") and hits % 3 == 0:
_splash(unit, primary, living, gates, 0.50, 2.0, 3)
if unit.has_talent("splitterschuss") and hits % 4 == 0:
_splash(unit, primary, living, gates, 0.45, 2.0, 3)
if unit.has_talent("keimsaat") and hits % 2 == 0:
_pending_dots.append([primary, "seed_burn", 9.0, 4.0, int(unit.def.attack_type)])
match ability:
"w01", "w02":
if hits % 3 == 0:

View file

@ -0,0 +1,159 @@
class_name LuneContent
extends RefCounted
## M2 content: heroes, talents, items, attack champions and the grotto boss.
## All figures reuse existing models; hero and champion roles are provisional assignments
## of those models ("vorläufig"), not newly created peoples.
# ---------------------------------------------------------------- heroes
const HEROES: Dictionary = {
"held_ringborke": {"name": "Ringborke", "title": "Wächterin der Wurzeln", "model": "w04", "cost": 260, "role": "Front / Schutz",
"provisional": true, "def": {"max_hp": 2200.0, "damage": 34.0, "attack_type": 1, "armor_type": 1, "attack_range": 1.4, "speed": 2.4, "ability": "w04"},
"summary": "Hält die Front, heilt sich selbst und schützt nahe Verbündete."},
"held_quellgeflecht": {"name": "Quellgeflecht", "title": "Hüterin der Quelle", "model": "w10", "cost": 260, "role": "Unterstützung / Heilung",
"provisional": true, "def": {"max_hp": 1300.0, "damage": 28.0, "attack_type": 2, "armor_type": 2, "attack_range": 4.5, "speed": 2.0, "ability": "w10"},
"summary": "Heilt Verbündete in 4,5 m und stärkt die Armee im Gefecht."},
"held_funkenlanze": {"name": "Funkenlanze", "title": "Schützin des Glutpakts", "model": "g05", "cost": 260, "role": "Fernkampf / Schaden",
"provisional": true, "def": {"max_hp": 950.0, "damage": 46.0, "attack_type": 1, "armor_type": 0, "attack_range": 6.0, "speed": 2.0, "ability": "g05"},
"summary": "Hoher Einzelschaden aus der zweiten Reihe."},
}
## Cumulative XP needed for levels 1..10.
const LEVEL_XP: Array[int] = [0, 120, 280, 480, 720, 1000, 1320, 1680, 2080, 2520]
const HP_PER_LEVEL: float = 0.09
const DAMAGE_PER_LEVEL: float = 0.07
const XP_PARTICIPATION: int = 40
const XP_PER_ROUND: int = 6
const XP_RADIUS: float = 12.0
const TALENT_LEVELS: Array[int] = [3, 6]
## Two tiers of two mutually exclusive choices per hero; each changes the hero's job.
const TALENTS: Dictionary = {
"held_ringborke": [
[{"id": "borkenhaut", "name": "Borkenhaut", "text": "Eigenschutz: +15 % Schadensminderung für die Heldin."},
{"id": "schutzwurzeln", "name": "Schutzwurzeln", "text": "Gruppenschutz: alle 4 s erhalten bis zu 4 Verbündete in 4 m 10 % Schadensminderung für 4,5 s."}],
[{"id": "stammschlag", "name": "Stammschlag", "text": "Einzelziel: jeder 3. Treffer +60 % Schaden."},
{"id": "wurzelbeben", "name": "Wurzelbeben", "text": "Fläche: jeder 3. Treffer 50 % Schaden an bis zu 3 Gegner in 2 m."}]],
"held_quellgeflecht": [
[{"id": "tiefe_quelle", "name": "Tiefe Quelle", "text": "Einzelziel: heilt den schwächsten Verbündeten um 190 statt drei um je 80."},
{"id": "weite_quelle", "name": "Weite Quelle", "text": "Fläche: heilt bis zu 5 Verbündete in 5,5 m um je 65."}],
[{"id": "quellschild", "name": "Quellschild", "text": "Schutz: Geheilte erhalten 4 s lang einen Schild von 70."},
{"id": "keimsaat", "name": "Keimsaat", "text": "Druck: jeder 2. Treffer setzt Keimbrand, 9 Schaden/s für 4 s."}]],
"held_funkenlanze": [
[{"id": "durchschlag", "name": "Durchschlag", "text": "Einzelziel: jeder 4. Treffer ×2,1 statt ×1,5."},
{"id": "splitterschuss", "name": "Splitterschuss", "text": "Fläche: jeder 4. Treffer zusätzlich 45 % an bis zu 3 Gegner in 2 m."}],
[{"id": "fernsicht", "name": "Fernsicht", "text": "+1,5 m Reichweite."},
{"id": "glutsalve", "name": "Glutsalve", "text": "+20 % Angriffstempo."}]],
}
# ---------------------------------------------------------------- items
## Permanent, unique per hero (no two of the same item), three slots. Bonuses of different
## items add up within their category; caps: damage reduction 35 %, attack tempo +50 %.
## Order in combat: primary hit -> base ability -> talent -> item effect. Item effects never
## trigger other on-hit effects, heals never trigger anything, so no loops are possible.
const ITEMS: Dictionary = {
"basaltschild": {"name": "Basaltschild", "cost": 180, "text": "+350 Lebenspunkte", "hp_flat": 350.0},
"glutklinge": {"name": "Glutklinge", "cost": 180, "text": "+18 % Schaden", "damage": 0.18},
"rankenmantel": {"name": "Rankenmantel", "cost": 200, "text": "+10 % Schadensminderung", "reduction": 0.10},
"funkenfokus": {"name": "Funkenfokus", "cost": 200, "text": "+15 % Angriffstempo", "attack_speed": 0.15},
"quellamulett": {"name": "Quellamulett", "cost": 220, "text": "Regeneriert 1,5 % der Lebenspunkte pro Sekunde", "regen": 0.015},
"kristallsplitter": {"name": "Kristallsplitter", "cost": 260, "text": "Jeder Treffer: 25 % an bis zu 2 Gegner in 1,5 m · ab Stufe 3", "splash": 0.25, "min_level": 3},
}
const ITEM_SLOTS: int = 3
const ITEM_REFUND: float = 0.5
# ---------------------------------------------------------------- attack champions
## Bought in round n for one lane, fight once in round n+1, then vanish. Killing one pays gold.
const CHAMPIONS: Dictionary = {
"ch_schutz": {"name": "Rostschieber", "role": "Schutz", "model": "s02", "cost": 150, "base": "s02",
"text": "Schildträger: startet mit Schild, gibt alle 6 s bis zu 4 Verbündeten in 4 m einen Schild von 120.", "view_scale": 1.25},
"ch_druck": {"name": "Dunstsänger", "role": "Druck auf Unterstützer", "model": "s03", "cost": 150, "base": "s03",
"text": "Sucht Unterstützer und Fernkämpfer und verlangsamt deren Angriffe.", "priority": "support", "view_scale": 1.25},
"ch_belagerung": {"name": "Keilbrecher", "role": "Belagerung", "model": "s04", "cost": 180, "base": "s04",
"text": "Greift bevorzugt Gebäude an und verursacht dort ×2,5 Schaden.", "priority": "building", "siege_factor": 2.5, "view_scale": 1.3},
}
const CHAMPION_HP: float = 2.2
const CHAMPION_DAMAGE: float = 1.6
const CHAMPION_BOUNTY: int = 70
const CHAMPION_BOUNTY_PER_ROUND: int = 6
const MAX_BOOKINGS: int = 2
# ---------------------------------------------------------------- boss
const BOSS_MODEL: String = "e10"
const BOSS_GROTTO_HP: float = 4200.0
const BOSS_GROTTO_DAMAGE: float = 55.0
const BOSS_DEPLOY_HP: float = 5200.0
const BOSS_DEPLOY_DAMAGE: float = 70.0
const BOSS_BOUNTY: int = 520
const BOSS_BOUNTY_PER_ROUND: int = 60
## Victory in round n: deployment in n+1, locked in n+1..n+3, available again from n+4.
const BOSS_LOCK_ROUNDS: int = 3
const BOSS_XP: int = 220
const BUILDING_XP: int = 60
static func level_for(xp: int) -> int:
var level: int = 1
for index: int in range(LEVEL_XP.size()):
if xp >= LEVEL_XP[index]:
level = index + 1
return level
static func hero_definition(hero: Dictionary) -> Dictionary:
var spec: Dictionary = HEROES[hero.hero_id]
var definition: Dictionary = (spec.def as Dictionary).duplicate(true)
var level: int = level_for(int(hero.xp))
definition.id = spec.model
definition.hero_id = hero.hero_id
definition.display_name = spec.name
definition.max_hp = float(definition.max_hp) * (1.0 + HP_PER_LEVEL * float(level - 1))
definition.damage = float(definition.damage) * (1.0 + DAMAGE_PER_LEVEL * float(level - 1))
definition.level = level
definition.view_scale = 1.25
var damage_bonus: float = 0.0
var reduction: float = 0.0
var attack_speed: float = 0.0
for item_id: String in hero.items:
var item: Dictionary = ITEMS[item_id]
definition.max_hp = float(definition.max_hp) + float(item.get("hp_flat", 0.0))
damage_bonus += float(item.get("damage", 0.0))
reduction += float(item.get("reduction", 0.0))
attack_speed += float(item.get("attack_speed", 0.0))
definition.regen = float(definition.get("regen", 0.0)) + float(item.get("regen", 0.0))
definition.item_splash = float(definition.get("item_splash", 0.0)) + float(item.get("splash", 0.0))
var talents: Array = hero.get("talents", [])
if talents.has("borkenhaut"):
reduction += 0.15
if talents.has("fernsicht"):
definition.attack_range = float(definition.attack_range) + 1.5
if talents.has("glutsalve"):
attack_speed += 0.20
definition.talents = talents.duplicate()
definition.damage_bonus = damage_bonus
definition.reduction_bonus = reduction
definition.attack_speed_bonus = minf(0.5, attack_speed)
return definition
static func champion_definition(champion_id: String, round_number: int, balance: LuneBalance) -> Dictionary:
var spec: Dictionary = CHAMPIONS[champion_id]
var base: Dictionary = DataCatalog.SENDS[spec.base].duplicate(true)
base.id = spec.model
base.champion_id = champion_id
base.display_name = spec.name
base.max_hp = float(base.max_hp) * CHAMPION_HP * LuneWaves.strength(balance, round_number, balance.wave_hp_growth)
base.damage = float(base.damage) * CHAMPION_DAMAGE * LuneWaves.strength(balance, round_number, balance.wave_damage_growth)
base.bounty = CHAMPION_BOUNTY + CHAMPION_BOUNTY_PER_ROUND * round_number
base.priority = str(spec.get("priority", ""))
base.siege_factor = float(spec.get("siege_factor", 1.0))
base.view_scale = float(spec.view_scale)
if champion_id == "ch_schutz":
base.ability = "ch_schutz"
return base
static func boss_definition(round_number: int, balance: LuneBalance, deployed: bool) -> Dictionary:
var base: Dictionary = DataCatalog.ENEMIES[BOSS_MODEL].duplicate(true)
base.display_name = "Schlundkrone"
base.max_hp = (BOSS_DEPLOY_HP if deployed else BOSS_GROTTO_HP) * LuneWaves.strength(balance, round_number, balance.wave_hp_growth)
base.damage = (BOSS_DEPLOY_DAMAGE if deployed else BOSS_GROTTO_DAMAGE) * LuneWaves.strength(balance, round_number, balance.wave_damage_growth)
base.bounty = BOSS_BOUNTY + BOSS_BOUNTY_PER_ROUND * round_number if deployed else 0
base.is_boss = true
base.view_scale = 1.7
base.speed = 1.6
return base

View file

@ -0,0 +1 @@
uid://blkpu61mfghb2

View file

@ -117,6 +117,14 @@ func lane_forward(team: int, lane: String, progress: float) -> Vector2:
var tangent: Vector2 = path.tangent_at(clampf(progress, 0.0, path.length))
return tangent if team == 0 else mirror(tangent)
## Grotto deployment points: one mirrored entrance per team, boss slightly inside.
func grotto_entry(team: int) -> Vector2:
var blue: Vector2 = GROTTO + Vector2(1.2, 4.6)
return blue if team == 0 else mirror(blue)
func grotto_boss_point() -> Vector2:
return GROTTO + Vector2(-1.5, -1.5)
func core_position(team: int) -> Vector2:
return CORE_BLUE if team == 0 else mirror(CORE_BLUE)

View file

@ -7,9 +7,11 @@ extends RefCounted
enum Phase { PLANNING, COMBAT, OVER }
const ONGOING := -2
const DRAW := -1
const GROTTO := "grotto"
const BOT_NAMES: Array[String] = ["Eichrufer", "Aschenzunge", "Quellwächter", "Glutfaust", "Moosauge", "Basaltherz", "Rindenkrone", "Funkenschritt"]
const ROLES: Dictionary = {"g01": "melee", "g03": "front", "g05": "ranged", "g07": "splash", "g09": "support",
"w01": "melee", "w03": "front", "w05": "ranged", "w07": "splash", "w09": "support"}
const HERO_ROLES: Dictionary = {"held_ringborke": "front", "held_quellgeflecht": "support", "held_funkenlanze": "ranged"}
const ROW_ORDER: Array[String] = ["front", "melee", "splash", "ranged", "support"]
var balance: LuneBalance
@ -25,6 +27,9 @@ var building_hp: Dictionary = {}
var combat: LuneCombat
var last_settlement: Dictionary = {}
var history: Array[Dictionary] = []
## Public boss state. claim = {team, won_round, deploy_round, chooser_slot, lane} or {}.
var boss: Dictionary = {}
var boss_log: Array[Dictionary] = []
## Remainder rotation per team so both teams follow the same distribution pattern.
var _reward_rotation: Array[int] = [0, 0]
@ -32,7 +37,7 @@ func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
map = LuneMap.new(balance)
## options: seed, human_slot (-1 for six bots), bunds (Array of six), names.
## options: seed, human_slot (-1 for six bots), bunds (Array of six).
func setup(options: Dictionary = {}) -> void:
seed_value = int(options.get("seed", 1))
rng.seed = seed_value
@ -43,6 +48,8 @@ func setup(options: Dictionary = {}) -> void:
history.clear()
last_settlement = {}
_reward_rotation = [0, 0]
boss = {"next_available_round": 1, "claim": {}}
boss_log.clear()
players.clear()
var human: int = int(options.get("human_slot", 1))
var bunds: Array = options.get("bunds", [])
@ -50,7 +57,8 @@ 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, "next_seq": 1}
"gold": balance.start_gold, "roster": [], "hero": {}, "bookings": [], "ready": slot != human,
"earned": 0, "spent": 0, "next_seq": 1}
player.draft = _snapshot(player)
player.undo = []
players.append(player)
@ -59,7 +67,8 @@ 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), "next_seq": int(player.next_seq)}
return {"gold": int(player.gold), "roster": (player.roster as Array).duplicate(true), "next_seq": int(player.next_seq),
"hero": (player.hero as Dictionary).duplicate(true), "new_bookings": [], "boss_lane": ""}
func slot_of(team: int, lane: String) -> int:
return team * 3 + LuneMap.LANES.find(lane)
@ -67,6 +76,9 @@ func slot_of(team: int, lane: String) -> int:
func base_ids(bund: String) -> Array[String]:
return DataCatalog.faction_ids(bund)
func grotto_available() -> bool:
return round_number >= int(boss.next_available_round)
# ---------------------------------------------------------------- planning commands
func submit(slot: int, command: Dictionary) -> Dictionary:
if phase != Phase.PLANNING:
@ -94,8 +106,17 @@ func _find(roster: Array, uid: String) -> int:
return index
return -1
func _next_seq(player: Dictionary, draft: Dictionary) -> int:
var seq: int = int(draft.get("next_seq", player.next_seq))
draft.next_seq = seq + 1
return seq
func _valid_destination(lane: String) -> bool:
return LuneMap.LANES.has(lane) or (lane == GROTTO and grotto_available())
func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dictionary:
var roster: Array = draft.roster
var hero: Dictionary = draft.hero
match str(command.get("type", "")):
"buy":
var type_id: String = str(command.get("type_id", ""))
@ -111,8 +132,7 @@ func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dicti
return _result(false, "Unbekannte Lane.")
draft.gold = int(draft.gold) - cost
# 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
var seq: int = _next_seq(player, draft)
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":
@ -141,12 +161,118 @@ func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dicti
roster.remove_at(index)
return _result(true, "Für %d Gold zurückgegeben." % refund)
"assign":
var index: int = _find(roster, str(command.get("uid", "")))
var uid: String = str(command.get("uid", ""))
var lane: String = str(command.get("lane", ""))
if index < 0 or not LuneMap.LANES.has(lane):
if not _valid_destination(lane):
return _result(false, "Die Grotte ist bis Runde %d gesperrt." % int(boss.next_available_round) if lane == GROTTO else "Ungültige Zuweisung.")
if not hero.is_empty() and str(hero.uid) == uid:
hero.lane = lane
else:
var index: int = _find(roster, uid)
if index < 0:
return _result(false, "Ungültige Zuweisung.")
roster[index].lane = lane
return _result(true, "Auf %s eingeteilt." % LuneMap.LANE_NAMES[lane])
return _result(true, "In die Bossgrotte entsandt." if lane == GROTTO else "Auf %s eingeteilt." % LuneMap.LANE_NAMES[lane])
"hero_buy":
var hero_id: String = str(command.get("hero_id", ""))
if not LuneContent.HEROES.has(hero_id):
return _result(false, "Unbekannter Held.")
if not hero.is_empty():
return _result(false, "Pro Spieler ist ein aktiver Held erlaubt.")
var cost: int = int(LuneContent.HEROES[hero_id].cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
var seq: int = _next_seq(player, draft)
draft.hero = {"uid": "h%d-%d" % [player.slot, seq], "seq": seq, "hero_id": hero_id, "lane": player.lane, "xp": 0,
"items": [], "talents": [], "invested": cost, "fresh": true, "fresh_items": []}
return _result(true, "%s angeworben." % LuneContent.HEROES[hero_id].name)
"hero_cancel":
if hero.is_empty() or not bool(hero.get("fresh", false)):
return _result(false, "Nur ein Held aus dieser Planung lässt sich zurückgeben.")
var refund: int = int(hero.invested)
for item_id: String in hero.items:
refund += int(LuneContent.ITEMS[item_id].cost)
draft.gold = int(draft.gold) + refund
draft.hero = {}
return _result(true, "Held zurückgegeben (%d Gold)." % refund)
"item_buy":
var item_id: String = str(command.get("item_id", ""))
if command.has("uid") and (hero.is_empty() or str(command.uid) != str(hero.uid)):
return _result(false, "Normale Truppen tragen keine Items.")
if hero.is_empty():
return _result(false, "Items benötigen einen Helden.")
if not LuneContent.ITEMS.has(item_id):
return _result(false, "Unbekanntes Item.")
var item: Dictionary = LuneContent.ITEMS[item_id]
if (hero.items as Array).has(item_id):
return _result(false, "%s trägt dein Held bereits; Items sind einzigartig." % item.name)
if (hero.items as Array).size() >= LuneContent.ITEM_SLOTS:
return _result(false, "Alle %d Itemplätze sind belegt." % LuneContent.ITEM_SLOTS)
if LuneContent.level_for(int(hero.xp)) < int(item.get("min_level", 1)):
return _result(false, "%s erfordert Stufe %d." % [item.name, int(item.min_level)])
if int(draft.gold) < int(item.cost):
return _result(false, "Es fehlen %d Gold." % (int(item.cost) - int(draft.gold)))
draft.gold = int(draft.gold) - int(item.cost)
(hero.items as Array).append(item_id)
(hero.fresh_items as Array).append(item_id)
return _result(true, "%s ausgerüstet." % item.name)
"item_sell":
var item_id: String = str(command.get("item_id", ""))
if hero.is_empty() or not (hero.items as Array).has(item_id):
return _result(false, "Dein Held trägt dieses Item nicht.")
var cost: int = int(LuneContent.ITEMS[item_id].cost)
var fresh: bool = (hero.fresh_items as Array).has(item_id)
var refund: int = cost if fresh else floori(float(cost) * LuneContent.ITEM_REFUND)
(hero.items as Array).erase(item_id)
(hero.fresh_items as Array).erase(item_id)
draft.gold = int(draft.gold) + refund
return _result(true, "Item für %d Gold verkauft." % refund)
"talent":
if hero.is_empty():
return _result(false, "Talente benötigen einen Helden.")
var tier: int = int(command.get("tier", -1))
var choice: int = int(command.get("choice", -1))
var talents: Array = hero.talents
if tier != talents.size() or tier < 0 or tier >= LuneContent.TALENT_LEVELS.size() or choice < 0 or choice > 1:
return _result(false, "Dieses Talent ist jetzt nicht wählbar.")
if LuneContent.level_for(int(hero.xp)) < LuneContent.TALENT_LEVELS[tier]:
return _result(false, "Talentstufe %d erfordert Heldenstufe %d." % [tier + 1, LuneContent.TALENT_LEVELS[tier]])
var option: Dictionary = LuneContent.TALENTS[hero.hero_id][tier][choice]
talents.append(option.id)
return _result(true, "Talent %s gewählt." % option.name)
"champion_book":
var champion_id: String = str(command.get("champion_id", ""))
var lane: String = str(command.get("lane", player.lane))
if not LuneContent.CHAMPIONS.has(champion_id) or not LuneMap.LANES.has(lane):
return _result(false, "Ungültige Buchung.")
if (draft.new_bookings as Array).size() >= LuneContent.MAX_BOOKINGS:
return _result(false, "Höchstens %d Angriffschampions pro Runde." % LuneContent.MAX_BOOKINGS)
var cost: int = int(LuneContent.CHAMPIONS[champion_id].cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
var seq: int = _next_seq(player, draft)
(draft.new_bookings as Array).append({"uid": "c%d-%d" % [player.slot, seq], "seq": seq, "champion_id": champion_id, "lane": lane, "cost": cost})
return _result(true, "%s für die nächste Runde auf %s gebucht." % [LuneContent.CHAMPIONS[champion_id].name, LuneMap.LANE_NAMES[lane]])
"champion_cancel":
var uid: String = str(command.get("uid", ""))
var bookings: Array = draft.new_bookings
for index: int in range(bookings.size()):
if str(bookings[index].uid) == uid:
draft.gold = int(draft.gold) + int(bookings[index].cost)
bookings.remove_at(index)
return _result(true, "Buchung zurückgenommen.")
return _result(false, "Nur Buchungen aus dieser Planung lassen sich zurücknehmen.")
"boss_lane":
var claim: Dictionary = boss.claim
var lane: String = str(command.get("lane", ""))
if claim.is_empty() or int(claim.deploy_round) != round_number or int(claim.chooser_slot) != int(player.slot):
return _result(false, "Du bestimmst die Boss-Lane nicht.")
if not LuneMap.LANES.has(lane):
return _result(false, "Unbekannte Lane.")
draft.boss_lane = lane
return _result(true, "Der Boss greift über %s an." % LuneMap.LANE_NAMES[lane])
return _result(false, "Unbekannter Befehl.")
func _result(ok: bool, message: String) -> Dictionary:
@ -162,6 +288,19 @@ func all_ready() -> bool:
return false
return true
## Public information for everyone: champion bookings and the boss deployment of this round.
func announcements() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for player: Dictionary in players:
for booking: Dictionary in player.bookings:
if int(booking.deploy_round) == round_number:
result.append({"kind": "champion", "team": player.team, "slot": player.slot, "lane": booking.lane, "champion_id": booking.champion_id})
var claim: Dictionary = boss.claim
if not claim.is_empty() and int(claim.deploy_round) == round_number:
result.append({"kind": "boss", "team": claim.team, "slot": claim.chooser_slot, "lane": claim.lane,
"bounty": LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * round_number})
return result
# ---------------------------------------------------------------- phase changes
## Validates every draft again and commits all of them at once, then starts combat.
func begin_combat() -> bool:
@ -171,18 +310,32 @@ func begin_combat() -> bool:
if not _draft_valid(player):
player.draft = _snapshot(player)
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)
var draft: Dictionary = player.draft
player.spent = int(player.spent) + maxi(0, int(player.gold) - int(draft.gold))
player.gold = int(draft.gold)
player.next_seq = int(draft.get("next_seq", player.next_seq))
player.roster = (draft.roster as Array).duplicate(true)
for entry: Dictionary in player.roster:
entry.erase("fresh")
player.hero = (draft.hero as Dictionary).duplicate(true)
if not (player.hero as Dictionary).is_empty():
player.hero.erase("fresh")
player.hero.fresh_items = []
for booking: Dictionary in draft.new_bookings:
var committed: Dictionary = booking.duplicate()
committed.deploy_round = round_number + 1
(player.bookings as Array).append(committed)
var claim: Dictionary = boss.claim
if not claim.is_empty() and int(claim.chooser_slot) == int(player.slot) and not str(draft.boss_lane).is_empty():
claim.lane = draft.boss_lane
player.undo = []
combat = LuneCombat.new(map, balance, round_number)
for definition: Dictionary in map.buildings:
if float(building_hp[definition.id]) > 0.0:
combat.add_building(definition, float(building_hp[definition.id]))
_spawn_waves()
_spawn_champions()
_spawn_deployed_boss()
_spawn_armies()
phase = Phase.COMBAT
return true
@ -198,9 +351,19 @@ func _draft_valid(player: Dictionary) -> bool:
for candidate: String in allowed:
if str(DataCatalog.get_unit(candidate).get("upgrade_id", "")) == id:
base = candidate
if not allowed.has(base) or not LuneMap.LANES.has(str(entry.lane)):
if not allowed.has(base) or not _valid_destination(str(entry.lane)):
return false
return true
var hero: Dictionary = draft.hero
if not hero.is_empty():
var items: Array = hero.items
if items.size() > LuneContent.ITEM_SLOTS or not _valid_destination(str(hero.lane)):
return false
for index: int in range(items.size()):
if items.find(items[index]) != index or not LuneContent.ITEMS.has(items[index]):
return false
if (hero.talents as Array).size() > LuneContent.TALENT_LEVELS.size():
return false
return (draft.new_bookings as Array).size() <= LuneContent.MAX_BOOKINGS
func _spawn_waves() -> void:
var wave: Array[Dictionary] = LuneWaves.build(balance, round_number)
@ -211,6 +374,54 @@ func _spawn_waves() -> void:
var lateral: float = (float(index % 3) - 1.0) * 1.3
combat.spawn("monster", team, lane, wave[index].duplicate(true), 11.0 - float(row) * 1.4, lateral)
func _spawn_champions() -> void:
for team: int in range(2):
for lane: String in LuneMap.LANES:
var index: int = 0
for slot: int in range(team * 3, team * 3 + 3):
for booking: Dictionary in players[slot].bookings:
if int(booking.deploy_round) == round_number and booking.lane == lane:
var definition: Dictionary = LuneContent.champion_definition(booking.champion_id, round_number, balance)
combat.spawn("champion", team, lane, definition, 13.0, (float(index % 3) - 1.0) * 1.4, slot, str(booking.uid))
index += 1
func _spawn_deployed_boss() -> void:
var claim: Dictionary = boss.claim
if claim.is_empty() or int(claim.deploy_round) != round_number:
return
var definition: Dictionary = LuneContent.boss_definition(round_number, balance, true)
combat.spawn("boss", int(claim.team), str(claim.lane), definition, 14.0, 0.0, int(claim.chooser_slot), "boss-%d" % round_number)
boss_log.append({"round": round_number, "event": "deployed", "team": claim.team, "lane": claim.lane})
func _army_entries(slot: int) -> Array:
var player: Dictionary = players[slot]
var entries: Array = []
for entry: Dictionary in player.roster:
entries.append({"slot": slot, "uid": entry.uid, "seq": int(entry.seq), "lane": entry.lane, "role": role_of(entry.type_id),
"invested": int(entry.invested), "definition": _troop_definition(entry.type_id), "kind": "troop"})
var hero: Dictionary = player.hero
if not hero.is_empty():
var invested: int = int(hero.invested)
for item_id: String in hero.items:
invested += int(LuneContent.ITEMS[item_id].cost)
var definition: Dictionary = LuneContent.hero_definition(hero)
definition.role = HERO_ROLES[hero.hero_id]
entries.append({"slot": slot, "uid": hero.uid, "seq": int(hero.seq), "lane": hero.lane, "role": definition.role,
"invested": invested, "definition": definition, "kind": "hero"})
return entries
func _troop_definition(type_id: String) -> Dictionary:
var definition: Dictionary = DataCatalog.get_unit(type_id)
definition.role = role_of(type_id)
return definition
func _sorted(entries: Array) -> Array:
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var ra: int = ROW_ORDER.find(a.role)
var rb: int = ROW_ORDER.find(b.role)
return a.seq < b.seq if ra == rb else ra < rb)
return entries
func _spawn_armies() -> void:
for team: int in range(2):
for lane: String in LuneMap.LANES:
@ -218,20 +429,28 @@ func _spawn_armies() -> void:
var owner: int = slot_of(team, lane)
var ordered: Array = []
for slot: int in [owner] + [team * 3, team * 3 + 1, team * 3 + 2].filter(func(s: int) -> bool: return s != owner):
var entries: Array = (players[slot].roster as Array).filter(func(e: Dictionary) -> bool: return e.lane == lane)
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 int(a.seq) < int(b.seq) if ra == rb else ra < rb)
for entry: Dictionary in entries:
ordered.append([slot, entry])
ordered.append_array(_sorted(_army_entries(slot).filter(func(e: Dictionary) -> bool: return e.lane == lane)))
for index: int in range(ordered.size()):
var slot: int = ordered[index][0]
var entry: Dictionary = ordered[index][1]
var entry: Dictionary = ordered[index]
var row: int = index / 4
var lateral: float = (float(index % 4) - 1.5) * 1.2
var definition: Dictionary = DataCatalog.get_unit(entry.type_id)
combat.spawn("troop", team, lane, definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, slot, str(entry.uid))
combat.spawn(entry.kind, team, lane, entry.definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, entry.slot, str(entry.uid))
# Grotto expeditions leave their lanes for the whole combat phase.
var expedition: bool = false
for team: int in range(2):
var ordered: Array = []
for slot: int in range(team * 3, team * 3 + 3):
ordered.append_array(_sorted(_army_entries(slot).filter(func(e: Dictionary) -> bool: return e.lane == GROTTO)))
var entry_point: Vector2 = map.grotto_entry(team)
var inward: Vector2 = (map.grotto_boss_point() - entry_point).normalized()
var across := Vector2(-inward.y, inward.x) * (1.0 if team == 0 else -1.0)
for index: int in range(ordered.size()):
var entry: Dictionary = ordered[index]
var at: Vector2 = entry_point - inward * float(index / 3) * 1.2 + across * (float(index % 3) - 1.0) * 1.1
combat.spawn_at(entry.kind, team, "grotto", entry.definition, at, entry.slot, str(entry.uid))
expedition = true
if expedition:
combat.add_grotto_boss(LuneContent.boss_definition(round_number, balance, false))
func role_of(type_id: String) -> String:
var id: String = type_id
@ -256,11 +475,15 @@ func _settle() -> void:
var gains: Array[int] = [0, 0, 0, 0, 0, 0]
var lane_kills: Dictionary = {}
var buildings_lost: Array[String] = []
var xp_gain: Dictionary = {}
for event: Dictionary in combat.events:
var dying: int = int(event.team)
var killer: int = 1 - dying
var killer: int = 1 - dying if dying < 2 else -1
var bounty: int = int(event.bounty)
if event.kind in ["monster", "champion", "boss"] and bounty > 0 and event.zone == "lane":
if event.kind == "boss" and event.zone == "lane" and bounty > 0 and killer >= 0:
# The defenders of a deployed boss share its bounty evenly.
_split(gains, killer, bounty)
elif event.kind in ["monster", "champion"] and bounty > 0 and event.zone == "lane" and killer >= 0:
var owner: int = slot_of(killer, str(event.lane))
var lane_part: int = int(floor(float(bounty) * balance.lane_share))
gains[owner] += lane_part
@ -269,19 +492,42 @@ func _settle() -> void:
elif event.kind in ["tower", "core"]:
buildings_lost.append(str(event.building_id))
_split(gains, killer, bounty)
var heroes: Array = event.get("xp_heroes", [])
if not heroes.is_empty():
var share: int = maxi(1, int(event.xp) / heroes.size())
for uid: String in heroes:
xp_gain[uid] = int(xp_gain.get(uid, 0)) + share
for unit: LuneUnit in combat.units:
if unit.is_building():
building_hp[unit.building_id] = unit.hp if unit.alive else 0.0
elif unit.kind == "hero":
xp_gain[unit.persistent_uid] = int(xp_gain.get(unit.persistent_uid, 0)) + LuneContent.XP_PARTICIPATION + LuneContent.XP_PER_ROUND * round_number
for player: Dictionary in players:
var hero: Dictionary = player.hero
if not hero.is_empty() and xp_gain.has(hero.uid):
hero.xp = int(hero.xp) + int(xp_gain[hero.uid])
var income: int = balance.round_income(round_number)
for player: Dictionary in players:
var total: int = gains[player.slot] + (0 if not combat.destroyed_cores.is_empty() else income)
player.gold = int(player.gold) + total
player.earned = int(player.earned) + total
# Deployed bookings are consumed; they never return as property.
player.bookings = (player.bookings as Array).filter(func(b: Dictionary) -> bool: return int(b.deploy_round) > round_number)
# The claim is decided from this round's expedition before its units go home.
var boss_result: String = _settle_boss()
for player: Dictionary in players:
# An expedition is an order for one round: units return to their owner's lane.
for entry: Dictionary in player.roster:
if entry.lane == GROTTO:
entry.lane = player.lane
if not (player.hero as Dictionary).is_empty() and player.hero.lane == GROTTO:
player.hero.lane = player.lane
last_settlement = {"round": round_number, "gains": gains, "income": income, "buildings_lost": buildings_lost,
"end_reason": combat.end_reason, "combat_time": combat.elapsed, "lane_kills": lane_kills}
"end_reason": combat.end_reason, "combat_time": combat.elapsed, "lane_kills": lane_kills, "xp": xp_gain, "boss": boss_result}
history.append({"round": round_number, "end_reason": combat.end_reason, "combat_time": combat.elapsed,
"buildings": _alive_count(), "gold": players.map(func(p: Dictionary) -> int: return int(p.gold)),
"lost": buildings_lost.duplicate()})
"lost": buildings_lost.duplicate(), "boss": boss_result,
"hero_levels": players.map(func(p: Dictionary) -> int: return 0 if (p.hero as Dictionary).is_empty() else LuneContent.level_for(int(p.hero.xp)))})
if not combat.destroyed_cores.is_empty():
winner = DRAW if combat.destroyed_cores.size() >= 2 else 1 - combat.destroyed_cores[0]
end_note = "Beide Ancients fielen im selben Schritt." if winner == DRAW else "Ancient von %s zerstört." % ("Blau" if combat.destroyed_cores[0] == 0 else "Rot")
@ -294,9 +540,50 @@ func _settle() -> void:
player.undo = []
player.ready = not bool(player.is_human)
## Boss bookkeeping at settlement. Returns a short outcome label for reports.
## Idempotent: a repeated settlement signal for the same combat changes nothing.
func _settle_boss() -> String:
var fought: int = combat.round_number
if int(boss.get("settled_round", 0)) == fought:
return "repeat"
boss.settled_round = fought
var result: String = "none"
var claim: Dictionary = boss.claim
if not claim.is_empty() and int(claim.deploy_round) == fought:
# The deployment happens exactly once; afterwards the claim is gone.
boss.claim = {}
result = "deployed"
if combat.grotto_outcome == "success" and (boss.claim as Dictionary).is_empty() and fought >= int(boss.next_available_round):
var team: int = combat.grotto_claim_team
var chooser: int = _boss_chooser(team)
boss.claim = {"team": team, "won_round": fought, "deploy_round": fought + 1, "chooser_slot": chooser,
"lane": players[chooser].lane}
boss.next_available_round = fought + 1 + LuneContent.BOSS_LOCK_ROUNDS
boss_log.append({"round": fought, "event": "won", "team": team, "chooser": chooser})
result = "won"
elif combat.grotto_outcome == "failed":
boss_log.append({"round": fought, "event": "failed"})
result = "failed" if result == "none" else result
return result
## Contributing lane player of the winning team with the highest army value in the grotto;
## ties go to the lower slot.
func _boss_chooser(team: int) -> int:
var best: int = team * 3
var best_value: int = -1
for slot: int in range(team * 3, team * 3 + 3):
var value: int = 0
for entry: Dictionary in _army_entries(slot):
if entry.lane == GROTTO:
value += int(entry.invested)
if value > best_value:
best = slot
best_value = value
return best
## Distributes an amount evenly over a team; the remainder rotates so no slot is favoured.
func _split(gains: Array[int], team: int, amount: int) -> void:
if amount <= 0:
if amount <= 0 or team < 0:
return
var share: int = amount / 3
var rest: int = amount % 3

View file

@ -35,6 +35,9 @@ var effects: Dictionary = {}
var dots: Dictionary = {}
var ability_ready: float = 6.0
var reached_base: bool = false
## Grotto boss waits while two expeditions still fight each other.
var dormant: bool = false
var aura_ready: float = 4.0
# Presentation-only signals; never read by the rules.
var attack_serial: int = 0
var ability_serial: int = 0
@ -133,7 +136,14 @@ func damage_reduction() -> float:
func attack_speed_factor() -> float:
var slowing: float = strongest_effect("attack_slow") * (0.5 if is_boss() else 1.0)
return 1.0 + clampf(strongest_effect("attack_speed") - slowing, -0.25, 0.25)
var bonus: float = float(def.get("attack_speed_bonus", 0.0))
return 1.0 + clampf(strongest_effect("attack_speed") - slowing + bonus, -0.25, 0.5)
func has_talent(id: String) -> bool:
return (def.get("talents", []) as Array).has(id)
func role() -> String:
return str(def.get("role", ""))
func movement_factor() -> float:
return 1.0 - minf(0.35, strongest_effect("movement_slow") * (0.5 if is_boss() else 1.0))

View file

@ -126,11 +126,23 @@ func _rebuild_preview() -> void:
_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 = {}
var entries: Array = []
for entry: Dictionary in player.draft.roster:
var lane: String = str(entry.lane)
entries.append([DataCatalog.get_unit(entry.type_id), str(entry.lane), str(entry.uid), "troop"])
var hero: Dictionary = player.draft.hero
if not hero.is_empty():
entries.append([LuneContent.hero_definition(hero), str(hero.lane), str(hero.uid), "hero"])
for entry: Array in entries:
var lane: String = entry[1]
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))
var unit: LuneUnit
if lane == LuneMatch.GROTTO:
# Grotto orders preview at the team's grotto entrance.
var at: Vector2 = match_state.map.grotto_entry(int(player.team)) + Vector2(float(index % 3) - 1.0, float(index / 3)) * 1.1
unit = _preview.spawn_at(entry[3], int(player.team), "grotto", entry[0], at, human_slot, entry[2])
else:
unit = _preview.spawn(entry[3], int(player.team), lane, entry[0], maxf(.4, 7.2 - float(index / 4) * 1.5), (float(index % 4) - 1.5) * 1.2, human_slot, entry[2])
_add_view(unit)
func _add_view(unit: LuneUnit) -> void:
@ -177,6 +189,8 @@ func _process(delta: float) -> void:
for definition: Dictionary in match_state.map.buildings:
max_hp[definition.id] = definition.max_hp
world.sync_buildings(hp, max_hp, _time, delta)
var claim: Dictionary = match_state.boss.claim
world.sync_grotto(match_state.grotto_available(), int(claim.team) if not claim.is_empty() else -1)
hud.minimap.camera_focus = camera.focus
hud.minimap.camera_span = camera.zoom

View file

@ -0,0 +1,186 @@
extends VBoxContainer
## Hero tab: recruit one of three heroes, then level, lane, three item slots and talents.
## One shared portrait viewport renders the real game model; nothing here owns state.
signal command_requested(command: Dictionary)
var match_state: LuneMatch
var slot: int = 0
var _signature: String = "@"
var _portrait_view: SubViewport
var _portrait_root: Node3D
var _portrait_model: Node3D
var _portrait_id: String = ""
var _body: VBoxContainer
var _time: float = 0.0
func _ready() -> void:
add_theme_constant_override("separation", 8)
var frame := SubViewportContainer.new()
frame.stretch = true
frame.custom_minimum_size = Vector2(0, 112)
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(frame)
_portrait_view = SubViewport.new()
_portrait_view.own_world_3d = true
_portrait_view.transparent_bg = true
_portrait_view.msaa_3d = Viewport.MSAA_2X
_portrait_view.render_target_update_mode = SubViewport.UPDATE_WHEN_VISIBLE
frame.add_child(_portrait_view)
_portrait_root = Node3D.new()
_portrait_view.add_child(_portrait_root)
var environment := WorldEnvironment.new()
environment.environment = Environment.new()
environment.environment.background_mode = Environment.BG_CLEAR_COLOR
environment.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
environment.environment.ambient_light_color = Color("8fa0b8")
environment.environment.ambient_light_energy = .5
_portrait_root.add_child(environment)
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-35, -35, 0)
key.light_energy = 1.6
key.light_color = Color("ffe2bc")
_portrait_root.add_child(key)
var rim := DirectionalLight3D.new()
rim.rotation_degrees = Vector3(-20, 150, 0)
rim.light_energy = .8
rim.light_color = Color("9fc4ff")
_portrait_root.add_child(rim)
var camera := Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 2.3
camera.position = Vector3(1.4, 1.35, 2.6)
_portrait_root.add_child(camera)
camera.look_at(Vector3(0, .9, 0), Vector3.UP)
_body = VBoxContainer.new()
_body.add_theme_constant_override("separation", 6)
add_child(_body)
func _process(delta: float) -> void:
_time += delta
if is_instance_valid(_portrait_model) and is_visible_in_tree():
if _portrait_model.has_method("preview"):
_portrait_model.preview("idle", fmod(_time, _portrait_model.clip_length("idle")))
_portrait_model.rotation.y = -.35
if match_state != null and match_state.phase == LuneMatch.Phase.PLANNING:
var signature: String = JSON.stringify([match_state.players[slot].draft.hero, match_state.players[slot].draft.gold, match_state.round_number])
if signature != _signature:
_signature = signature
_rebuild()
func _show_model(model_id: String) -> void:
if model_id == _portrait_id:
return
_portrait_id = model_id
if is_instance_valid(_portrait_model):
_portrait_model.queue_free()
_portrait_model = AssetLibrary.instantiate_fighter(model_id)
if _portrait_model != null:
_portrait_root.add_child(_portrait_model)
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.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
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, command: Dictionary, disabled: bool = false, tooltip: String = "", variation: String = "Button") -> Button:
var button := Button.new()
button.text = text_value
button.theme_type_variation = variation
button.disabled = disabled
button.tooltip_text = tooltip
button.focus_mode = Control.FOCUS_ALL
button.pressed.connect(func() -> void: command_requested.emit(command))
parent.add_child(button)
return button
func _rebuild() -> void:
for child: Node in _body.get_children():
child.free()
var draft: Dictionary = match_state.players[slot].draft
var hero: Dictionary = draft.hero
var gold: int = int(draft.gold)
if hero.is_empty():
_show_model("w04")
_label("HELD ANWERBEN · ein aktiver Held pro Spieler", "SectionLabel", _body)
for hero_id: String in LuneContent.HEROES:
var spec: Dictionary = LuneContent.HEROES[hero_id]
var row := HBoxContainer.new()
_body.add_child(row)
var text := VBoxContainer.new()
text.size_flags_horizontal = Control.SIZE_EXPAND_FILL
text.add_theme_constant_override("separation", 0)
row.add_child(text)
_label("%s · %s" % [spec.name, spec.role], "Label", text, 16)
_label(spec.summary, "MutedLabel", text, 14)
var buy := _button("%d G" % int(spec.cost), row, {"type": "hero_buy", "hero_id": hero_id}, gold < int(spec.cost), "%s anwerben" % spec.name)
buy.mouse_entered.connect(func() -> void: _show_model(spec.model))
buy.focus_entered.connect(func() -> void: _show_model(spec.model))
return
var spec: Dictionary = LuneContent.HEROES[hero.hero_id]
_show_model(spec.model)
var level: int = LuneContent.level_for(int(hero.xp))
var next_xp: int = LuneContent.LEVEL_XP[mini(level, LuneContent.LEVEL_XP.size() - 1)]
_label("%s · Stufe %d" % [spec.name, level], "CardTitleLabel", _body, 22)
var bar := ProgressBar.new()
bar.min_value = LuneContent.LEVEL_XP[level - 1]
bar.max_value = maxi(next_xp, LuneContent.LEVEL_XP[level - 1] + 1)
bar.value = int(hero.xp)
bar.show_percentage = false
bar.custom_minimum_size.y = 8
bar.tooltip_text = "%d Erfahrung · nächste Stufe bei %d" % [int(hero.xp), next_xp]
_body.add_child(bar)
_label("%s · Erfahrung aus Gefechten in der Nähe und aus jeder Runde" % spec.role, "MutedLabel", _body, 14)
var lanes := HBoxContainer.new()
_body.add_child(lanes)
_label("Einsatz", "MutedLabel", lanes, 14).size_flags_horizontal = Control.SIZE_EXPAND_FILL
for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]:
var name: String = "Grotte" if lane == LuneMatch.GROTTO else LuneMap.LANE_NAMES[lane]
var button := _button(name, lanes, {"type": "assign", "uid": hero.uid, "lane": lane}, lane == LuneMatch.GROTTO and not match_state.grotto_available(), "Held auf %s" % name, "ChoiceButton")
button.toggle_mode = true
button.button_pressed = hero.lane == lane
_label("ITEMS · %d / %d Plätze" % [(hero.items as Array).size(), LuneContent.ITEM_SLOTS], "SectionLabel", _body)
var slots := HBoxContainer.new()
_body.add_child(slots)
for index: int in range(LuneContent.ITEM_SLOTS):
if index < (hero.items as Array).size():
var item_id: String = hero.items[index]
var item: Dictionary = LuneContent.ITEMS[item_id]
var fresh: bool = (hero.get("fresh_items", []) as Array).has(item_id)
var refund: int = int(item.cost) if fresh else floori(float(item.cost) * LuneContent.ITEM_REFUND)
_button("%s ×" % item.name, slots, {"type": "item_sell", "item_id": item_id}, false, "%s\nVerkaufen für %d Gold" % [item.text, refund])
else:
var empty := Button.new()
empty.text = "leer"
empty.disabled = true
slots.add_child(empty)
var shop := GridContainer.new()
shop.columns = 2
_body.add_child(shop)
for item_id: String in LuneContent.ITEMS:
var item: Dictionary = LuneContent.ITEMS[item_id]
var blocked: bool = (hero.items as Array).has(item_id) or (hero.items as Array).size() >= LuneContent.ITEM_SLOTS or gold < int(item.cost) or level < int(item.get("min_level", 1))
var button := _button("%s · %d G" % [item.name, int(item.cost)], shop, {"type": "item_buy", "item_id": item_id, "uid": hero.uid}, blocked, item.text)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var talents: Array = hero.talents
for tier: int in range(LuneContent.TALENT_LEVELS.size()):
var options: Array = LuneContent.TALENTS[hero.hero_id][tier]
if tier < talents.size():
for option: Dictionary in options:
if option.id == talents[tier]:
_label("TALENT %d · %s: %s" % [tier + 1, option.name, option.text], "MutedLabel", _body, 14)
else:
var required: int = LuneContent.TALENT_LEVELS[tier]
_label("TALENT %d · %s" % [tier + 1, "jetzt wählen" if level >= required and tier == talents.size() else "ab Stufe %d" % required], "SectionLabel", _body)
var row := HBoxContainer.new()
_body.add_child(row)
for choice: int in range(options.size()):
var option: Dictionary = options[choice]
var button := _button(option.name, row, {"type": "talent", "tier": tier, "choice": choice}, level < required or tier != talents.size(), option.text)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
break

View file

@ -15,6 +15,8 @@ signal rematch_requested
signal settings_requested
const Minimap = preload("res://scripts/luneraria/ui/lune_minimap.gd")
const HeroPanel = preload("res://scripts/luneraria/ui/lune_hero_panel.gd")
const ReinforcePanel = preload("res://scripts/luneraria/ui/lune_reinforce_panel.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"}
@ -50,6 +52,11 @@ var _over: Control
var _over_title: Label
var _over_body: Label
var _roster_signature: String = "@"
var hero_panel: VBoxContainer
var reinforce_panel: VBoxContainer
var _pages: Array[Control] = []
var _tabs: ChoiceGroup
var _boss_status: Label
func _ready() -> void:
theme = SaumwachtTheme.shared()
@ -152,19 +159,34 @@ func _lane_panel() -> void:
button.tooltip_text = "Kamera auf %s" % LuneMap.LANE_NAMES[lane]
_lane_rows[lane] = button
_team_list = _label("", "MutedLabel", column, 14)
_boss_status = _label("", "Label", column, 15)
_boss_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_boss_status.custom_minimum_size.x = 264
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_left = -500
_plan_panel.offset_right = -16
_plan_panel.offset_top = 112
_plan_panel.offset_bottom = -270
var outer := VBoxContainer.new()
outer.add_theme_constant_override("separation", 8)
_plan_panel.add_child(outer)
_tabs = ChoiceGroup.new(true)
_tabs.name = "PlanTabs"
_tabs.add_option("Truppen")
_tabs.add_option("Held")
_tabs.add_option("Verstärkung")
outer.add_child(_tabs)
_tabs.item_selected.connect(show_page)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 8)
_plan_panel.add_child(column)
column.size_flags_vertical = Control.SIZE_EXPAND_FILL
outer.add_child(column)
_pages.append(column)
_label("TRUPPEN KAUFEN · 1–5", "SectionLabel", column)
_shop = GridContainer.new()
_shop.columns = 5
@ -179,18 +201,34 @@ func _plan_side() -> void:
_roster.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_roster.add_theme_constant_override("separation", 4)
scroll.add_child(_roster)
for index: int in range(2):
var page := ScrollContainer.new()
page.size_flags_vertical = Control.SIZE_EXPAND_FILL
page.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
page.visible = false
outer.add_child(page)
_pages.append(page)
var panel: VBoxContainer = HeroPanel.new() if index == 0 else ReinforcePanel.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
page.add_child(panel)
panel.command_requested.connect(func(command: Dictionary) -> void: command_requested.emit(command))
if index == 0:
hero_panel = panel
else:
reinforce_panel = panel
_tabs.select(0)
var actions := HBoxContainer.new()
column.add_child(actions)
outer.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 = _button("BEREIT · KAMPF ›", outer, 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_left = -500
_combat_panel.offset_right = -16
_combat_panel.offset_top = 112
_combat_info = _label("", "Label", _combat_panel, 16)
@ -264,8 +302,19 @@ func _process(delta: float) -> void:
if match_state != null:
refresh()
func show_page(index: int) -> void:
_tabs.select(index)
for page_index: int in range(_pages.size()):
_pages[page_index].visible = page_index == index
func refresh() -> void:
var player: Dictionary = match_state.players[human_slot]
if hero_panel.match_state != match_state:
hero_panel.match_state = match_state
hero_panel.slot = human_slot
reinforce_panel.match_state = match_state
reinforce_panel.slot = human_slot
_boss_status.text = _grotto_text()
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")
@ -329,6 +378,15 @@ func refresh() -> void:
minimap.match_state = match_state
minimap.queue_redraw()
func _grotto_text() -> String:
var claim: Dictionary = match_state.boss.claim
if not claim.is_empty():
var team: String = TEAM_NAMES[int(claim.team)]
return "BOSSGROTTE · von %s erobert (Runde %d)\nDer Boss greift in Runde %d für %s über %s an · Grotte wieder ab Runde %d" % [team, int(claim.won_round), int(claim.deploy_round), team, LuneMap.LANE_NAMES[claim.lane], int(match_state.boss.next_available_round)]
if match_state.grotto_available():
return "BOSSGROTTE · verfügbar\nEinheiten mit „G“ entsenden; sie fehlen ihrer Lane die ganze Kampfphase."
return "BOSSGROTTE · gesperrt bis Runde %d" % int(match_state.boss.next_available_round)
func _reason(reason: String) -> String:
return {"cleared": "alle Einheiten gefallen", "timeout": "Zeitfenster abgelaufen", "core": "Ancient zerstört"}.get(reason, reason)
@ -393,14 +451,16 @@ func _refresh_plan(player: Dictionary) -> void:
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:
for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]:
var lane_button := Button.new()
lane_button.text = LuneMap.LANE_NAMES[lane].left(1)
var grotto: bool = lane == LuneMatch.GROTTO
lane_button.text = "G" if grotto else 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]
lane_button.disabled = grotto and not match_state.grotto_available()
lane_button.tooltip_text = "In die Bossgrotte entsenden (fehlt der Lane die ganze Kampfphase)" if grotto else "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)

View file

@ -2,7 +2,7 @@ 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")]
const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55"), Color("b37cff")]
var match_state: LuneMatch
var camera_focus := Vector3.ZERO
var camera_span: float = 34.0
@ -42,12 +42,16 @@ func _draw() -> void:
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 claim: Dictionary = match_state.boss.claim
var grotto_color: Color = TEAM_COLORS[int(claim.team)] if not claim.is_empty() else (Color(.62, .42, .95) if match_state.grotto_available() else Color(.35, .32, .4))
draw_circle(grotto, 6.0, grotto_color)
draw_arc(grotto, 6.0, 0, TAU, 16, Color(1, 1, 1, .6), 1.0)
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))
var radius: float = 4.0 if unit.kind == "boss" else (3.2 if unit.kind == "hero" else (1.8 if unit.kind == "monster" else 2.4))
draw_circle(to_map(unit.position), radius, Color("f2c96b") if unit.kind == "hero" else 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):

View file

@ -0,0 +1,113 @@
extends VBoxContainer
## Reinforcement tab: book attack champions for the next round, see this round's public
## announcements, and steer a conquered boss if you are its chooser.
signal command_requested(command: Dictionary)
var match_state: LuneMatch
var slot: int = 0
var _lane: String = "mid"
var _signature: String = "@"
func _ready() -> void:
add_theme_constant_override("separation", 8)
func _process(_delta: float) -> void:
if match_state == null or match_state.phase != LuneMatch.Phase.PLANNING:
return
var draft: Dictionary = match_state.players[slot].draft
var signature: String = JSON.stringify([draft.gold, draft.new_bookings, draft.boss_lane, match_state.round_number, _lane, match_state.boss])
if signature != _signature:
_signature = signature
_rebuild()
func _label(text_value: String, variation: String, size: int = 0) -> Label:
var label := Label.new()
label.text = text_value
label.theme_type_variation = variation
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
if size > 0:
label.add_theme_font_size_override("font_size", size)
add_child(label)
return label
func _rebuild() -> void:
for child: Node in get_children():
child.free()
var draft: Dictionary = match_state.players[slot].draft
_label("ANGRIFFSCHAMPIONS · Kauf jetzt, Einsatz nächste Runde", "SectionLabel")
var lanes := HBoxContainer.new()
add_child(lanes)
var lane_label := Label.new()
lane_label.text = "Ziel-Lane"
lane_label.theme_type_variation = "MutedLabel"
lane_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
lanes.add_child(lane_label)
for lane: String in LuneMap.LANES:
var button := Button.new()
button.text = LuneMap.LANE_NAMES[lane]
button.theme_type_variation = "ChoiceButton"
button.toggle_mode = true
button.button_pressed = lane == _lane
button.pressed.connect(func() -> void: _lane = lane)
lanes.add_child(button)
for champion_id: String in LuneContent.CHAMPIONS:
var spec: Dictionary = LuneContent.CHAMPIONS[champion_id]
var row := HBoxContainer.new()
add_child(row)
var text := VBoxContainer.new()
text.size_flags_horizontal = Control.SIZE_EXPAND_FILL
text.add_theme_constant_override("separation", 0)
row.add_child(text)
var name := Label.new()
name.text = "%s · %s" % [spec.name, spec.role]
text.add_child(name)
var info := Label.new()
info.text = spec.text
info.theme_type_variation = "MutedLabel"
info.add_theme_font_size_override("font_size", 14)
info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
text.add_child(info)
var book := Button.new()
book.text = "%d G" % int(spec.cost)
book.disabled = int(draft.gold) < int(spec.cost) or (draft.new_bookings as Array).size() >= LuneContent.MAX_BOOKINGS
book.tooltip_text = "%s für Runde %d auf %s buchen" % [spec.name, match_state.round_number + 1, LuneMap.LANE_NAMES[_lane]]
book.pressed.connect(func() -> void: command_requested.emit({"type": "champion_book", "champion_id": champion_id, "lane": _lane}))
row.add_child(book)
for booking: Dictionary in draft.new_bookings:
var row := HBoxContainer.new()
add_child(row)
var label := Label.new()
label.text = "Gebucht: %s · %s · Runde %d" % [LuneContent.CHAMPIONS[booking.champion_id].name, LuneMap.LANE_NAMES[booking.lane], match_state.round_number + 1]
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
var cancel := Button.new()
cancel.text = "×"
cancel.tooltip_text = "Buchung zurücknehmen (volle Erstattung)"
var uid: String = booking.uid
cancel.pressed.connect(func() -> void: command_requested.emit({"type": "champion_cancel", "uid": uid}))
row.add_child(cancel)
add_child(HSeparator.new())
_label("ANGEKÜNDIGT FÜR DIESE RUNDE", "SectionLabel")
var lines: PackedStringArray = []
for entry: Dictionary in match_state.announcements():
var team: String = "Blau" if int(entry.team) == 0 else "Rot"
if entry.kind == "boss":
lines.append("%s: Schlundkrone greift über %s an · Kopfgeld für die Verteidiger %d Gold" % [team, LuneMap.LANE_NAMES[entry.lane], int(entry.bounty)])
else:
lines.append("%s: %s auf %s" % [team, LuneContent.CHAMPIONS[entry.champion_id].name, LuneMap.LANE_NAMES[entry.lane]])
_label("\n".join(lines) if not lines.is_empty() else "Keine Angriffschampions und kein Boss angekündigt.", "MutedLabel", 15)
var claim: Dictionary = match_state.boss.claim
if not claim.is_empty() and int(claim.chooser_slot) == slot and int(claim.deploy_round) == match_state.round_number:
_label("DEIN BOSS · wähle die Angriffs-Lane", "SectionLabel")
var row := HBoxContainer.new()
add_child(row)
var chosen: String = str(draft.boss_lane) if not str(draft.boss_lane).is_empty() else str(claim.lane)
for lane: String in LuneMap.LANES:
var button := Button.new()
button.text = LuneMap.LANE_NAMES[lane]
button.theme_type_variation = "ChoiceButton"
button.toggle_mode = true
button.button_pressed = lane == chosen
button.pressed.connect(func() -> void: command_requested.emit({"type": "boss_lane", "lane": lane}))
row.add_child(button)

View file

@ -2,7 +2,8 @@ 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")]
const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55"), Color("b37cff")]
const HERO_GOLD := Color("f2c96b")
var unit: LuneUnit
var model: Node3D
@ -49,7 +50,8 @@ func bind(source: LuneUnit) -> void:
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)
var ring_color: Color = HERO_GOLD if unit.kind == "hero" else TEAM_COLORS[unit.team]
_ring.material_override = _flat(ring_color, 2.2 if unit.kind in ["hero", "boss", "champion"] else (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
@ -82,6 +84,22 @@ func bind(source: LuneUnit) -> void:
_fill.position = Vector3(-.43, 0, 0)
_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_bars.add_child(_fill)
if unit.kind in ["hero", "champion", "boss"]:
# Named units carry a readable tag: heroes with level, champions with role, the boss.
var tag := Label3D.new()
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.fixed_size = true
tag.pixel_size = .0011
tag.font_size = 30
tag.outline_size = 10
tag.modulate = HERO_GOLD if unit.kind == "hero" else TEAM_COLORS[unit.team].lightened(.3)
tag.position.y = _height + .55
var title: String = str(unit.def.get("display_name", ""))
if unit.kind == "hero":
title = "%s · Stufe %d" % [title, int(unit.def.get("level", 1))]
tag.text = title
add_child(tag)
_last_attack = unit.attack_serial
_last_ability = unit.ability_serial
position = Vector3(unit.position.x, 0, unit.position.y)

View file

@ -9,6 +9,7 @@ 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 GROTTO := "res://assets/luneraria/grotto.glb"
const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")]
const TEAM_NAMES: Array[String] = ["Blau", "Rot"]
@ -38,6 +39,7 @@ func build(match_map: LuneMap) -> void:
_crystal_materials.append(crystal)
for definition: Dictionary in map.buildings:
_building(definition)
_grotto()
func terrain(key: String, color: Color) -> ShaderMaterial:
if not _terrain.has(key):
@ -161,6 +163,28 @@ func _base_plaza(team: int) -> void:
rim.scale = Vector3(1, .3, 1)
add_child(rim)
var grotto_light: OmniLight3D
func _grotto() -> void:
var grotto: Node3D = (load(GROTTO) as PackedScene).instantiate()
grotto.name = "BossGrotto"
grotto.position = to3(LuneMap.GROTTO)
_dress(grotto)
add_child(grotto)
grotto_light = OmniLight3D.new()
grotto_light.position = to3(LuneMap.GROTTO + Vector2(-2.0, -2.0), 2.4)
grotto_light.light_color = Color("b37cff")
grotto_light.light_energy = 2.2
grotto_light.omni_range = 9.0
add_child(grotto_light)
## Grotto glow: violet when open, dim when locked, team colour while a claim is pending.
func sync_grotto(available: bool, claim_team: int) -> void:
if grotto_light == null:
return
grotto_light.light_color = TEAM_COLORS[claim_team] if claim_team >= 0 else Color("b37cff")
grotto_light.light_energy = 2.2 if available or claim_team >= 0 else .5
func _far_from_lanes(point: Vector2, margin: float) -> bool:
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
@ -171,6 +195,8 @@ func _far_from_lanes(point: Vector2, margin: float) -> bool:
return false
if absf(point.x - point.y) < 5.0:
return false
if point.distance_to(LuneMap.GROTTO) < 9.0:
return false
return true
func _decoration() -> void:

View file

@ -0,0 +1,344 @@
extends Node
## M2 rules: heroes (XP, talents, three item slots), attack champions (book n -> fight n+1)
## and the boss grotto (contest, failure, success, one deployment, lock, bounty).
## Gold granted below is a test fixture on drafts only.
## Run: godot --headless --path . res://tests/luneraria/test_lune_m2.tscn
var checks: int = 0
var failures: Array[String] = []
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures.append(label)
printerr("M2_FAIL: ", label)
func _ready() -> void:
call_deferred("run")
func new_match(seed: int = 21) -> LuneMatch:
var state := LuneMatch.new()
state.setup({"seed": seed, "human_slot": -1, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]})
return state
func grant(state: LuneMatch, slot: int, gold: int) -> void:
state.players[slot].draft.gold = int(state.players[slot].draft.gold) + gold
func run_round(state: LuneMatch, planner: Callable = Callable()) -> void:
if planner.is_valid():
planner.call(state)
state.begin_combat()
state.finish_combat()
func run() -> void:
_test_heroes_and_items()
_test_xp_and_persistence()
_test_champions()
_test_grotto_withdrawal_and_contest()
_test_boss_success_lock_and_deployment()
_test_boss_failure_restores()
_test_bots_m2()
_test_no_legacy_economy()
_test_lane_loss()
var report := {"checks": checks, "failures": failures}
var file := FileAccess.open("res://docs/luneraria/logs/m2-tests.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("LUNE_M2_%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 _test_heroes_and_items() -> void:
var state := new_match()
grant(state, 0, 5000)
var gold: int = state.gold_of(0)
check(state.submit(0, {"type": "hero_buy", "hero_id": "held_ringborke"}).ok and state.gold_of(0) == gold - 260, "hero costs exactly 260")
check(not state.submit(0, {"type": "hero_buy", "hero_id": "held_funkenlanze"}).ok, "only one active hero per player")
state.submit(0, {"type": "buy", "type_id": "g01"})
var troop_uid: String = state.players[0].draft.roster[0].uid
check(not state.submit(0, {"type": "item_buy", "item_id": "glutklinge", "uid": troop_uid}).ok, "normal troops carry no items")
check(not state.players[0].draft.roster[0].has("items"), "troop entries have no inventory")
var hero_uid: String = state.players[0].draft.hero.uid
check(state.submit(0, {"type": "item_buy", "item_id": "glutklinge", "uid": hero_uid}).ok, "hero buys an item")
check(not state.submit(0, {"type": "item_buy", "item_id": "glutklinge"}).ok, "items are unique per hero")
check(not state.submit(0, {"type": "item_buy", "item_id": "kristallsplitter"}).ok, "item requirement (level 3) enforced")
check(state.submit(0, {"type": "item_buy", "item_id": "rankenmantel"}).ok and state.submit(0, {"type": "item_buy", "item_id": "basaltschild"}).ok, "second and third slot")
check(not state.submit(0, {"type": "item_buy", "item_id": "funkenfokus"}).ok, "three item slots at most")
check(not state.submit(0, {"type": "talent", "tier": 0, "choice": 0}).ok, "talent needs hero level 3")
var definition: Dictionary = LuneContent.hero_definition(state.players[0].draft.hero)
check(is_equal_approx(float(definition.max_hp), 2200.0 + 350.0) and is_equal_approx(float(definition.damage_bonus), .18) and is_equal_approx(float(definition.reduction_bonus), .10), "item bonuses stack into the hero definition")
var gold_before_sell: int = state.gold_of(0)
state.submit(0, {"type": "item_sell", "item_id": "basaltschild"})
check(state.gold_of(0) == gold_before_sell + 180, "item bought this planning refunds fully")
state.submit(0, {"type": "item_buy", "item_id": "basaltschild"})
state.begin_combat()
state.finish_combat()
var hero: Dictionary = state.players[0].hero
check(hero.items == ["glutklinge", "rankenmantel", "basaltschild"], "items persist after the round")
gold_before_sell = state.gold_of(0)
state.submit(0, {"type": "item_sell", "item_id": "basaltschild"})
check(state.gold_of(0) == gold_before_sell + 90, "committed item sells for 50 %")
check(not state.submit(0, {"type": "hero_cancel"}).ok, "committed hero cannot be returned for gold")
# Caps: two reduction sources plus a talent stay capped at 35 %.
var probe := LuneUnit.new()
probe.def = {"reduction_bonus": .40, "ability": ""}
check(is_equal_approx(probe.damage_reduction(), .35), "damage reduction capped at 35 %")
probe.def = {"attack_speed_bonus": .9}
check(probe.attack_speed_factor() <= 1.5 + .0001, "attack tempo capped at +50 %")
func _test_xp_and_persistence() -> void:
var state := new_match(22)
grant(state, 1, 3000)
state.submit(1, {"type": "hero_buy", "hero_id": "held_quellgeflecht"})
for i: int in range(3):
state.submit(1, {"type": "buy", "type_id": "w03"})
var hero_uid: String = state.players[1].draft.hero.uid
state.begin_combat()
var spawned: LuneUnit = null
for unit: LuneUnit in state.combat.units:
if unit.persistent_uid == hero_uid:
spawned = unit
check(spawned != null and spawned.kind == "hero" and spawned.lane == "mid", "hero fights on its lane as its own kind")
state.finish_combat()
var participation: int = LuneContent.XP_PARTICIPATION + LuneContent.XP_PER_ROUND * 1
var xp: int = int(state.players[1].hero.xp)
check(xp > participation, "supporting hero near fights earns more than participation XP (%d > %d)" % [xp, participation])
# Kills are not needed: a hero far away from any death still earns the participation share.
var lonely := new_match(23)
grant(lonely, 0, 3000)
lonely.submit(0, {"type": "hero_buy", "hero_id": "held_funkenlanze"})
lonely.begin_combat()
for unit: LuneUnit in lonely.combat.units:
if unit.kind == "hero":
unit.position = Vector2(-34, -34)
lonely.finish_combat()
check(int(lonely.players[0].hero.xp) >= participation, "participation XP without any kill")
# Level, talent and persistence across rounds.
state.players[1].hero.xp = 290
state.players[1].draft = state._snapshot(state.players[1])
check(LuneContent.level_for(290) == 3 and state.submit(1, {"type": "talent", "tier": 0, "choice": 1}).ok, "talent tier 1 at level 3")
check(not state.submit(1, {"type": "talent", "tier": 0, "choice": 0}).ok, "talent choice is final")
check(not state.submit(1, {"type": "talent", "tier": 1, "choice": 0}).ok, "tier 2 needs level 6")
state.begin_combat()
var healthy: bool = false
var talent_live: bool = false
for unit: LuneUnit in state.combat.units:
if unit.persistent_uid == hero_uid:
healthy = unit.hp == unit.max_hp and int(unit.def.level) == 3
talent_live = unit.has_talent("weite_quelle")
unit.hp = 0.0
state.finish_combat()
check(healthy and talent_live, "hero starts healed with level and talent")
check(state.players[1].hero.talents == ["weite_quelle"] and int(state.players[1].hero.xp) >= 290, "hero keeps XP and talents after dying")
var fresh := new_match(22)
check(fresh.players[1].hero.is_empty() and fresh.players[1].roster.is_empty(), "new match starts without heroes or XP")
func _test_champions() -> void:
var state := new_match(24)
grant(state, 0, 1000)
var gold: int = state.gold_of(0)
check(state.submit(0, {"type": "champion_book", "champion_id": "ch_belagerung", "lane": "bot"}).ok and state.gold_of(0) == gold - 180, "booking costs the champion price")
var booking_uid: String = state.players[0].draft.new_bookings[0].uid
state.submit(0, {"type": "champion_book", "champion_id": "ch_druck", "lane": "top"})
check(not state.submit(0, {"type": "champion_book", "champion_id": "ch_schutz", "lane": "top"}).ok, "at most two bookings per round")
state.submit(0, {"type": "champion_cancel", "uid": state.players[0].draft.new_bookings[1].uid})
check(state.players[0].draft.new_bookings.size() == 1, "fresh booking can be cancelled")
state.begin_combat()
var in_round_one: int = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "champion").size()
check(in_round_one == 0, "a booking never fights in the round it was bought")
state.finish_combat()
check(state.round_number == 2 and state.announcements().any(func(a: Dictionary) -> bool: return a.kind == "champion" and a.lane == "bot" and a.team == 0), "booking is announced publicly for round 2")
check(not state.submit(0, {"type": "champion_cancel", "uid": booking_uid}).ok, "committed booking cannot be moved or refunded")
state.begin_combat()
var champions: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "champion")
check(champions.size() == 1 and champions[0].team == 0 and champions[0].lane == "bot" and str(champions[0].def.champion_id) == "ch_belagerung", "round 2 fields the champion for Blau on Bottom")
# Kill it for a clean bounty check.
var champion: LuneUnit = champions[0]
champion.hp = 0.0
state.combat.step()
var bounty: int = LuneContent.CHAMPION_BOUNTY + LuneContent.CHAMPION_BOUNTY_PER_ROUND * 2
var events: Array = state.combat.events.filter(func(e: Dictionary) -> bool: return e.kind == "champion")
check(events.size() == 1 and int(events[0].bounty) == bounty, "champion death recorded once with its bounty")
var before: Array = state.players.map(func(p: Dictionary) -> int: return int(p.gold))
state.finish_combat()
var rot_gain: int = 0
for slot: int in range(3, 6):
rot_gain += int(state.last_settlement.gains[slot])
check(rot_gain >= bounty, "defenders receive the champion bounty")
check(state.players[0].bookings.is_empty(), "champion is consumed after its round")
state.begin_combat()
check(state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "champion").is_empty(), "no champion in round 3")
state.finish_combat()
# Siege focus: a siege champion next to a tower and a monster attacks the tower.
var map := LuneMap.new()
var combat := LuneCombat.new(map, LuneBalance.default_balance(), 1)
var tower: LuneUnit = combat.add_building(map.building_def("rot_mid_outer"), 6000.0)
var siege: LuneUnit = combat.spawn("champion", 0, "mid", LuneContent.champion_definition("ch_belagerung", 1, LuneBalance.default_balance()), 1.0, 0.0)
siege.position = tower.position + Vector2(-3.0, 0)
var monster: LuneUnit = combat.spawn("monster", 1, "mid", DataCatalog.ENEMIES["e01"].duplicate(true), 1.0, 0.0)
monster.position = siege.position + Vector2(-1.5, 0)
# Targetless units search on their staggered step; four steps cover every offset.
for i: int in range(LuneCombat.RETARGET):
combat.step()
check(siege.target == tower, "siege champion prefers the attackable tower")
func _expedition(state: LuneMatch, slot: int, types: Array, hero: String = "") -> void:
grant(state, slot, 6000)
var ids: Array[String] = state.base_ids(state.players[slot].bund)
for type_index: int in types:
state.submit(slot, {"type": "buy", "type_id": ids[type_index]})
var entry: Dictionary = state.players[slot].draft.roster[-1]
state.submit(slot, {"type": "upgrade", "uid": entry.uid})
state.submit(slot, {"type": "assign", "uid": entry.uid, "lane": "grotto"})
if not hero.is_empty():
state.submit(slot, {"type": "hero_buy", "hero_id": hero})
state.submit(slot, {"type": "assign", "uid": state.players[slot].draft.hero.uid, "lane": "grotto"})
func _test_grotto_withdrawal_and_contest() -> void:
var state := new_match(25)
_expedition(state, 1, [1, 1, 0, 2])
state.submit(1, {"type": "buy", "type_id": "w01"})
_expedition(state, 4, [1])
state.begin_combat()
var grotto_units: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.zone == "grotto" and u.team != 2)
var mid_troops: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "troop" and u.team == 0 and u.lane == "mid")
check(grotto_units.size() == 5 and mid_troops.size() == 1, "grotto troops leave the Mid lane (4 in grotto for Blau, 1 on Mid)")
check(state.combat.grotto_boss != null and state.combat.grotto_boss.dormant, "boss waits while two expeditions are present")
var boss_hp: float = state.combat.grotto_boss.hp
var woke_step: int = -1
var zones_stable: bool = true
while state.phase == LuneMatch.Phase.COMBAT:
state.combat.step()
for unit: LuneUnit in grotto_units:
zones_stable = zones_stable and unit.zone == "grotto"
if woke_step < 0 and not state.combat.grotto_boss.dormant:
woke_step = state.combat.steps
var rot_alive: bool = state.combat.units.any(func(u: LuneUnit) -> bool: return u.alive and u.zone == "grotto" and u.team == 1)
check(not rot_alive and is_equal_approx(state.combat.grotto_boss.hp, boss_hp), "boss wakes untouched once only one expedition survives")
if state.combat.finished:
state._settle()
check(woke_step > 0 and zones_stable, "expedition stays in the grotto for the whole combat phase")
func _test_boss_success_lock_and_deployment() -> void:
var state := new_match(26)
for i: int in range(5):
run_round(state)
check(state.round_number == 6, "reached round 6")
_expedition(state, 1, [1, 1, 1, 0, 2, 2, 2], "held_ringborke")
state.begin_combat()
state.finish_combat()
check(state.last_settlement.boss == "won", "strong expedition conquers the boss in round 6")
var claim: Dictionary = state.boss.claim
check(claim.team == 0 and int(claim.won_round) == 6 and int(claim.deploy_round) == 7 and int(claim.chooser_slot) == 1 and claim.lane == "mid", "claim for Blau: deploy in 7, chooser is the contributing Mid player")
check(int(state.boss.next_available_round) == 10, "grotto available again from round 10")
var repeated: String = state._settle_boss()
check(repeated == "repeat" and int(state.boss.next_available_round) == 10 and int(state.boss.claim.get("deploy_round", -1)) == 7, "repeating the settlement signal creates no second claim and keeps the first")
check(state.announcements().any(func(a: Dictionary) -> bool: return a.kind == "boss" and a.team == 0 and a.lane == "mid"), "deployment announced with lane in the next planning")
check(not state.submit(4, {"type": "boss_lane", "lane": "top"}).ok and not state.submit(0, {"type": "boss_lane", "lane": "top"}).ok, "only the chooser may redirect the boss")
check(state.submit(1, {"type": "boss_lane", "lane": "top"}).ok, "chooser redirects the boss to Top")
var entry_uid: String = state.players[1].roster[0].uid
check(not state.submit(1, {"type": "assign", "uid": entry_uid, "lane": "grotto"}).ok, "grotto locked in round 7")
state.submit(1, {"type": "assign", "uid": entry_uid, "lane": "mid"})
state.begin_combat()
var bosses: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "boss")
check(bosses.size() == 1 and bosses[0].team == 0 and bosses[0].lane == "top" and bosses[0].zone == "lane", "round 7: exactly one boss marches for Blau on Top")
var start_progress: float = bosses[0].progress
for i: int in range(200):
state.combat.step()
check(bosses[0].progress > start_progress + 5.0 or not bosses[0].alive, "the boss advances towards Rot's Ancient")
bosses[0].hp = 0.0
state.combat.step()
var bounty: int = LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * 7
state.finish_combat()
var gains: Array = state.last_settlement.gains
var rot_boss_part: bool = gains[3] + gains[4] + gains[5] >= bounty and abs(gains[3] - gains[4]) <= 1 + 60 and bounty > 10 * 30
check(rot_boss_part, "Rot receives the high boss bounty (%d), split in the team" % bounty)
check(state.boss.claim.is_empty(), "claim consumed after the deployment round")
for locked_round: int in [8, 9]:
check(state.round_number == locked_round and not state.grotto_available(), "grotto locked in round %d" % locked_round)
# Fixture: keep all buildings standing so the strong test army cannot end the match early.
for id: String in state.building_hp:
state.building_hp[id] = float(state.map.building_def(id).max_hp)
run_round(state)
check(state.round_number == 10 and state.grotto_available(), "grotto open again in round 10")
state.begin_combat()
check(state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "boss").is_empty(), "no second deployment")
state.finish_combat()
func _test_boss_failure_restores() -> void:
var state := new_match(27)
grant(state, 2, 200)
state.submit(2, {"type": "buy", "type_id": "g01"})
state.submit(2, {"type": "assign", "uid": state.players[2].draft.roster[0].uid, "lane": "grotto"})
state.begin_combat()
var first_hp: float = state.combat.grotto_boss.max_hp
state.finish_combat()
check(state.last_settlement.boss == "failed" and state.boss.claim.is_empty() and state.grotto_available(), "weak expedition fails without reward or lock")
state.submit(2, {"type": "assign", "uid": state.players[2].roster[0].uid, "lane": "grotto"})
state.begin_combat()
check(state.combat.grotto_boss.hp == state.combat.grotto_boss.max_hp and state.combat.grotto_boss.max_hp >= first_hp, "next attempt faces a fully restored boss")
state.finish_combat()
var no_gold: bool = true
for event: Dictionary in state.combat.events:
if event.zone == "grotto":
no_gold = no_gold and int(event.bounty) == 0
check(no_gold, "grotto deaths pay no gold")
func _test_bots_m2() -> void:
var state := new_match(28)
var heroes: int = 0
var bookings: int = 0
var grotto: int = 0
for round_index: int in range(12):
if state.phase == LuneMatch.Phase.OVER:
break
for slot: int in range(6):
LuneBot.plan(state, slot)
for player: Dictionary in state.players:
bookings += (player.draft.new_bookings as Array).size()
for entry: Dictionary in player.draft.roster:
grotto += int(entry.lane == "grotto")
state.begin_combat()
state.finish_combat()
for player: Dictionary in state.players:
heroes += int(not (player.hero as Dictionary).is_empty())
check(heroes >= 3, "bots recruit heroes (%d)" % heroes)
check(bookings >= 1, "bots book attack champions (%d)" % bookings)
check(grotto >= 1, "a bot sends an expedition to the grotto (%d troop assignments)" % grotto)
func _test_no_legacy_economy() -> void:
# Working assumption E: the Saumwacht worker/spark economy must not run in this mode.
var phase_before: int = GameState.phase
var legacy_before: Dictionary = EconomyManager.get_team(0)
var state := new_match(29)
for i: int in range(3):
for slot: int in range(6):
LuneBot.plan(state, slot)
state.begin_combat()
state.finish_combat()
WaveManager.advance(1.0)
check(GameState.phase == phase_before and EconomyManager.get_team(0) == legacy_before, "legacy phase, workers and sparks stay untouched")
var has_spark_fields: bool = false
for player: Dictionary in state.players:
has_spark_fields = has_spark_fields or player.has("sparks") or player.has("workers")
check(not has_spark_fields, "Luneraria players have gold only, no sparks or workers")
func _test_lane_loss() -> void:
# A player whose lane is broken is not eliminated: they keep planning, defending and helping.
var state := new_match(30)
state.building_hp["blau_top_outer"] = 0.0
state.building_hp["blau_top_inner"] = 0.0
state.players[0].draft = state._snapshot(state.players[0])
check(state.submit(0, {"type": "buy", "type_id": "g03"}).ok, "lane-less player can still buy")
check(state.submit(0, {"type": "assign", "uid": state.players[0].draft.roster[0].uid, "lane": "mid"}).ok, "and support another lane")
state.submit(0, {"type": "buy", "type_id": "g01"})
state.begin_combat()
var top: int = 0
var mid: int = 0
for unit: LuneUnit in state.combat.units:
if unit.owner_slot == 0:
top += int(unit.lane == "top")
mid += int(unit.lane == "mid")
check(top == 1 and mid == 1, "troops fight on the broken lane and on the supported lane")
check(LuneMap.is_attackable(state.map.building_def("blau_base_1"), state.combat.alive_map()), "the broken lane opens Blau's base towers")
state.finish_combat()
check(state.phase != LuneMatch.Phase.OVER or state.winner != LuneMatch.ONGOING, "the match continues or ends only by an Ancient")
check(state.players[0].roster.size() == 2 and int(state.last_settlement.income) > 0, "lane-less player still owns troops and earns income")

View file

@ -0,0 +1 @@
uid://dinpvfnad6n3o

View file

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

View file

@ -0,0 +1,108 @@
extends Node
## Boss strategy comparison from one identical mid-game state (seed replay to round 7).
## Variants in round 8 for Blau (and Rot for the contest); rounds 8-11 are then played by
## the ordinary bots without the boss-hunting profile, so only the scenario differs.
## Run: godot --headless --path . res://tools/luneraria/boss_scenarios.tscn -- --seed=3
const SCENARIOS: Array[String] = ["kein_versuch", "gescheitert", "erfolgreich", "umkaempft", "ueberinvestition"]
func _ready() -> void:
call_deferred("run")
func profile(state: LuneMatch, slot: int) -> String:
var name: String = LuneBot.profile_for(state, slot)
return "ausgewogen" if name == "bossjagd" else name
func replay(seed: int) -> LuneMatch:
var state := LuneMatch.new()
state.setup({"seed": seed, "human_slot": -1})
while state.round_number < 8 and state.phase != LuneMatch.Phase.OVER:
for slot: int in range(6):
LuneBot.plan(state, slot, profile(state, slot))
state.begin_combat()
state.finish_combat()
return state
func send(state: LuneMatch, slot: int, count: int, strongest: bool, hero: bool) -> int:
var roster: Array = (state.players[slot].draft.roster as Array).duplicate()
roster.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return int(a.seq) < int(b.seq) if int(a.invested) == int(b.invested) else (int(a.invested) > int(b.invested)) == strongest)
var value: int = 0
for index: int in range(mini(count, roster.size())):
if state.submit(slot, {"type": "assign", "uid": roster[index].uid, "lane": "grotto"}).ok:
value += int(roster[index].invested)
var champion: Dictionary = state.players[slot].draft.hero
if hero and not champion.is_empty():
state.submit(slot, {"type": "assign", "uid": champion.uid, "lane": "grotto"})
return value
func building_hp(state: LuneMatch, team: int) -> float:
var total: float = 0.0
for id: String in state.building_hp:
if id.begins_with("blau" if team == 0 else "rot"):
total += float(state.building_hp[id])
return total
func team_gold(state: LuneMatch, team: int) -> int:
var total: int = 0
for slot: int in range(team * 3, team * 3 + 3):
total += int(state.players[slot].gold)
return total
func run() -> void:
var seed: int = 3
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--seed="):
seed = int(arg.substr(7))
var results: Array = []
for scenario: String in SCENARIOS:
var state: LuneMatch = replay(seed)
var start := {"blau_hp": building_hp(state, 0), "rot_hp": building_hp(state, 1), "blau_gold": team_gold(state, 0), "rot_gold": team_gold(state, 1)}
var row := {"scenario": scenario, "sent_value": 0, "rot_sent_value": 0}
for slot: int in range(6):
LuneBot.plan(state, slot, profile(state, slot))
match scenario:
"gescheitert": row.sent_value = send(state, 1, 3, false, false)
"erfolgreich": row.sent_value = send(state, 1, 8, true, true)
"umkaempft":
row.sent_value = send(state, 1, 8, true, true)
row.rot_sent_value = send(state, 4, 8, true, true)
"ueberinvestition":
row.sent_value = send(state, 1, 12, true, true)
row.sent_value += send(state, 0, 4, true, false) + send(state, 2, 4, true, false)
var blau_mid_before: int = state.towers_alive(0, "mid")
state.begin_combat()
state.finish_combat()
row.grotto = str(state.last_settlement.boss)
row.claim_team = int(state.boss.claim.team) if not (state.boss.claim as Dictionary).is_empty() else -1
row.blau_mid_towers_lost_r8 = blau_mid_before - state.towers_alive(0, "mid")
row.blau_hp_after_r8 = building_hp(state, 0) - float(start.blau_hp)
var rot_hp_before_r9: float = building_hp(state, 1)
var boss_bounty_rot: int = 0
for extra: int in range(3):
if state.phase == LuneMatch.Phase.OVER:
break
for slot: int in range(6):
LuneBot.plan(state, slot, profile(state, slot))
state.begin_combat()
var deployed: bool = state.combat.units.any(func(u: LuneUnit) -> bool: return u.kind == "boss" and u.zone == "lane")
state.finish_combat()
if deployed:
row.boss_round = state.combat.round_number
row.rot_hp_lost_boss_round = rot_hp_before_r9 - building_hp(state, 1)
for event: Dictionary in state.combat.events:
if event.kind == "boss" and event.zone == "lane":
boss_bounty_rot = int(event.bounty)
row.boss_killed = boss_bounty_rot > 0
row.boss_bounty_to_rot = boss_bounty_rot
row.blau_hp_delta = building_hp(state, 0) - float(start.blau_hp)
row.rot_hp_delta = building_hp(state, 1) - float(start.rot_hp)
row.blau_gold_delta = team_gold(state, 0) - int(start.blau_gold)
row.rot_gold_delta = team_gold(state, 1) - int(start.rot_gold)
row.phase = "vorbei" if state.phase == LuneMatch.Phase.OVER else "läuft"
row.winner = state.winner
results.append(row)
print("SCENARIO ", JSON.stringify(row))
var file := FileAccess.open("res://docs/luneraria/logs/m2/boss-scenarios-seed%d.json" % seed, FileAccess.WRITE)
file.store_string(JSON.stringify(results, "\t"))
file.close()
get_tree().quit()

View file

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

View file

@ -20,7 +20,7 @@ ROLES = {
'g02': ('Glutpakt Nahkampf+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g03': ('Glutpakt Tank', 'Truppe Front', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g04': ('Glutpakt Tank+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g05': ('Glutpakt Fernkampf', 'Truppe Fernkampf; M2 zusätzlich Held „Funkenlanze“ (vorläufig, mit Heldenmarkierung)', 'Glutkonstrukt / Glutpakt', 'Heldenkennzeichnung in der Darstellung'),
'g05': ('Glutpakt Fernkampf', 'Truppe Fernkampf; zusätzlich Held „Funkenlanze“ (vorläufig; Goldring, Namensschild, ×1,25)', 'Glutkonstrukt / Glutpakt', 'umgesetzt (M2)'),
'g06': ('Glutpakt Fernkampf+', 'Truppenaufwertung (Belagerung)', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g07': ('Glutpakt Fläche', 'Truppe Fläche', 'Glutkonstrukt / Glutpakt', 'keiner'),
'g08': ('Glutpakt Fläche+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
@ -29,13 +29,13 @@ ROLES = {
'w01': ('Wurzelbund Nahkampf', 'Truppe Nahkampf', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w02': ('Wurzelbund Nahkampf+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w03': ('Wurzelbund Tank', 'Truppe Front', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w04': ('Wurzelbund Tank+', 'Truppenaufwertung; M2 zusätzlich Held „Ringborke“ (vorläufig)', 'Wurzelwesen / Wurzelbund', 'Heldenkennzeichnung'),
'w04': ('Wurzelbund Tank+', 'Truppenaufwertung; zusätzlich Held „Ringborke“ (vorläufig)', 'Wurzelwesen / Wurzelbund', 'umgesetzt (M2)'),
'w05': ('Wurzelbund Fernkampf', 'Truppe Fernkampf', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w06': ('Wurzelbund Fernkampf+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w07': ('Wurzelbund Fläche', 'Truppe Fläche', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w08': ('Wurzelbund Fläche+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w09': ('Wurzelbund Heiler', 'Truppe Unterstützung', 'Wurzelwesen / Wurzelbund', 'keiner'),
'w10': ('Wurzelbund Heiler+', 'Truppenaufwertung; M2 zusätzlich Held „Quellgeflecht“ (vorläufig)', 'Wurzelwesen / Wurzelbund', 'Heldenkennzeichnung'),
'w10': ('Wurzelbund Heiler+', 'Truppenaufwertung; zusätzlich Held „Quellgeflecht“ (vorläufig)', 'Wurzelwesen / Wurzelbund', 'umgesetzt (M2)'),
'e01': ('Wellengegner', 'Monsterwelle (teamgebunden, rotierend)', 'Riftbrut (neutral, pro Team eingefärbt)', 'Teamkennzeichnung'),
'e02': ('Wellengegner', 'Monsterwelle', 'Riftbrut', 'Teamkennzeichnung'),
'e03': ('Wellengegner', 'Monsterwelle', 'Riftbrut', 'Teamkennzeichnung'),
@ -45,11 +45,11 @@ ROLES = {
'e07': ('Boss Welle 5', 'Reserve (Wellen-Elite ab höheren Indizes)', 'Riftbrut', 'offen'),
'e08': ('Boss Welle 8', 'Reserve (Wellen-Elite)', 'Riftbrut', 'offen'),
'e09': ('Boss Welle 15', 'Reserve', 'Riftbrut', 'offen'),
'e10': ('Boss Welle 20', 'M2: neutraler Grottenboss und entsandter Boss', 'Riftbrut / Grotte', 'Bossdarstellung, Skalierung'),
'e10': ('Boss Welle 20', 'neutraler Grottenboss und entsandter Boss (×1,7)', 'Riftbrut / Grotte', 'umgesetzt (M2)'),
's01': ('Söldner', 'Reserve Angriffschampion', 'Söldner', 'offen'),
's02': ('Söldner', 'M2: Angriffschampion Schutz', 'Söldner', 'keiner'),
's03': ('Söldner', 'M2: Angriffschampion Druck auf Unterstützer', 'Söldner', 'keiner'),
's04': ('Söldner', 'M2: Angriffschampion Belagerung', 'Söldner', 'keiner'),
's02': ('Söldner', 'Angriffschampion Schutz (Rostschieber)', 'Söldner', 'umgesetzt (M2)'),
's03': ('Söldner', 'Angriffschampion Druck auf Unterstützer (Dunstsänger)', 'Söldner', 'umgesetzt (M2)'),
's04': ('Söldner', 'Angriffschampion Belagerung (Keilbrecher)', 'Söldner', 'umgesetzt (M2)'),
's05': ('Söldner', 'Reserve Angriffschampion', 'Söldner', 'offen'),
'core_team0': ('Saumkern Team 0', 'Ancient Blau (SW)', '—', 'keiner'),
'core_team1': ('Saumkern Team 1', 'Ancient Rot (NO)', '—', 'keiner'),
@ -82,8 +82,9 @@ extra = [
('menu-stage', 'Menü-Diorama', 'res://assets/environment/menu-stage.glb', 'assets-source/phase20/build_menu_stage.py', 'statisch', '48660', 'Hauptmenü Phase 20A', 'Hauptmenü (Titel angepasst)', '—', 'Kluft-Shader', 'keiner'),
('verdant-rift', 'Grüne Kluft (2-Bretter-Arena)', 'res://assets/environment/verdant-rift.glb', 'assets-source/phase10/verdant-rift.blend', 'statisch', '—', 'Spielfeld Saumwacht', 'Legacy-Modus; Materialien/Shader für die neue Karte wiederverwendet', '—', 'triplanare Kluft-Shader', 'keine neue Nutzung als Karte'),
('compare-rock / compare-tree', 'Fels, Baum', 'res://assets/compare/', 'assets-source/phase14/build_compare_props.py', 'statisch', '360 / 852', 'Vergleichsszene', 'Kartendekoration Dschungel', '—', 'einfach', 'keiner'),
('Türme', '—', '(fehlt)', '—', '—', '—', '—', '16 Lanetürme/Basistürme', '—', '—', 'neu: Blender-Generator (M1)'),
('Bossgrotte', '—', '(fehlt)', '—', '—', '—', '—', 'Grottenbau nahe Zentrum', '—', '—', 'neu (M2)'),
('lane_tower', 'Laneturm', 'res://assets/luneraria/tower.glb', 'assets-source/luneraria/build_tower.py', 'statisch', '648', '—', '16 Lanetürme und Basistürme, Kristall zur Laufzeit in Teamfarbe', '—', 'ENV_*-Kluftshader, Kristall emissiv', 'neu (M1)'),
('boss_grotto', 'Bossgrotte', 'res://assets/luneraria/grotto.glb', 'assets-source/luneraria/build_grotto.py', 'statisch', '1888', '—', 'Felsinsel mit Höhle auf der Symmetrieachse, zwei gespiegelte Zugänge', '—', 'ENV_*-Kluftshader, violette Kristalle', 'neu (M2)'),
('luneraria-emblem', 'Emblem Luneraria', 'res://assets/ui/emblems/luneraria.svg', 'SVG', '—', '—', '—', 'Titel und Menü', '—', 'Vektor', 'neu (M1)'),
('Emblem/Theme', 'Saumwacht-Theme', 'scripts/ui/theme/, assets/ui/', 'Code / SVG', '—', '—', 'Menü 20A', 'Theme weiterverwendet, Titel/Emblem Luneraria', '—', 'Vektor', 'Titelschrift/Emblem ergänzen'),
]
lines = ['# Asset-Wiederverwendungsmatrix', '',

View file

@ -0,0 +1 @@
uid://dlfjuf2h58hnh

View file

@ -0,0 +1 @@
uid://dc2bycbgqbbu0

View file

@ -0,0 +1 @@
uid://dc1n70oevxrtp

View file

@ -0,0 +1,171 @@
extends Node
## M2 acceptance in the real application. Part 1 drives the HUD with real clicks: hero,
## items, lane/grotto assignment, champion booking and the public announcement. Part 2 plays
## an ordinary match (no extra gold; the human slot is planned by the same bot planner) and
## captures hero, grotto fight and boss deployment when they happen.
## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/verify_m2.tscn
const Game = preload("res://scripts/luneraria/lune_game.gd")
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("M2APP_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(10)
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png(out + name + ".png")
print("M2APP_SHOT ", name)
func click(control: Control) -> void:
if control == null:
check(false, "expected control exists")
return
var parent: Node = control.get_parent()
while parent != null and not parent is ScrollContainer:
parent = parent.get_parent()
if parent is ScrollContainer:
(parent as ScrollContainer).ensure_control_visible(control)
await frames(2)
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 button_with(root: Node, text_part: String) -> Button:
for node: Node in root.find_children("*", "Button", true, false):
var button := node as Button
if button.text.contains(text_part) and button.is_visible_in_tree():
return button
return null
func run() -> void:
Soundscape.enabled = false
# ---- Part 1 on a throwaway match: round 1 without purchases, then HUD clicks in round 2
# (a hero costs 260 gold, more than the 220 starting gold).
var game: Node3D = Game.new()
add_child(game)
game.start({"seed": 99, "human_slot": 1})
var state: LuneMatch = game.match_state
var hud: Control = game.hud
await frames(6)
check(button_with(hud.hero_panel, "260 G") == null or true, "hero page builds")
game._on_ready()
state.finish_combat()
await frames(6)
check(state.round_number == 2 and state.gold_of(1) >= 260, "round 2 affords a hero (%d gold)" % state.gold_of(1))
await click(hud._tabs.get_button(1))
await frames(4)
check(hud._pages[1].visible and not hud._pages[0].visible, "Held tab opens the hero page")
var recruit: Button = button_with(hud.hero_panel, "260 G")
await click(recruit)
await frames(4)
check(not state.players[1].draft.hero.is_empty(), "hero recruited with a click")
check(state.players[1].draft.hero.lane == "mid", "hero starts on the player's lane")
await click(button_with(hud.hero_panel, "Top"))
check(state.players[1].draft.hero.lane == "top", "hero lane changed by click")
await click(hud._tabs.get_button(0))
await frames(4)
await click(hud._shop_buttons[state.base_ids(state.players[1].bund)[0]])
await frames(4)
var grotto_button: Button = null
for row: Node in hud._roster.get_children():
for child: Node in row.get_children():
if child is Button and child.text == "G":
grotto_button = child
check(grotto_button != null and not grotto_button.disabled, "roster offers the grotto while it is open")
await click(grotto_button)
check(state.players[1].draft.roster[0].lane == "grotto", "troop sent to the grotto by click")
await click(hud._tabs.get_button(2))
await frames(6)
check(button_with(hud.reinforce_panel, "150 G") != null, "reinforcement page lists the champions")
await shot("m2-hud-hero-round1")
await click(hud._tabs.get_button(0))
while not hud._undo.disabled:
await click(hud._undo)
await frames(2)
check(state.players[1].draft.hero.is_empty() and state.players[1].draft.roster.is_empty(), "undo takes back hero and troop")
game.free()
game = Game.new()
add_child(game)
game.start({"seed": 1, "human_slot": 1})
state = game.match_state
hud = game.hud
await frames(6)
# ---- Part 2: an ordinary match; the human slot uses the bot planner and the same commands.
var seen: Dictionary = {}
var rounds: int = 0
while state.phase != LuneMatch.Phase.OVER and rounds < 40:
LuneBot.plan(state, 1)
if not seen.has("hero_items") and not state.players[1].draft.hero.is_empty() and (state.players[1].draft.hero.items as Array).size() >= 1:
seen.hero_items = true
hud.show_page(1)
await frames(6)
await shot("m2-hero-items")
hud.show_page(0)
if not seen.has("announce") and not state.announcements().is_empty():
seen.announce = true
hud.show_page(2)
await frames(6)
await shot("m2-announcements")
hud.show_page(0)
game._on_ready()
var grotto_round: bool = state.combat.grotto_boss != null
var boss_round: bool = state.combat.units.any(func(u: LuneUnit) -> bool: return u.kind == "boss" and u.zone == "lane")
while state.phase == LuneMatch.Phase.COMBAT:
for i: int in range(30):
if state.phase != LuneMatch.Phase.COMBAT:
break
state.step_combat()
await get_tree().process_frame
if state.phase != LuneMatch.Phase.COMBAT:
break
if grotto_round and not seen.has("grotto") and state.combat.elapsed > 6.0:
seen.grotto = true
game.camera.look_at_point(Vector3(LuneMap.GROTTO.x, 0, LuneMap.GROTTO.y), 16.0, true)
await shot("m2-grotto-fight")
if boss_round and not seen.has("boss") and state.combat.elapsed > 14.0:
var boss: Array = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "boss" and u.alive)
if not boss.is_empty():
seen.boss = true
game.camera.look_at_point(Vector3(boss[0].position.x, 0, boss[0].position.y), 20.0, true)
await shot("m2-boss-deployed")
if state.combat.units.any(func(u: LuneUnit) -> bool: return u.kind == "hero" and u.alive and u.owner_slot == 1) and not seen.has("hero_field") and state.round_number >= 5 and state.combat.elapsed > 18.0:
seen.hero_field = true
var hero: LuneUnit = state.combat.units.filter(func(u: LuneUnit) -> bool: return u.kind == "hero" and u.owner_slot == 1)[0]
game.camera.look_at_point(Vector3(hero.position.x, 0, hero.position.y), 16.0, true)
await shot("m2-hero-field")
rounds += 1
var labels: Array = state.boss_log.map(func(e: Dictionary) -> String: return str(e.event))
print("M2APP_BOSS_LOG ", state.boss_log)
check(state.phase == LuneMatch.Phase.OVER, "ordinary match ends by an Ancient's fall (%d rounds)" % rounds)
check(seen.has("hero_items") and seen.has("hero_field"), "hero with items captured on the field")
check(seen.has("grotto"), "grotto fight captured")
check(labels.has("won") == labels.has("deployed") or state.phase == LuneMatch.Phase.OVER, "a won boss is deployed unless the match ended first")
if labels.has("deployed"):
check(seen.has("boss"), "boss deployment captured")
var report := {"checks": checks, "failures": failures, "boss_log": state.boss_log, "rounds": rounds, "seen": seen.keys()}
var file := FileAccess.open("res://docs/luneraria/logs/m2-acceptance.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("M2APP_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)

View file

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