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>
BIN
assets-source/luneraria/grotto.blend
Normal file
BIN
assets/luneraria/grotto.glb
Normal file
45
assets/luneraria/grotto.glb.import
Normal 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
|
||||||
18
assets/luneraria/grotto_manifest.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 | M2: Angriffschampion Druck auf Unterstützer | 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 | 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 | M2: Angriffschampion Belagerung | 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 | 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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) |
|
||||||
| Bossgrotte | — | (fehlt) | — | — | — | — | Grottenbau nahe Zentrum | — | — | neu (M2) |
|
| 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 |
|
| Emblem/Theme | Saumwacht-Theme | scripts/ui/theme/, assets/ui/ | Code / SVG | — | — | Menü 20A | Theme weiterverwendet, Titel/Emblem Luneraria | — | Vektor | Titelschrift/Emblem ergänzen |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- **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).
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
"checks": 38,
|
"checks": 38,
|
||||||
"failures": [],
|
"failures": [],
|
||||||
"object_counts": [
|
"object_counts": [
|
||||||
3505.0,
|
3850.0,
|
||||||
3505.0,
|
3850.0,
|
||||||
3505.0,
|
3850.0,
|
||||||
3505.0
|
3850.0
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
44
docs/luneraria/logs/m2-acceptance.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
4
docs/luneraria/logs/m2-tests.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"checks": 73,
|
||||||
|
"failures": []
|
||||||
|
}
|
||||||
85
docs/luneraria/logs/m2/boss-scenarios-seed3.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
85
docs/luneraria/logs/m2/boss-scenarios-seed7.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
9
docs/luneraria/logs/m2/lune-core.log
Normal 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
|
||||||
8
docs/luneraria/logs/m2/lune-m2.log
Normal 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
|
||||||
3327
docs/luneraria/logs/m2/sim-12-seeds.json
Normal file
15
docs/luneraria/logs/m2/verify-m1.log
Normal 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
|
||||||
16
docs/luneraria/logs/m2/verify-m2.log
Normal 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
|
||||||
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
40
docs/luneraria/screenshots/combat-mid-live.png.import
Normal 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
|
||||||
40
docs/luneraria/screenshots/combat-mid.png.import
Normal 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
|
||||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.5 MiB |
40
docs/luneraria/screenshots/combat-top.png.import
Normal 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
|
||||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
40
docs/luneraria/screenshots/core-siege.png.import
Normal 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
|
||||||
BIN
docs/luneraria/screenshots/m2-announcements.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
docs/luneraria/screenshots/m2-boss-deployed.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/luneraria/screenshots/m2-grotto-fight.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/luneraria/screenshots/m2-hero-field.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/luneraria/screenshots/m2-hero-items.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/luneraria/screenshots/m2-hud-hero-round1.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
40
docs/luneraria/screenshots/menu-setup.png.import
Normal 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
|
||||||
40
docs/luneraria/screenshots/menu-title.png.import
Normal 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
|
||||||
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
40
docs/luneraria/screenshots/overview-combat.png.import
Normal 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
|
||||||
40
docs/luneraria/screenshots/overview-planning.png.import
Normal 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
|
||||||
|
Before Width: | Height: | Size: 649 KiB After Width: | Height: | Size: 650 KiB |
40
docs/luneraria/screenshots/planning-1280x720.png.import
Normal 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
|
||||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
40
docs/luneraria/screenshots/planning-1920x1080.png.import
Normal 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
|
||||||
40
docs/luneraria/screenshots/planning.png.import
Normal 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
|
||||||
|
Before Width: | Height: | Size: 592 KiB After Width: | Height: | Size: 595 KiB |
40
docs/luneraria/screenshots/result.png.import
Normal 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
|
||||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
40
docs/luneraria/screenshots/settlement.png.import
Normal 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
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
class_name LuneBot
|
class_name LuneBot
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
## Plans through LuneMatch.submit exactly like the human: same costs, same caps, same phase.
|
## 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 = {
|
const PROFILES: Dictionary = {
|
||||||
"ausgewogen": {"front": .30, "melee": .25, "ranged": .25, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0},
|
"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},
|
"hero": "held_ringborke", "hero_round": 3, "items": ["basaltschild", "glutklinge", "rankenmantel"], "talents": [0, 1], "champion": ""},
|
||||||
"druck": {"front": .20, "melee": .35, "ranged": .25, "splash": .15, "support": .05, "upgrade_from": 8, "support_lane": 0.0},
|
"verteidigung": {"front": .40, "melee": .15, "ranged": .20, "splash": .10, "support": .15, "upgrade_from": 5, "support_lane": 0.0,
|
||||||
"fernkampf": {"front": .25, "melee": .10, "ranged": .40, "splash": .15, "support": .10, "upgrade_from": 6, "support_lane": 0.0},
|
"hero": "held_ringborke", "hero_round": 4, "items": ["rankenmantel", "basaltschild", "quellamulett"], "talents": [0, 0], "champion": ""},
|
||||||
"teamhilfe": {"front": .30, "melee": .20, "ranged": .25, "splash": .10, "support": .15, "upgrade_from": 6, "support_lane": .35},
|
"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:
|
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 = {}
|
var roles: Dictionary = {}
|
||||||
for id: String in ids:
|
for id: String in ids:
|
||||||
roles[match_state.role_of(id)] = id
|
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):
|
for guard: int in range(40):
|
||||||
var draft: Dictionary = player.draft
|
var draft: Dictionary = player.draft
|
||||||
var roster: Array = draft.roster
|
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)
|
acted = _try_upgrade(match_state, slot, roster)
|
||||||
if not acted:
|
if not acted:
|
||||||
break
|
break
|
||||||
|
_plan_items(match_state, slot, profile)
|
||||||
|
_plan_boss(match_state, slot, profile)
|
||||||
match_state.set_ready(slot)
|
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:
|
static func _needed_role(match_state: LuneMatch, roster: Array, profile: Dictionary) -> String:
|
||||||
var counts: Dictionary = {}
|
var counts: Dictionary = {}
|
||||||
for entry: Dictionary in roster:
|
for entry: Dictionary in roster:
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,15 @@ var finished: bool = false
|
||||||
var end_reason: String = ""
|
var end_reason: String = ""
|
||||||
var destroyed_cores: Array[int] = []
|
var destroyed_cores: Array[int] = []
|
||||||
var events: Array[Dictionary] = []
|
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 _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.
|
## Units without a target look for one every RETARGET steps, staggered by team_index.
|
||||||
const RETARGET: int = 4
|
const RETARGET: int = 4
|
||||||
var _event_serial: int = 0
|
var _event_serial: int = 0
|
||||||
|
|
@ -94,6 +101,20 @@ func spawn(kind: String, team: int, lane: String, definition: Dictionary, progre
|
||||||
units.append(unit)
|
units.append(unit)
|
||||||
return 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:
|
func alive_map() -> Dictionary:
|
||||||
var result: Dictionary = {}
|
var result: Dictionary = {}
|
||||||
for id: String in buildings:
|
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:
|
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)
|
limit = minf(limit, path.length - float(map.building_def(id).progress) - 1.5)
|
||||||
_limits[team * 3 + LuneMap.LANES.find(lane)] = limit
|
_limits[team * 3 + LuneMap.LANES.find(lane)] = limit
|
||||||
_grid = {0: {}, 1: {}}
|
_grid = {0: {}, 1: {}, 2: {}}
|
||||||
for unit: LuneUnit in living:
|
for unit: LuneUnit in living:
|
||||||
if unit.is_mobile():
|
if unit.is_mobile():
|
||||||
var cells: Dictionary = _grid[unit.team]
|
var cells: Dictionary = _grid[unit.team]
|
||||||
|
|
@ -130,13 +151,16 @@ func _prepare_caches(living: Array[LuneUnit], gates: Dictionary) -> void:
|
||||||
cells[key] = []
|
cells[key] = []
|
||||||
(cells[key] as Array).append(unit)
|
(cells[key] as Array).append(unit)
|
||||||
|
|
||||||
## Mobile units of one team (or both with team = -1) near a point; the caller checks distance.
|
static func hostile_teams(team: int) -> Array:
|
||||||
func _near_mobile(center: Vector2, radius: float, team: int = -1) -> 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 result: Array = []
|
||||||
var reach: int = ceili(radius / CELL)
|
var reach: int = ceili(radius / CELL)
|
||||||
var cx: int = floori(center.x / CELL)
|
var cx: int = floori(center.x / CELL)
|
||||||
var cy: int = floori(center.y / 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]
|
var cells: Dictionary = _grid[side]
|
||||||
if cells.is_empty():
|
if cells.is_empty():
|
||||||
continue
|
continue
|
||||||
|
|
@ -220,17 +244,37 @@ func step() -> void:
|
||||||
unit.death_time = elapsed
|
unit.death_time = elapsed
|
||||||
unit.target = null
|
unit.target = null
|
||||||
_record_death(unit)
|
_record_death(unit)
|
||||||
|
_update_grotto()
|
||||||
_lap("resolve")
|
_lap("resolve")
|
||||||
_check_end()
|
_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:
|
func _check_end() -> void:
|
||||||
if not destroyed_cores.is_empty():
|
if not destroyed_cores.is_empty():
|
||||||
finished = true
|
finished = true
|
||||||
end_reason = "core"
|
end_reason = "core"
|
||||||
return
|
return
|
||||||
var mobile_alive: bool = false
|
var mobile_alive: bool = not grotto_resolved
|
||||||
for unit: LuneUnit in units:
|
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
|
mobile_alive = true
|
||||||
break
|
break
|
||||||
if not mobile_alive:
|
if not mobile_alive:
|
||||||
|
|
@ -252,11 +296,26 @@ func _record_death(unit: LuneUnit) -> void:
|
||||||
event.building_id = unit.building_id
|
event.building_id = unit.building_id
|
||||||
if unit.kind == "core" and not destroyed_cores.has(unit.team):
|
if unit.kind == "core" and not destroyed_cores.has(unit.team):
|
||||||
destroyed_cores.append(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)
|
events.append(event)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- targeting and movement
|
# ---------------------------------------------------------------- targeting and movement
|
||||||
func _hostile(unit: LuneUnit, other: LuneUnit) -> bool:
|
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:
|
func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> LuneUnit:
|
||||||
var current: LuneUnit = unit.target
|
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 radius: float = unit.attack_range() if unit.kind == "tower" else balance.aggro_radius
|
||||||
var best: LuneUnit = null
|
var best: LuneUnit = null
|
||||||
var best_distance: float = INF
|
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):
|
if not _hostile(unit, other):
|
||||||
continue
|
continue
|
||||||
var d: float = unit.position.distance_to(other.position)
|
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)):
|
if d <= radius and (d < best_distance - 0.0001 or (absf(d - best_distance) <= 0.0001 and other.id < best.id)):
|
||||||
best = other
|
best = other
|
||||||
best_distance = d
|
best_distance = d
|
||||||
|
|
@ -312,6 +386,12 @@ func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array:
|
||||||
if gap > 0.0:
|
if gap > 0.0:
|
||||||
goal = unit.target.position
|
goal = unit.target.position
|
||||||
travel = minf(step_length, gap + 0.02)
|
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":
|
elif unit.zone == "lane":
|
||||||
var limit: float = _march_limit(unit)
|
var limit: float = _march_limit(unit)
|
||||||
var anchor: Vector2 = map.lane_point(unit.team, unit.lane, unit.progress, unit.lateral)
|
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))
|
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.
|
# Separation from allies so bodies do not stack; opponents may still close to melee range.
|
||||||
var push := Vector2.ZERO
|
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:
|
if other == unit or other.zone != unit.zone:
|
||||||
continue
|
continue
|
||||||
var offset: Vector2 = unit.position - other.position
|
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())
|
var damage: float = float(unit.def.get("damage", 0.0)) * (1.0 + unit.damage_bonus())
|
||||||
if unit.hit_count % 4 == 0:
|
if unit.hit_count % 4 == 0:
|
||||||
if unit.ability() == "g05":
|
if unit.ability() == "g05":
|
||||||
damage *= 1.5
|
damage *= 2.1 if unit.has_talent("durchschlag") else 1.5
|
||||||
elif unit.ability() == "g06":
|
elif unit.ability() == "g06":
|
||||||
damage *= 1.75
|
damage *= 1.75
|
||||||
|
if unit.hit_count % 3 == 0 and unit.has_talent("stammschlag"):
|
||||||
|
damage *= 1.6
|
||||||
if target.is_building():
|
if target.is_building():
|
||||||
damage *= float(unit.def.get("siege_factor", 1.0))
|
damage *= float(unit.def.get("siege_factor", 1.0))
|
||||||
_queue_damage(target, target.mitigated(damage, int(unit.def.get("attack_type", 0))), gates)
|
_queue_damage(target, target.mitigated(damage, int(unit.def.get("attack_type", 0))), gates)
|
||||||
_on_hit(unit, target, living, 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:
|
func _queue_damage(target: LuneUnit, amount: float, gates: Dictionary) -> void:
|
||||||
if amount <= 0.0 or not target.alive:
|
if amount <= 0.0 or not target.alive:
|
||||||
|
|
@ -410,20 +496,20 @@ func _start_ability(unit: LuneUnit) -> void:
|
||||||
match unit.ability():
|
match unit.ability():
|
||||||
"w03", "w04": unit.ability_ready = 5.0
|
"w03", "w04": unit.ability_ready = 5.0
|
||||||
"w09", "w10": unit.ability_ready = 4.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)
|
"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]:
|
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] = []
|
var result: Array[LuneUnit] = []
|
||||||
# Buildings can be splash victims; they are few, so check them directly.
|
# 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:
|
if not allied:
|
||||||
for id: String in buildings:
|
for id: String in buildings:
|
||||||
pool.append(buildings[id])
|
pool.append(buildings[id])
|
||||||
for other: LuneUnit in pool:
|
for other: LuneUnit in pool:
|
||||||
if not other.alive or other == excluded or other.zone != unit.zone:
|
if not other.alive or other == excluded or other.zone != unit.zone:
|
||||||
continue
|
continue
|
||||||
if (other.team == unit.team) != allied:
|
if (other.team == unit.team) != allied or other.dormant:
|
||||||
continue
|
continue
|
||||||
if allied and other.is_building():
|
if allied and other.is_building():
|
||||||
continue
|
continue
|
||||||
|
|
@ -440,6 +526,13 @@ func _nearby(unit: LuneUnit, living: Array[LuneUnit], allied: bool, center: Vect
|
||||||
return result
|
return result
|
||||||
|
|
||||||
func _ability_step(unit: LuneUnit, living: Array[LuneUnit]) -> void:
|
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:
|
if unit.combat_time + 0.000001 < unit.ability_ready:
|
||||||
return
|
return
|
||||||
var ability: String = unit.ability()
|
var ability: String = unit.ability()
|
||||||
|
|
@ -451,10 +544,27 @@ func _ability_step(unit: LuneUnit, living: Array[LuneUnit]) -> void:
|
||||||
success = true
|
success = true
|
||||||
"w09", "w10":
|
"w09", "w10":
|
||||||
var upgraded: bool = ability == "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:
|
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
|
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":
|
"g09", "g10":
|
||||||
var upgraded: bool = ability == "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)
|
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:
|
func _on_hit(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> void:
|
||||||
var ability: String = unit.ability()
|
var ability: String = unit.ability()
|
||||||
var hits: int = unit.hit_count
|
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:
|
match ability:
|
||||||
"w01", "w02":
|
"w01", "w02":
|
||||||
if hits % 3 == 0:
|
if hits % 3 == 0:
|
||||||
|
|
|
||||||
159
scripts/luneraria/core/lune_content.gd
Normal 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
|
||||||
1
scripts/luneraria/core/lune_content.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://blkpu61mfghb2
|
||||||
|
|
@ -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))
|
var tangent: Vector2 = path.tangent_at(clampf(progress, 0.0, path.length))
|
||||||
return tangent if team == 0 else mirror(tangent)
|
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:
|
func core_position(team: int) -> Vector2:
|
||||||
return CORE_BLUE if team == 0 else mirror(CORE_BLUE)
|
return CORE_BLUE if team == 0 else mirror(CORE_BLUE)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ extends RefCounted
|
||||||
enum Phase { PLANNING, COMBAT, OVER }
|
enum Phase { PLANNING, COMBAT, OVER }
|
||||||
const ONGOING := -2
|
const ONGOING := -2
|
||||||
const DRAW := -1
|
const DRAW := -1
|
||||||
|
const GROTTO := "grotto"
|
||||||
const BOT_NAMES: Array[String] = ["Eichrufer", "Aschenzunge", "Quellwächter", "Glutfaust", "Moosauge", "Basaltherz", "Rindenkrone", "Funkenschritt"]
|
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",
|
const ROLES: Dictionary = {"g01": "melee", "g03": "front", "g05": "ranged", "g07": "splash", "g09": "support",
|
||||||
"w01": "melee", "w03": "front", "w05": "ranged", "w07": "splash", "w09": "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"]
|
const ROW_ORDER: Array[String] = ["front", "melee", "splash", "ranged", "support"]
|
||||||
|
|
||||||
var balance: LuneBalance
|
var balance: LuneBalance
|
||||||
|
|
@ -25,6 +27,9 @@ var building_hp: Dictionary = {}
|
||||||
var combat: LuneCombat
|
var combat: LuneCombat
|
||||||
var last_settlement: Dictionary = {}
|
var last_settlement: Dictionary = {}
|
||||||
var history: Array[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.
|
## Remainder rotation per team so both teams follow the same distribution pattern.
|
||||||
var _reward_rotation: Array[int] = [0, 0]
|
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()
|
balance = config if config != null else LuneBalance.default_balance()
|
||||||
map = LuneMap.new(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:
|
func setup(options: Dictionary = {}) -> void:
|
||||||
seed_value = int(options.get("seed", 1))
|
seed_value = int(options.get("seed", 1))
|
||||||
rng.seed = seed_value
|
rng.seed = seed_value
|
||||||
|
|
@ -43,6 +48,8 @@ func setup(options: Dictionary = {}) -> void:
|
||||||
history.clear()
|
history.clear()
|
||||||
last_settlement = {}
|
last_settlement = {}
|
||||||
_reward_rotation = [0, 0]
|
_reward_rotation = [0, 0]
|
||||||
|
boss = {"next_available_round": 1, "claim": {}}
|
||||||
|
boss_log.clear()
|
||||||
players.clear()
|
players.clear()
|
||||||
var human: int = int(options.get("human_slot", 1))
|
var human: int = int(options.get("human_slot", 1))
|
||||||
var bunds: Array = options.get("bunds", [])
|
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 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,
|
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,
|
"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.draft = _snapshot(player)
|
||||||
player.undo = []
|
player.undo = []
|
||||||
players.append(player)
|
players.append(player)
|
||||||
|
|
@ -59,7 +67,8 @@ func setup(options: Dictionary = {}) -> void:
|
||||||
building_hp[definition.id] = float(definition.max_hp)
|
building_hp[definition.id] = float(definition.max_hp)
|
||||||
|
|
||||||
func _snapshot(player: Dictionary) -> Dictionary:
|
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:
|
func slot_of(team: int, lane: String) -> int:
|
||||||
return team * 3 + LuneMap.LANES.find(lane)
|
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]:
|
func base_ids(bund: String) -> Array[String]:
|
||||||
return DataCatalog.faction_ids(bund)
|
return DataCatalog.faction_ids(bund)
|
||||||
|
|
||||||
|
func grotto_available() -> bool:
|
||||||
|
return round_number >= int(boss.next_available_round)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- planning commands
|
# ---------------------------------------------------------------- planning commands
|
||||||
func submit(slot: int, command: Dictionary) -> Dictionary:
|
func submit(slot: int, command: Dictionary) -> Dictionary:
|
||||||
if phase != Phase.PLANNING:
|
if phase != Phase.PLANNING:
|
||||||
|
|
@ -94,8 +106,17 @@ func _find(roster: Array, uid: String) -> int:
|
||||||
return index
|
return index
|
||||||
return -1
|
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:
|
func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dictionary:
|
||||||
var roster: Array = draft.roster
|
var roster: Array = draft.roster
|
||||||
|
var hero: Dictionary = draft.hero
|
||||||
match str(command.get("type", "")):
|
match str(command.get("type", "")):
|
||||||
"buy":
|
"buy":
|
||||||
var type_id: String = str(command.get("type_id", ""))
|
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.")
|
return _result(false, "Unbekannte Lane.")
|
||||||
draft.gold = int(draft.gold) - cost
|
draft.gold = int(draft.gold) - cost
|
||||||
# Per-player numbering keeps both teams' ids in the same order; seq sorts numerically.
|
# 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))
|
var seq: int = _next_seq(player, draft)
|
||||||
draft.next_seq = seq + 1
|
|
||||||
roster.append({"uid": "p%d-%d" % [player.slot, seq], "seq": seq, "type_id": type_id, "lane": lane, "invested": cost, "fresh": true})
|
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)
|
return _result(true, "%s gekauft." % DataCatalog.get_unit(type_id).display_name)
|
||||||
"upgrade":
|
"upgrade":
|
||||||
|
|
@ -141,12 +161,118 @@ func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dicti
|
||||||
roster.remove_at(index)
|
roster.remove_at(index)
|
||||||
return _result(true, "Für %d Gold zurückgegeben." % refund)
|
return _result(true, "Für %d Gold zurückgegeben." % refund)
|
||||||
"assign":
|
"assign":
|
||||||
var index: int = _find(roster, str(command.get("uid", "")))
|
var uid: String = str(command.get("uid", ""))
|
||||||
var lane: String = str(command.get("lane", ""))
|
var lane: String = str(command.get("lane", ""))
|
||||||
if index < 0 or not LuneMap.LANES.has(lane):
|
if not _valid_destination(lane):
|
||||||
return _result(false, "Ungültige Zuweisung.")
|
return _result(false, "Die Grotte ist bis Runde %d gesperrt." % int(boss.next_available_round) if lane == GROTTO else "Ungültige Zuweisung.")
|
||||||
roster[index].lane = lane
|
if not hero.is_empty() and str(hero.uid) == uid:
|
||||||
return _result(true, "Auf %s eingeteilt." % LuneMap.LANE_NAMES[lane])
|
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, "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.")
|
return _result(false, "Unbekannter Befehl.")
|
||||||
|
|
||||||
func _result(ok: bool, message: String) -> Dictionary:
|
func _result(ok: bool, message: String) -> Dictionary:
|
||||||
|
|
@ -162,6 +288,19 @@ func all_ready() -> bool:
|
||||||
return false
|
return false
|
||||||
return true
|
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
|
# ---------------------------------------------------------------- phase changes
|
||||||
## Validates every draft again and commits all of them at once, then starts combat.
|
## Validates every draft again and commits all of them at once, then starts combat.
|
||||||
func begin_combat() -> bool:
|
func begin_combat() -> bool:
|
||||||
|
|
@ -171,18 +310,32 @@ func begin_combat() -> bool:
|
||||||
if not _draft_valid(player):
|
if not _draft_valid(player):
|
||||||
player.draft = _snapshot(player)
|
player.draft = _snapshot(player)
|
||||||
for player: Dictionary in players:
|
for player: Dictionary in players:
|
||||||
player.spent = int(player.spent) + maxi(0, int(player.gold) - int(player.draft.gold))
|
var draft: Dictionary = player.draft
|
||||||
player.gold = int(player.draft.gold)
|
player.spent = int(player.spent) + maxi(0, int(player.gold) - int(draft.gold))
|
||||||
player.next_seq = int(player.draft.get("next_seq", player.next_seq))
|
player.gold = int(draft.gold)
|
||||||
player.roster = (player.draft.roster as Array).duplicate(true)
|
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:
|
for entry: Dictionary in player.roster:
|
||||||
entry.erase("fresh")
|
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 = []
|
player.undo = []
|
||||||
combat = LuneCombat.new(map, balance, round_number)
|
combat = LuneCombat.new(map, balance, round_number)
|
||||||
for definition: Dictionary in map.buildings:
|
for definition: Dictionary in map.buildings:
|
||||||
if float(building_hp[definition.id]) > 0.0:
|
if float(building_hp[definition.id]) > 0.0:
|
||||||
combat.add_building(definition, float(building_hp[definition.id]))
|
combat.add_building(definition, float(building_hp[definition.id]))
|
||||||
_spawn_waves()
|
_spawn_waves()
|
||||||
|
_spawn_champions()
|
||||||
|
_spawn_deployed_boss()
|
||||||
_spawn_armies()
|
_spawn_armies()
|
||||||
phase = Phase.COMBAT
|
phase = Phase.COMBAT
|
||||||
return true
|
return true
|
||||||
|
|
@ -198,9 +351,19 @@ func _draft_valid(player: Dictionary) -> bool:
|
||||||
for candidate: String in allowed:
|
for candidate: String in allowed:
|
||||||
if str(DataCatalog.get_unit(candidate).get("upgrade_id", "")) == id:
|
if str(DataCatalog.get_unit(candidate).get("upgrade_id", "")) == id:
|
||||||
base = candidate
|
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 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:
|
func _spawn_waves() -> void:
|
||||||
var wave: Array[Dictionary] = LuneWaves.build(balance, round_number)
|
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
|
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)
|
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:
|
func _spawn_armies() -> void:
|
||||||
for team: int in range(2):
|
for team: int in range(2):
|
||||||
for lane: String in LuneMap.LANES:
|
for lane: String in LuneMap.LANES:
|
||||||
|
|
@ -218,20 +429,28 @@ func _spawn_armies() -> void:
|
||||||
var owner: int = slot_of(team, lane)
|
var owner: int = slot_of(team, lane)
|
||||||
var ordered: Array = []
|
var ordered: Array = []
|
||||||
for slot: int in [owner] + [team * 3, team * 3 + 1, team * 3 + 2].filter(func(s: int) -> bool: return s != owner):
|
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)
|
ordered.append_array(_sorted(_army_entries(slot).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])
|
|
||||||
for index: int in range(ordered.size()):
|
for index: int in range(ordered.size()):
|
||||||
var slot: int = ordered[index][0]
|
var entry: Dictionary = ordered[index]
|
||||||
var entry: Dictionary = ordered[index][1]
|
|
||||||
var row: int = index / 4
|
var row: int = index / 4
|
||||||
var lateral: float = (float(index % 4) - 1.5) * 1.2
|
var lateral: float = (float(index % 4) - 1.5) * 1.2
|
||||||
var definition: Dictionary = DataCatalog.get_unit(entry.type_id)
|
combat.spawn(entry.kind, team, lane, entry.definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, entry.slot, str(entry.uid))
|
||||||
combat.spawn("troop", team, lane, definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, 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:
|
func role_of(type_id: String) -> String:
|
||||||
var id: String = type_id
|
var id: String = type_id
|
||||||
|
|
@ -256,11 +475,15 @@ func _settle() -> void:
|
||||||
var gains: Array[int] = [0, 0, 0, 0, 0, 0]
|
var gains: Array[int] = [0, 0, 0, 0, 0, 0]
|
||||||
var lane_kills: Dictionary = {}
|
var lane_kills: Dictionary = {}
|
||||||
var buildings_lost: Array[String] = []
|
var buildings_lost: Array[String] = []
|
||||||
|
var xp_gain: Dictionary = {}
|
||||||
for event: Dictionary in combat.events:
|
for event: Dictionary in combat.events:
|
||||||
var dying: int = int(event.team)
|
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)
|
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 owner: int = slot_of(killer, str(event.lane))
|
||||||
var lane_part: int = int(floor(float(bounty) * balance.lane_share))
|
var lane_part: int = int(floor(float(bounty) * balance.lane_share))
|
||||||
gains[owner] += lane_part
|
gains[owner] += lane_part
|
||||||
|
|
@ -269,19 +492,42 @@ func _settle() -> void:
|
||||||
elif event.kind in ["tower", "core"]:
|
elif event.kind in ["tower", "core"]:
|
||||||
buildings_lost.append(str(event.building_id))
|
buildings_lost.append(str(event.building_id))
|
||||||
_split(gains, killer, bounty)
|
_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:
|
for unit: LuneUnit in combat.units:
|
||||||
if unit.is_building():
|
if unit.is_building():
|
||||||
building_hp[unit.building_id] = unit.hp if unit.alive else 0.0
|
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)
|
var income: int = balance.round_income(round_number)
|
||||||
for player: Dictionary in players:
|
for player: Dictionary in players:
|
||||||
var total: int = gains[player.slot] + (0 if not combat.destroyed_cores.is_empty() else income)
|
var total: int = gains[player.slot] + (0 if not combat.destroyed_cores.is_empty() else income)
|
||||||
player.gold = int(player.gold) + total
|
player.gold = int(player.gold) + total
|
||||||
player.earned = int(player.earned) + 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,
|
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,
|
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)),
|
"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():
|
if not combat.destroyed_cores.is_empty():
|
||||||
winner = DRAW if combat.destroyed_cores.size() >= 2 else 1 - combat.destroyed_cores[0]
|
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")
|
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.undo = []
|
||||||
player.ready = not bool(player.is_human)
|
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.
|
## 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:
|
func _split(gains: Array[int], team: int, amount: int) -> void:
|
||||||
if amount <= 0:
|
if amount <= 0 or team < 0:
|
||||||
return
|
return
|
||||||
var share: int = amount / 3
|
var share: int = amount / 3
|
||||||
var rest: int = amount % 3
|
var rest: int = amount % 3
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ var effects: Dictionary = {}
|
||||||
var dots: Dictionary = {}
|
var dots: Dictionary = {}
|
||||||
var ability_ready: float = 6.0
|
var ability_ready: float = 6.0
|
||||||
var reached_base: bool = false
|
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.
|
# Presentation-only signals; never read by the rules.
|
||||||
var attack_serial: int = 0
|
var attack_serial: int = 0
|
||||||
var ability_serial: int = 0
|
var ability_serial: int = 0
|
||||||
|
|
@ -133,7 +136,14 @@ func damage_reduction() -> float:
|
||||||
|
|
||||||
func attack_speed_factor() -> float:
|
func attack_speed_factor() -> float:
|
||||||
var slowing: float = strongest_effect("attack_slow") * (0.5 if is_boss() else 1.0)
|
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:
|
func movement_factor() -> float:
|
||||||
return 1.0 - minf(0.35, strongest_effect("movement_slow") * (0.5 if is_boss() else 1.0))
|
return 1.0 - minf(0.35, strongest_effect("movement_slow") * (0.5 if is_boss() else 1.0))
|
||||||
|
|
|
||||||
|
|
@ -126,11 +126,23 @@ func _rebuild_preview() -> void:
|
||||||
_preview = LuneCombat.new(match_state.map, match_state.balance, match_state.round_number)
|
_preview = LuneCombat.new(match_state.map, match_state.balance, match_state.round_number)
|
||||||
var player: Dictionary = match_state.players[human_slot]
|
var player: Dictionary = match_state.players[human_slot]
|
||||||
var per_lane: Dictionary = {}
|
var per_lane: Dictionary = {}
|
||||||
|
var entries: Array = []
|
||||||
for entry: Dictionary in player.draft.roster:
|
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))
|
var index: int = int(per_lane.get(lane, 0))
|
||||||
per_lane[lane] = index + 1
|
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)
|
_add_view(unit)
|
||||||
|
|
||||||
func _add_view(unit: LuneUnit) -> void:
|
func _add_view(unit: LuneUnit) -> void:
|
||||||
|
|
@ -177,6 +189,8 @@ func _process(delta: float) -> void:
|
||||||
for definition: Dictionary in match_state.map.buildings:
|
for definition: Dictionary in match_state.map.buildings:
|
||||||
max_hp[definition.id] = definition.max_hp
|
max_hp[definition.id] = definition.max_hp
|
||||||
world.sync_buildings(hp, max_hp, _time, delta)
|
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_focus = camera.focus
|
||||||
hud.minimap.camera_span = camera.zoom
|
hud.minimap.camera_span = camera.zoom
|
||||||
|
|
||||||
|
|
|
||||||
186
scripts/luneraria/ui/lune_hero_panel.gd
Normal 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
|
||||||
|
|
@ -15,6 +15,8 @@ signal rematch_requested
|
||||||
signal settings_requested
|
signal settings_requested
|
||||||
|
|
||||||
const Minimap = preload("res://scripts/luneraria/ui/lune_minimap.gd")
|
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_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")]
|
||||||
const TEAM_NAMES: Array[String] = ["Blau", "Rot"]
|
const TEAM_NAMES: Array[String] = ["Blau", "Rot"]
|
||||||
const ROLE_NAMES: Dictionary = {"front": "Front", "melee": "Nahkampf", "ranged": "Fernkampf", "splash": "Fläche", "support": "Unterstützung"}
|
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_title: Label
|
||||||
var _over_body: Label
|
var _over_body: Label
|
||||||
var _roster_signature: String = "@"
|
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:
|
func _ready() -> void:
|
||||||
theme = SaumwachtTheme.shared()
|
theme = SaumwachtTheme.shared()
|
||||||
|
|
@ -152,19 +159,34 @@ func _lane_panel() -> void:
|
||||||
button.tooltip_text = "Kamera auf %s" % LuneMap.LANE_NAMES[lane]
|
button.tooltip_text = "Kamera auf %s" % LuneMap.LANE_NAMES[lane]
|
||||||
_lane_rows[lane] = button
|
_lane_rows[lane] = button
|
||||||
_team_list = _label("", "MutedLabel", column, 14)
|
_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:
|
func _plan_side() -> void:
|
||||||
_plan_panel = _panel("MenuPanel")
|
_plan_panel = _panel("MenuPanel")
|
||||||
_plan_panel.anchor_left = 1.0
|
_plan_panel.anchor_left = 1.0
|
||||||
_plan_panel.anchor_right = 1.0
|
_plan_panel.anchor_right = 1.0
|
||||||
_plan_panel.anchor_bottom = 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_right = -16
|
||||||
_plan_panel.offset_top = 112
|
_plan_panel.offset_top = 112
|
||||||
_plan_panel.offset_bottom = -270
|
_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()
|
var column := VBoxContainer.new()
|
||||||
column.add_theme_constant_override("separation", 8)
|
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)
|
_label("TRUPPEN KAUFEN · 1–5", "SectionLabel", column)
|
||||||
_shop = GridContainer.new()
|
_shop = GridContainer.new()
|
||||||
_shop.columns = 5
|
_shop.columns = 5
|
||||||
|
|
@ -179,18 +201,34 @@ func _plan_side() -> void:
|
||||||
_roster.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_roster.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
_roster.add_theme_constant_override("separation", 4)
|
_roster.add_theme_constant_override("separation", 4)
|
||||||
scroll.add_child(_roster)
|
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()
|
var actions := HBoxContainer.new()
|
||||||
column.add_child(actions)
|
outer.add_child(actions)
|
||||||
_starter = _button("Startaufstellung", actions, func() -> void: starter_requested.emit())
|
_starter = _button("Startaufstellung", actions, func() -> void: starter_requested.emit())
|
||||||
_starter.tooltip_text = "Kauft Front, Nahkampf und Fernkampf deines Bundes für deine Lane."
|
_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())
|
_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.custom_minimum_size.y = 58
|
||||||
_ready_button.tooltip_text = "Leertaste · Befehle werden jetzt verbindlich übernommen."
|
_ready_button.tooltip_text = "Leertaste · Befehle werden jetzt verbindlich übernommen."
|
||||||
_combat_panel = _panel()
|
_combat_panel = _panel()
|
||||||
_combat_panel.anchor_left = 1.0
|
_combat_panel.anchor_left = 1.0
|
||||||
_combat_panel.anchor_right = 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_right = -16
|
||||||
_combat_panel.offset_top = 112
|
_combat_panel.offset_top = 112
|
||||||
_combat_info = _label("", "Label", _combat_panel, 16)
|
_combat_info = _label("", "Label", _combat_panel, 16)
|
||||||
|
|
@ -264,8 +302,19 @@ func _process(delta: float) -> void:
|
||||||
if match_state != null:
|
if match_state != null:
|
||||||
refresh()
|
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:
|
func refresh() -> void:
|
||||||
var player: Dictionary = match_state.players[human_slot]
|
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 planning: bool = match_state.phase == LuneMatch.Phase.PLANNING
|
||||||
var combat: bool = match_state.phase == LuneMatch.Phase.COMBAT
|
var combat: bool = match_state.phase == LuneMatch.Phase.COMBAT
|
||||||
var phase_name: String = "PLANUNG" if planning else ("KAMPF" if combat else "PARTIE BEENDET")
|
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.match_state = match_state
|
||||||
minimap.queue_redraw()
|
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:
|
func _reason(reason: String) -> String:
|
||||||
return {"cleared": "alle Einheiten gefallen", "timeout": "Zeitfenster abgelaufen", "core": "Ancient zerstört"}.get(reason, reason)
|
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)
|
var name_label := _label(str(data.display_name), "Label", row, 15)
|
||||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
name_label.clip_text = true
|
name_label.clip_text = true
|
||||||
for lane: String in LuneMap.LANES:
|
for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]:
|
||||||
var lane_button := Button.new()
|
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.toggle_mode = true
|
||||||
lane_button.button_pressed = entry.lane == lane
|
lane_button.button_pressed = entry.lane == lane
|
||||||
lane_button.theme_type_variation = "ChoiceButton"
|
lane_button.theme_type_variation = "ChoiceButton"
|
||||||
lane_button.custom_minimum_size = Vector2(34, 34)
|
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)
|
var uid: String = str(entry.uid)
|
||||||
lane_button.pressed.connect(func() -> void: command_requested.emit({"type": "assign", "uid": uid, "lane": lane}))
|
lane_button.pressed.connect(func() -> void: command_requested.emit({"type": "assign", "uid": uid, "lane": lane}))
|
||||||
row.add_child(lane_button)
|
row.add_child(lane_button)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ extends Control
|
||||||
## Minimap drawn from the authoritative match state. Click or drag to move the camera.
|
## Minimap drawn from the authoritative match state. Click or drag to move the camera.
|
||||||
|
|
||||||
signal focus_requested(point: Vector3)
|
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 match_state: LuneMatch
|
||||||
var camera_focus := Vector3.ZERO
|
var camera_focus := Vector3.ZERO
|
||||||
var camera_span: float = 34.0
|
var camera_span: float = 34.0
|
||||||
|
|
@ -42,12 +42,16 @@ func _draw() -> void:
|
||||||
points.append(to_map(p))
|
points.append(to_map(p))
|
||||||
draw_polyline(points, Color(.62, .56, .42), 4.0, true)
|
draw_polyline(points, Color(.62, .56, .42), 4.0, true)
|
||||||
var grotto: Vector2 = to_map(LuneMap.GROTTO)
|
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 = {}
|
var hp: Dictionary = {}
|
||||||
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null:
|
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null:
|
||||||
for unit: LuneUnit in match_state.combat.units:
|
for unit: LuneUnit in match_state.combat.units:
|
||||||
if unit.is_mobile() and unit.alive:
|
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:
|
for definition: Dictionary in match_state.map.buildings:
|
||||||
var alive: bool = match_state.building_alive(definition.id)
|
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):
|
if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null and match_state.combat.buildings.has(definition.id):
|
||||||
|
|
|
||||||
113
scripts/luneraria/ui/lune_reinforce_panel.gd
Normal 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)
|
||||||
|
|
@ -2,7 +2,8 @@ extends Node3D
|
||||||
## Visual twin of one LuneUnit. Reads the authoritative state and drives the existing
|
## 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.
|
## 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 unit: LuneUnit
|
||||||
var model: Node3D
|
var model: Node3D
|
||||||
|
|
@ -49,7 +50,8 @@ func bind(source: LuneUnit) -> void:
|
||||||
torus.rings = 20
|
torus.rings = 20
|
||||||
torus.ring_segments = 4
|
torus.ring_segments = 4
|
||||||
_ring.mesh = torus
|
_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.scale = Vector3(1, .25, 1)
|
||||||
_ring.position.y = .05
|
_ring.position.y = .05
|
||||||
_ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
_ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
|
|
@ -82,6 +84,22 @@ func bind(source: LuneUnit) -> void:
|
||||||
_fill.position = Vector3(-.43, 0, 0)
|
_fill.position = Vector3(-.43, 0, 0)
|
||||||
_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
_bars.add_child(_fill)
|
_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_attack = unit.attack_serial
|
||||||
_last_ability = unit.ability_serial
|
_last_ability = unit.ability_serial
|
||||||
position = Vector3(unit.position.x, 0, unit.position.y)
|
position = Vector3(unit.position.x, 0, unit.position.y)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const RiverShader = preload("res://scripts/presentation/river.gdshader")
|
||||||
const TOWER := "res://assets/luneraria/tower.glb"
|
const TOWER := "res://assets/luneraria/tower.glb"
|
||||||
const ROCK := "res://assets/compare/compare-rock.glb"
|
const ROCK := "res://assets/compare/compare-rock.glb"
|
||||||
const TREE := "res://assets/compare/compare-tree.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_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")]
|
||||||
const TEAM_NAMES: Array[String] = ["Blau", "Rot"]
|
const TEAM_NAMES: Array[String] = ["Blau", "Rot"]
|
||||||
|
|
||||||
|
|
@ -38,6 +39,7 @@ func build(match_map: LuneMap) -> void:
|
||||||
_crystal_materials.append(crystal)
|
_crystal_materials.append(crystal)
|
||||||
for definition: Dictionary in map.buildings:
|
for definition: Dictionary in map.buildings:
|
||||||
_building(definition)
|
_building(definition)
|
||||||
|
_grotto()
|
||||||
|
|
||||||
func terrain(key: String, color: Color) -> ShaderMaterial:
|
func terrain(key: String, color: Color) -> ShaderMaterial:
|
||||||
if not _terrain.has(key):
|
if not _terrain.has(key):
|
||||||
|
|
@ -161,6 +163,28 @@ func _base_plaza(team: int) -> void:
|
||||||
rim.scale = Vector3(1, .3, 1)
|
rim.scale = Vector3(1, .3, 1)
|
||||||
add_child(rim)
|
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:
|
func _far_from_lanes(point: Vector2, margin: float) -> bool:
|
||||||
for lane: String in LuneMap.LANES:
|
for lane: String in LuneMap.LANES:
|
||||||
var path: LuneMap.LanePath = map.lanes[lane]
|
var path: LuneMap.LanePath = map.lanes[lane]
|
||||||
|
|
@ -171,6 +195,8 @@ func _far_from_lanes(point: Vector2, margin: float) -> bool:
|
||||||
return false
|
return false
|
||||||
if absf(point.x - point.y) < 5.0:
|
if absf(point.x - point.y) < 5.0:
|
||||||
return false
|
return false
|
||||||
|
if point.distance_to(LuneMap.GROTTO) < 9.0:
|
||||||
|
return false
|
||||||
return true
|
return true
|
||||||
|
|
||||||
func _decoration() -> void:
|
func _decoration() -> void:
|
||||||
|
|
|
||||||
344
tests/luneraria/test_lune_m2.gd
Normal 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")
|
||||||
1
tests/luneraria/test_lune_m2.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dinpvfnad6n3o
|
||||||
6
tests/luneraria/test_lune_m2.tscn
Normal 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")
|
||||||
108
tools/luneraria/boss_scenarios.gd
Normal 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()
|
||||||
6
tools/luneraria/boss_scenarios.tscn
Normal 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")
|
||||||
|
|
@ -20,7 +20,7 @@ ROLES = {
|
||||||
'g02': ('Glutpakt Nahkampf+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
|
'g02': ('Glutpakt Nahkampf+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
|
||||||
'g03': ('Glutpakt Tank', 'Truppe Front', 'Glutkonstrukt / Glutpakt', 'keiner'),
|
'g03': ('Glutpakt Tank', 'Truppe Front', 'Glutkonstrukt / Glutpakt', 'keiner'),
|
||||||
'g04': ('Glutpakt Tank+', 'Truppenaufwertung', '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'),
|
'g06': ('Glutpakt Fernkampf+', 'Truppenaufwertung (Belagerung)', 'Glutkonstrukt / Glutpakt', 'keiner'),
|
||||||
'g07': ('Glutpakt Fläche', 'Truppe Fläche', 'Glutkonstrukt / Glutpakt', 'keiner'),
|
'g07': ('Glutpakt Fläche', 'Truppe Fläche', 'Glutkonstrukt / Glutpakt', 'keiner'),
|
||||||
'g08': ('Glutpakt Fläche+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
|
'g08': ('Glutpakt Fläche+', 'Truppenaufwertung', 'Glutkonstrukt / Glutpakt', 'keiner'),
|
||||||
|
|
@ -29,13 +29,13 @@ ROLES = {
|
||||||
'w01': ('Wurzelbund Nahkampf', 'Truppe Nahkampf', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
'w01': ('Wurzelbund Nahkampf', 'Truppe Nahkampf', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
||||||
'w02': ('Wurzelbund Nahkampf+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
'w02': ('Wurzelbund Nahkampf+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
||||||
'w03': ('Wurzelbund Tank', 'Truppe Front', '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'),
|
'w05': ('Wurzelbund Fernkampf', 'Truppe Fernkampf', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
||||||
'w06': ('Wurzelbund Fernkampf+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
'w06': ('Wurzelbund Fernkampf+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
||||||
'w07': ('Wurzelbund Fläche', 'Truppe Fläche', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
'w07': ('Wurzelbund Fläche', 'Truppe Fläche', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
||||||
'w08': ('Wurzelbund Fläche+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
'w08': ('Wurzelbund Fläche+', 'Truppenaufwertung', 'Wurzelwesen / Wurzelbund', 'keiner'),
|
||||||
'w09': ('Wurzelbund Heiler', 'Truppe Unterstützung', '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'),
|
'e01': ('Wellengegner', 'Monsterwelle (teamgebunden, rotierend)', 'Riftbrut (neutral, pro Team eingefärbt)', 'Teamkennzeichnung'),
|
||||||
'e02': ('Wellengegner', 'Monsterwelle', 'Riftbrut', 'Teamkennzeichnung'),
|
'e02': ('Wellengegner', 'Monsterwelle', 'Riftbrut', 'Teamkennzeichnung'),
|
||||||
'e03': ('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'),
|
'e07': ('Boss Welle 5', 'Reserve (Wellen-Elite ab höheren Indizes)', 'Riftbrut', 'offen'),
|
||||||
'e08': ('Boss Welle 8', 'Reserve (Wellen-Elite)', 'Riftbrut', 'offen'),
|
'e08': ('Boss Welle 8', 'Reserve (Wellen-Elite)', 'Riftbrut', 'offen'),
|
||||||
'e09': ('Boss Welle 15', 'Reserve', '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'),
|
's01': ('Söldner', 'Reserve Angriffschampion', 'Söldner', 'offen'),
|
||||||
's02': ('Söldner', 'M2: Angriffschampion Schutz', 'Söldner', 'keiner'),
|
's02': ('Söldner', 'Angriffschampion Schutz (Rostschieber)', 'Söldner', 'umgesetzt (M2)'),
|
||||||
's03': ('Söldner', 'M2: Angriffschampion Druck auf Unterstützer', 'Söldner', 'keiner'),
|
's03': ('Söldner', 'Angriffschampion Druck auf Unterstützer (Dunstsänger)', 'Söldner', 'umgesetzt (M2)'),
|
||||||
's04': ('Söldner', 'M2: Angriffschampion Belagerung', 'Söldner', 'keiner'),
|
's04': ('Söldner', 'Angriffschampion Belagerung (Keilbrecher)', 'Söldner', 'umgesetzt (M2)'),
|
||||||
's05': ('Söldner', 'Reserve Angriffschampion', 'Söldner', 'offen'),
|
's05': ('Söldner', 'Reserve Angriffschampion', 'Söldner', 'offen'),
|
||||||
'core_team0': ('Saumkern Team 0', 'Ancient Blau (SW)', '—', 'keiner'),
|
'core_team0': ('Saumkern Team 0', 'Ancient Blau (SW)', '—', 'keiner'),
|
||||||
'core_team1': ('Saumkern Team 1', 'Ancient Rot (NO)', '—', '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'),
|
('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'),
|
('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'),
|
('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)'),
|
('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)'),
|
||||||
('Bossgrotte', '—', '(fehlt)', '—', '—', '—', '—', 'Grottenbau nahe Zentrum', '—', '—', 'neu (M2)'),
|
('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'),
|
('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', '',
|
lines = ['# Asset-Wiederverwendungsmatrix', '',
|
||||||
|
|
|
||||||
1
tools/luneraria/lune_benchmark.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dlfjuf2h58hnh
|
||||||
1
tools/luneraria/lune_review.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dc2bycbgqbbu0
|
||||||
1
tools/luneraria/verify_m1.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dc1n70oevxrtp
|
||||||
171
tools/luneraria/verify_m2.gd
Normal 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)
|
||||||
6
tools/luneraria/verify_m2.tscn
Normal 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")
|
||||||