diff --git a/docs/luneraria/G3-HUD-BESTAND.md b/docs/luneraria/G3-HUD-BESTAND.md new file mode 100644 index 0000000..a923b76 --- /dev/null +++ b/docs/luneraria/G3-HUD-BESTAND.md @@ -0,0 +1,137 @@ +# G3 · HUD-Bestand vor dem Umbau + +Stand: 29. September 2026 · Grundlage `luneraria/g2` (`9f62e30`), Arbeitszweig `luneraria/g3`. +Diese Aufnahme beschreibt das HUD, wie es G2 ausliefert. Sie ist die Referenz für die Entscheidungen in [G3-HUD.md](G3-HUD.md). + +## Aufnahmen + +Alle Bilder stammen aus dem echten Spiel (`LuneGame` mit dem echten HUD). `tools/luneraria/g3_views.gd` setzt nur öffentlichen Partiezustand (Runde, Gold, Gebäude-HP, Boss-Claim) und bedient das HUD über dessen eigene Methoden; dasselbe Skript fotografiert später das G3-HUD. + +| Auflösung | Ordner | logische Fläche | +| --- | --- | --- | +| 1920×1080 | `screenshots/g3/vorher-1920x1080/` | 1706×960 (Skalierung 1,125) | +| 2560×1440 | `screenshots/g3/vorher-2560x1440/` | 1706×960 (1,5) | +| 1280×720 | `screenshots/g3/vorher-1280x720/` | 1706×960 (0,75) | + +Motive je Ordner: `01-planung`, `02-held-ausgewaehlt`, `03-itemauswahl`, `04-einheit-ausgewaehlt`, `05-angriffschampion`, `06-boss-verfuegbar`, `07-boss-angekuendigt`, `08/09/10-kampf-top/mid/bottom`, `11-eigener-ancient-kritisch`, `12-gegnerischer-ancient-kritisch`, `13-turm-zerstoert`, `14-grosse-kampfansicht`. + +Wichtig für das Layout: Das Projekt skaliert mit `canvas_items`/`expand` von 1600×960 aus. Jede 16:9-Auflösung ergibt dieselbe logische Fläche von 1706×960; 1280×720 zeigt also exakt dasselbe Layout wie 2560×1440, nur mit halb so vielen Pixeln. Ein 16-px-Text ist bei 720p physisch 12 px hoch. Ultrawide (21:9) verbreitert die Fläche auf etwa 2275×960, 16:10 und das Mindestfenster 1152×720 schmälern sie auf 1600×960. + +## Aufbau + +| Datei | Zeilen | Inhalt | +| --- | ---: | --- | +| `scripts/luneraria/ui/lune_hud.gd` | 484 | das gesamte HUD in einem Skript: Kopfleiste, Lane-Panel, Planungsseite, Kampfpanel, Bericht, Minimap-Rahmen, Ergebnis | +| `scripts/luneraria/ui/lune_hero_panel.gd` | 186 | Reiter „Held“: Anwerben, Stufe/EP, Einsatz-Lane, Items, Talente, Live-Porträt | +| `scripts/luneraria/ui/lune_reinforce_panel.gd` | 113 | Reiter „Verstärkung“: Champion-Buchung, Ankündigungen, Boss-Lane | +| `scripts/luneraria/ui/lune_minimap.gd` | 80 | Minimap aus dem Partiezustand | +| `scripts/luneraria/ui/lune_settings.gd` | 68 | Optionen und Steuerungstext | +| `scripts/ui/theme/saumwacht_theme.gd`, `forged_style.gd` | 247 + 69 | gemeinsames Menü-Theme (Basaltplatten, Bronzekanten) | +| `scripts/ui/components/choice_group.gd` | – | Segmentauswahl, im HUD für die drei Reiter | + +Bildschirmaufteilung (logische Pixel bei 1706×960): + +- **Kopfleiste** über die ganze Breite (Höhe ≈ 96): Titel, Phase, „Welle n“, zwei Ancient-Balken mit Zahl, Gold, Einkommen, Armeegröße, Identität (Team · Lane · Bund), Pause/Tempo (nur Kampf), Optionen, Menü. +- **Links** (300 breit, ≈ 400 hoch): „LANES · TÜRME“ mit drei Knöpfen `TOP Blau ●● Rot ●●` plus Besitzernamen, darunter Teamlisten mit Basistürmen und der Grottenstatus als Fließtext. +- **Rechts** (484 breit ≈ 28–30 % der Breite, 112 bis 270 über dem Boden): Reiter Truppen / Held / Verstärkung, Kaufkacheln, Armeeliste mit T/M/B/G-Umschaltern, ↑-Aufwertung, ×-Verkauf, Startaufstellung, Rückgängig, Bereit. Im Kampf ersetzt ein Textpanel mit Einheitenzahlen pro Lane die Seite. +- **Unten links** (bis 760 breit): Rundenbericht und Hinweis-/Fehlerzeile. +- **Unten rechts**: Minimap 236×236. +- **Ergebnis**: Vollbild-Abdunklung mit Sieg/Niederlage. + +## Theme und Komponenten + +- Das HUD nutzt das Menü-Theme `SaumwachtTheme` unverändert: Varianten `CaptionPanel`, `MenuPanel`, `SectionLabel`, `MutedLabel`, `CardTitleLabel`, `PrimaryButton`, `QuietButton`, `ChoiceButton`. +- Es gibt keine HUD-Komponenten. Knöpfe, Balken und Zeilen entstehen lokal; `_bar()` legt für jeden Balken eigene `StyleBoxFlat`-Kopien an. +- Die Menüplatten (`MenuPanel`: 30/26 px Innenabstand, Nieten, 18 px Schatten) sind für ein Spielfeld-HUD zu schwer; der rechte Block wirkt wie ein Menü über dem Feld. + +## Porträts + +- Kauf- und Armeekacheln zeigen `AssetLibrary.icon()`, also die 2D-Konzeptbilder aus Phase 3, nicht die Spielmodelle. Heldenmodelle, Champions, Monster und die Schlundkrone haben dort gar kein Bild. +- Nur der Heldenreiter rendert das echte Modell: eine eigene `SubViewport` mit eigener Welt, `UPDATE_WHEN_VISIBLE`, jede Frame animiert, solange der Reiter offen ist. +- Keine einheitliche Kamera, kein Cache. + +## Eingabe + +| Taste | Wirkung (G2) | Ort | +| --- | --- | --- | +| 1–5 | Truppe kaufen | `lune_game._input` | +| Leertaste | Planung: Bereit · Kampf: Pause | `lune_game._input` | +| P | Pause | `lune_game._input` | +| Tab | nächste Lane | `lune_game._input` | +| F2 / F3 / F4 | Kamera Top / Mid / Bottom | `lune_game._input` | +| B · G · H · O · R | eigene Basis · Grotte · Held · Übersicht · Kamera zurück | `lune_game._input` | +| Q / E | Kamera drehen | `lune_game._input` | +| WASD, Pfeile, mittlere Maustaste, Mausrad | Kamera | `lune_camera` | +| F1 · M · F11 · Esc | Optionen · Ton · Vollbild · Optionen schließen | `lune_main._unhandled_input` | + +Frei sind unter anderem C, V, X, Z, N, U, I, J, K, L, T, Y und F5–F10. Die Welt kennt keine Auswahl per Linksklick; Einheiten wählt man nur über die Armeeliste. + +## Tooltips + +Alle Hinweise sind Godot-Standard-Tooltips (`tooltip_text`): reiner Text, feste Verzögerung, Stil aus dem Menü-Theme. Einheitenwerte stehen nur im Tooltip der Kaufkachel (HP, „Schaden/s“ – tatsächlich Schaden pro Treffer bei 1 Angriff/s –, Reichweite, Fähigkeit). Item- und Talenttexte erscheinen nur als Tooltip. + +## Optionen + +Lautstärke, Ton stumm, Vollbild, Bewegung reduzieren, dazu ein Steuerungstext. Es gibt keine UI-Größe. „Bewegung reduzieren“ wirkt auf Kamera, Menü und Effekte; das HUD hat keine Animationen. + +## Minimap + +- Zeichnet Lanes, Fluss, Grotte (Farbe: frei/gesperrt/Team), alle Gebäude (voll/zerstört) und im Kampf **jede** bewegliche Einheit als Punkt. Bei 90–120 Einheiten entsteht eine Punktwolke; Helden (gold) und Boss (größer) gehen darin unter. +- Türme beider Teams unterscheiden sich nur durch die Farbe. +- Klick und Ziehen bewegen die Kamera; das Trapez des Kamerabilds ist sichtbar. +- `queue_redraw()` in jeder Frame, unabhängig von Änderungen. + +## Lanes, Türme, Ancients + +- Lane-Zustand nur als `●●`/`●○` für eigene und gegnerische Außen-/Innentürme; kein Schadensstand, keine Bedrohung, keine Angabe, welche Seite drückt, keine angekündigten Champions. +- Basistürme stehen als „Basistürme 2/2“ im Teamtext. +- Ancients: zwei gleich gewichtete Balken mit Zahl in der Kopfleiste; kein kritischer Zustand. +- Klick auf eine Lane springt mit der Kamera dorthin (gut, bleibt). + +## Held, Items, Talente + +- Heldenreiter mit Live-Porträt, Stufe, EP-Balken (Werte nur im Tooltip), Einsatz-Lane, drei Itemknöpfen (Name + ×), sechs Item-Kaufknöpfen, Talentwahl. +- Keine Grundwerte (HP, Schaden, Tempo, Reichweite, Rüstung) und keine Fähigkeitswerte. +- Item-Zustände nur über „deaktiviert“; warum ein Item nicht geht (Gold, Stufe, schon getragen, Plätze voll), steht nirgends. +- Bei 1280×720 ist der Reiter abgeschnitten: Itemkauf und Talente liegen unter „Startaufstellung/Rückgängig“ und brauchen Scrollen (`vorher-1280x720/03-itemauswahl.png`). + +## Normale Einheiten + +- Eine „Auswahl“ gibt es nicht; jede Armeezeile hat Porträt (2D), abgeschnittenen Namen („Korkı“, „Dorn“, „Same“), T/M/B/G, ↑ Kosten, ×. +- Werte der gekauften Einheit, Aufwertungsziel und Rückgabebetrag nur im Tooltip. +- G (Grotte) ohne Warnung, dass die Einheit ihrer Lane die ganze Kampfphase fehlt (nur im Tooltip und im Grottentext links). + +## Angriffschampions, Boss, Wellen + +- Champions: Textliste im Reiter „Verstärkung“ mit Ziel-Lane-Schaltern; Buchungen als „Gebucht: … Runde n“. Optisch wie normale Knöpfe, kein Unterschied zu dauerhaften Truppen. +- Ankündigungen (öffentlich) nur in diesem Reiter. +- Bossgrotte: Fließtext links („verfügbar“, „gesperrt bis Runde n“, „von Blau erobert … greift in Runde 12 über Mid an“). Kein Hinweis auf dem Spielfeld, kein Kopfgeld im Blick, keine Unterscheidung „umkämpft“. +- Wellen: „Welle n“ ohne Obergrenze (richtig). Was die nächste Welle bringt, zeigt das HUD nicht, obwohl `LuneWaves.build()` öffentlich und deterministisch ist. +- Ereignisse: nur der Rundenbericht nach dem Kampf und eine Hinweiszeile; Turmfall, Bossangriff oder kritischer Ancient erscheinen nicht im Moment des Geschehens. + +## Netzwerk und Privatsphäre + +- Der Gast erhält `rpc_state` mit dem **Gold aller Spieler**, deren bestätigten Armeen, Helden und Buchungen; den Entwurf (`draft`) nur für den eigenen Sitz. +- Das G2-HUD zeigt Gold nur für den eigenen Sitz (`gold_of(human_slot)`) – das bleibt Pflicht. +- Bereit-Status, Mensch/Bot und Verbindungsabbrüche zeigt das HUD nicht; Sitzungsmeldungen erscheinen nur als Hinweiszeile. +- Nach „Bereit“ lehnt der Host weitere Befehle ab („Bereits bereit gemeldet …“); das HUD zeigt dann „WARTE AUF ANDERE …“, lässt aber Kaufkacheln, Lane-Knöpfe und Tasten 1–5 aktiv. + +## Leistung + +- `refresh()` läuft jede Frame: alle Beschriftungen werden neu formatiert, im Kampf wird zusätzlich über alle Einheiten gezählt (O(n) je Frame), in der Planung `JSON.stringify(draft)` gebildet. +- Held- und Verstärkungsreiter bilden jede Frame eine JSON-Signatur. +- Die Minimap zeichnet jede Frame alle Einheiten. +- Die Heldenporträt-`SubViewport` rendert jede Frame, solange der Reiter offen ist. + +## Konsequenzen für G3 + +1. HUD in Komponenten zerlegen, Theme um HUD-Varianten erweitern statt lokaler StyleBox-Kopien. +2. Ancients und Welle in eine schmale Kopfzeile; die Ancients bekommen mehr Gewicht als Einzeltürme. +3. Lane-Übersicht mit echten Turmsymbolen (Form + Schadensstand), deterministischer Bedrohungsstufe aus öffentlichem Zustand und angekündigten Gefahren. +4. Kauf, Armee und Held nach unten; rechts nur Kontext (Auswahl, nächste Welle, Boss) statt einer dauerhaften 30-%-Leiste. +5. Porträts aus den echten Modellen, einmal gerendert und zwischengespeichert, statt 2D-Skizzen und Live-Viewport. +6. Minimap mit zusammengefassten Armeen, deutlichen Helden, Champions und Boss, Teamformen statt nur Farben. +7. Ereignisleiste für Turmfall, Champions, Boss, kritische Ancients. +8. Bereit-Sperre sichtbar machen, fremdes Gold und fremde Entwürfe weiter nie anzeigen, Mensch/Bot/Verbindung pro Mitspieler zeigen. +9. Aktualisierung nach Änderungen statt jeder Frame; Minimap gedrosselt. +10. Layout für 1600–2300 logische Breite und eine UI-Größe in den Optionen; Mindestschriftgröße so, dass 720p lesbar bleibt. diff --git a/docs/luneraria/G3-HUD.md b/docs/luneraria/G3-HUD.md new file mode 100644 index 0000000..25b85b7 --- /dev/null +++ b/docs/luneraria/G3-HUD.md @@ -0,0 +1,59 @@ +# G3 · Ingame-HUD (Zwischenstand) + +Stand: 29. September 2026 · Zweig `luneraria/g3` (von `luneraria/g2`, `9f62e30`). + +**G3 ist nicht abgenommen.** Nach dem ersten Spieltest hat der Projektinhaber entschieden, Champions of Luneraria nach Unity zu portieren. Dieser Stand ist die Referenz für das HUD in Unity (Portphase U3). Ausgangslage vor dem Umbau: [G3-HUD-BESTAND.md](G3-HUD-BESTAND.md). + +## Was steht + +**Informationsarchitektur** (logische Pixel bei 1706×960, UI-Größe darauf): + +| Bereich | Inhalt | +| --- | --- | +| oben Mitte | eigener Ancient (Emblem Mondsichel/Sonnenscheibe, HP-Balken mit Wert, zwei Basistürme, Zustand), **WELLE n** ohne Obergrenze, Phase (PLANUNG, PLANUNG · BEREIT, KAMPF mm:ss), gegnerischer Ancient | +| oben rechts | Pause, Tempo (nur im Kampf), Optionen F1, Menü | +| links | drei Lane-Zeilen: Bedrohung, Turmkette von der eigenen zur gegnerischen Basis mit HP, Spieler und Helden beider Seiten, angekündigte Champions/Boss, Einheitenzahl im Kampf; darunter das eigene Team und die Ereignisleiste | +| unten | Gold und Einkommen, Rückgängig; Kaufkarten 1–5 oder Reiter Angriffschampions; eigene Armee als Porträt-Chips mit Lane und Stufe; Heldenkarte | +| rechts | Bossgrotte, nächste Welle, ausgewählte Einheit; nie eine dauerhafte Seitenleiste über 30 % | +| unten rechts | Bereit (Planung) bzw. Kampfuhr, darunter die Minimap | + +**Komponenten** (`scripts/luneraria/ui/components/`, Theme `ui/theme/lune_ui_theme.gd`): StatusBadge, HpBar, ResourceDisplay, TowerIndicator, LaneIndicator, TeamPanel, UnitCard (Champion-Variante kupfern, schraffiert, „1 WELLE“), ArmyChip, HeroCard, HeroDetail (Anwerben, Werte, Items, Talente), ItemSlot mit acht Zuständen, UnitDetail, ChampionPanel, BossIndicator, NextWave, EventFeed, TabBar, ModalPanel, ein gemeinsamer Tooltip. Symbole sind Vektorgrafiken (`LuneGlyphs`), Porträts kommen aus den echten Modellen (`LunePortraits`: eine Offscreen-Kamera, jedes Modell einmal gerendert und zwischengespeichert). + +**Datenzugang und Privatsphäre:** Alles läuft über `LuneHudModel`. Gold, Entwurf und neue Buchungen gibt es nur für den eigenen Sitz; von anderen Spielern nur Name, Lane, Bereitschaft, Mensch/Bot/offline und der bestätigte Held. Die Lane-Bedrohung (`LuneLaneThreat`: RUHIG, UMKÄMPFT, UNTER DRUCK, KRITISCH) rechnet nur mit öffentlichen Daten und für Host und Gast gleich; auf einem Gast werden Positionen auf die Lane projiziert. + +**Bedienung:** Klick auf Lane, Ereignis, Bossgrotte oder Minimap bewegt die Kamera; F2–F4, G, B, H wie in G2, alle in den Optionen gelistet. Linksklick auf eine eigene Figur im Feld wählt sie aus. Nach „Bereit“ sind Karten, Tasten 1–5, Rückgängig und Einsatzknöpfe gesperrt. Bereit ignoriert eine Sekunde nach Kampfende Klicks und Leertaste. Das Mausrad über HUD-Flächen zoomt die Welt nicht. + +**Aktualisierung:** 10 Hz in der Planung, 5 Hz im Kampf, Minimap 10 Hz mit eigener Ebene für den Kamerarahmen; jede Komponente vergleicht eine Signatur und baut nur bei Änderungen neu. + +**Team-Erkennung ohne Farbe:** Blau rund (Mondsichel), Rot eckig (Sonnenscheibe), Grotte sechseckig: in HUD, Minimap, Einheitenringen im Feld. Die entsandte Schlundkrone trägt einen zweiten Besitzring und „für Blau/Rot“ im Namen (G2-Risiko der Farbnähe). + +**Legion TD 2 / Dota 2:** Die Wellenvorschau zeigt die Rüstung jedes Monsters und welcher Schadenstyp stark dagegen ist (Schadensmatrix). + +## Prüfung + +| Prüfung | Ergebnis | +| --- | --- | +| `tools/luneraria/verify_g3.tscn` (Layout 1152×720 bis 2560×1440 und 2560×1080 bei 90/100/130 %, Status, Held/Items/Einheiten, Champions/Boss, Bereit-Sperre, Bots, Netzgast mit Privatsphäre, Eingabe, Ereignisse, Bewegung reduzieren, Raten, Porträt-Cache) | 715/715 | +| Regeln `test_lune_core` / `test_lune_m2` / `test_lune_net` | 91/91 · 73/73 · 43/43 | +| `verify_m1` / `verify_m2` (UI-Treiber an das neue HUD angepasst, Regelprüfungen unverändert) | 38/38 · 14/14 | + +Bilder: `screenshots/g3/zwischenstand-1920x1080/`, Spieltest `screenshots/g3/spieltest/`, Ablauf `logs/g3/playtest-log.txt`. + +## Spieltest (Partie über die echte Oberfläche bis Welle 8) + +Werkzeug: `tools/luneraria/g3_playtest.tscn` (Mausklicks, Tasten und Mausrad an Fensterkoordinaten, Bildschirmfoto nach jedem Schritt; keine Eingriffe in den Spielzustand). + +| Befund | Status | +| --- | --- | +| Pause/Tempo lagen im Kampf genau dort, wo danach „Bereit“ erscheint; ein später Klick übersprang die Planung von Welle 2 | behoben: Pause/Tempo oben rechts, Bereit 1 s gesperrt | +| Heldenkarte bot im Kampf „Held anwerben“ an | behoben: „Kein Held · Anwerben in der Planung“ | +| „1 WELLE“ auf Champion-Karten überdeckt die Köpfe der Porträts | offen: als Band am unteren Porträtrand | +| Gut lesbar: Hotkey-Kauf mit Rückmeldung, Lane-Tooltip mit Turm-HP und Gründen, gefallene Einheiten durchgestrichen, Stufenaufstieg und Turmfall in der Ereignisleiste, „TALENT!“, Item-Zustände, Champion-Ankündigung „FÜR EUCH“ in Lane und Wellenvorschau | – | + +## Offen (für U3 übernehmen) + +- Champion-Etikett (siehe oben). +- Schadens- und Rüstungstyp als Symbol auf Einheitenkarten, farblich passend zur Wellenvorschau (Legion-TD-Prinzip). +- Armeewert (Summe des Einsatzes) in der Armeezeile. +- Heldenporträts statt Punkten auf der Minimap (Dota-Prinzip). +- Aufnahmen 2560×1440, 1280×720, Ultrawide für den Endstand, Video, Leistungsvergleich G2 → G3. diff --git a/docs/luneraria/HELDEN-KONZEPT.md b/docs/luneraria/HELDEN-KONZEPT.md new file mode 100644 index 0000000..3fb9d67 --- /dev/null +++ b/docs/luneraria/HELDEN-KONZEPT.md @@ -0,0 +1,47 @@ +# Heldenkonzept · Champions of Luneraria + +Stand: 29. September 2026 · Vorgabe des Projektinhabers, während G3 eingegangen. +**Noch nicht umgesetzt.** G3 schließt neue Einheiten und Regeländerungen aus; die Umsetzung gehört in eine eigene Phase (Einheiten/Helden, frühestens mit G6). +Die vier Helden sollen die drei vorläufigen Helden ablösen. Diese nutzen bisher vorhandene Modelle: Ringborke (w04), Quellgeflecht (w10) und Funkenlanze (g05); siehe `scripts/luneraria/core/lune_content.gd`. + +## Die vier Helden + +| | Lucy | Ghaliga | Siegfried | Feena | +| --- | --- | --- | --- | --- | +| Volk | Dunkelelfe | Zwerg | Mensch | Waldelfe | +| Rolle | Kontrolle, Schwächung, Wellen stören | Tank, Bruiser, Frontkämpfer | Anführer, Paladin, Team-Buff, Front | Fernkampf-DPS, Boss- und Elitekiller | +| Waffe | Bogen | Axt | Schwert (gold-blau) | Bogen | +| Fähigkeit | Frostpfeile verlangsamen Gegner | Wirbelhieb: Flächenangriff um ihn herum | Schlachtruf stärkt nahe Verbündete | Giftpfeile: Schaden über Zeit | +| Ultimate (Command-Menü) | Multischuss: mehrere Pfeile auf mehrere Ziele, Kontrolle und Flächendruck statt Burst | Versteinerung: absorbiert massiv Schaden, extrem zäh, optional Bindung/Aggro | Excalibur: heiliger Schlag mit hohem Schaden und kurzer Betäubung mehrerer Gegner | Turbopfeile: kurz stark erhöhte Angriffsgeschwindigkeit | +| Spielziel | gefährliche Wellen verlangsamen, Formationen stören | Front halten, Gegner binden, Wellen abfangen | Verbündete stärken, kritische Kämpfe drehen | hoher anhaltender Fernkampfschaden, besonders gegen starke Einzelziele | +| Optik | lange weiße Haare, grüne Augen, dunkle Haut, dunkle Lederkleidung mit dunkelblauen Akzenten, frostige Nachtästhetik | rote Haare, langer Bart, blaue Augen, goldene Zwergenrüstung, schwere robuste Silhouette | kurze blonde Haare, blaue Augen, goldene Paladinrüstung | lange rote Haare, grüne Augen, grün-braune Lederrüstung, flinke naturverbundene Schützin | + +## Was die Umsetzung berührt (Notizen für die spätere Phase) + +**Neue Mechanik: Ultimates über ein Command-Menü.** Bisher laufen Kämpfe automatisch, Helden handeln allein und Spieler geben nur in der Planung Befehle. Ein Ultimate im Kampf braucht deshalb: +- Regeln: Auslösen, Abklingzeit oder Ladung, Zielwahl (Spieler oder automatisch), Dauer, Reihenfolge im Simulationsschritt, Determinismus. +- Netzwerk: einen Kampfbefehl vom Gast an den Host; heute gibt es nur Planungsbefehle. +- Bots: wann sie auslösen. +- HUD: ein Command-Menü. G3 hat bewusst keine MOBA-Aktionsleiste; Heldenkarte und Heldenfenster haben aber Platz für Fähigkeit und Ultimate. + +**Effekte gegenüber dem heutigen Regelwerk:** + +| Effekt | Stand heute | +| --- | --- | +| Verlangsamung (Frost) | vorhanden: `movement_slow`, `attack_slow` | +| Gift über Zeit | vorhanden: DoTs wie `seed_burn`, `burn` | +| Fläche um den Helden (Wirbelhieb) | vorhanden: Splash-Logik | +| Buff naher Verbündeter (Schlachtruf) | vorhanden: `damage`-Effekt | +| Angriffstempo (Turbopfeile) | vorhanden: `attack_speed`-Effekt | +| Schadensabsorption / Zähigkeit (Versteinerung) | vorhanden: Schild und Schadensminderung (Deckel 35 %) | +| Betäubung (Excalibur) | **neu** | +| Bindung / Aggro (Versteinerung) | **neu**: Zielwahl-Regel | + +**Assets:** vier neue skelettierte Modelle (Blender) mit mindestens idle, walk, attack, ability, ultimate, hit und death, dazu die Waffen (zwei Bögen, Axt, Schwert). Porträts entstehen automatisch über den G3-Porträt-Cache aus den Modellen. + +**Offene Fragen:** +- Bleibt es bei einem Helden pro Spieler? +- Hängen Helden an Bünden (Glutpakt/Wurzelbund) oder sind sie frei wählbar? +- Ersetzen die vier die drei vorläufigen Helden vollständig? +- Wie oft gibt es das Ultimate (je Welle, Abklingzeit in Sekunden, Ladung über Stufe)? +- Wie werden Talente und Items auf die neuen Rollen abgestimmt? diff --git a/docs/luneraria/STATUS.md b/docs/luneraria/STATUS.md index 749782c..74f860e 100644 --- a/docs/luneraria/STATUS.md +++ b/docs/luneraria/STATUS.md @@ -1,6 +1,8 @@ # STATUS · Champions of Luneraria -Stand: 29. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `luneraria/m3`, `luneraria/m4`, `luneraria/g1`, `luneraria/g2` · Luneraria-Demo 0.17.0-g2 (macOS, Windows x64/ARM64, Linux). +Stand: 29. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `luneraria/m3`, `luneraria/m4`, `luneraria/g1`, `luneraria/g2`, `luneraria/g3` (Zwischenstand) · Luneraria-Demo 0.17.0-g2 (macOS, Windows x64/ARM64, Linux). + +**Engine-Wechsel (29. September 2026):** Der Projektinhaber hat entschieden, Champions of Luneraria nach Unity 6 zu portieren, um eine höhere Grafikqualität zu erreichen. Das Unity-Projekt liegt in `../luneraria-unity`. Dieses Godot-Projekt bleibt als Referenz für Regeln, Tests, Assets und HUD-Design erhalten; neue Arbeit findet im Unity-Projekt statt. Arbeitsrechner seit G2: Mac (Apple M5, macOS 27) mit Godot 4.7.2 und Blender 5.2.2 LTS; bis G1 Fedora 44 (RTX 5060 Ti). Werkzeugpfade und Plattformhinweise: [ANLEITUNG.md](ANLEITUNG.md). @@ -13,7 +15,8 @@ Arbeitsrechner seit G2: Mac (Apple M5, macOS 27) mit Godot 4.7.2 und Blender 5.2 | M4 · Netzwerk und Pakete | **pausiert** (Host/Join, Mehr-Runden-Loopback, Wiedereinstieg; offen: Windows auf x64-Hardware, Netz über mehrere Rechner) | | G1 · Map-Blockout und Drei-Lane-Layout | **abgeschlossen** ([G1-KARTE.md](G1-KARTE.md)) | | G2 · Ancients, Türme und Bossgrotte | **abgeschlossen** ([G2-STRATEGISCHE-BAUWERKE.md](G2-STRATEGISCHE-BAUWERKE.md), [G2-ASSETS.md](G2-ASSETS.md)) | -| G3–G6 · HUD, Umgebung, Licht, Einheiten | offen | +| G3 · Ingame-HUD | **Zwischenstand, nicht abgenommen** ([G3-HUD.md](G3-HUD.md)); abgebrochen zugunsten des Unity-Ports | +| G4–G6 · Umgebung, Licht, Einheiten | in Godot nicht mehr geplant; werden nach dem Port in Unity fortgesetzt | ## M1 · abgeschlossen diff --git a/docs/luneraria/logs/g3/playtest-log.txt b/docs/luneraria/logs/g3/playtest-log.txt new file mode 100644 index 0000000..380917a --- /dev/null +++ b/docs/luneraria/logs/g3/playtest-log.txt @@ -0,0 +1,94 @@ + 6.4 s #1 [menu] wait 1.5 + 7.9 s #1 [menu] click 290 853 + 7.9 s #1 [menu] wait 1.2 + 9.1 s #1 [menu] click 247 320 + 9.1 s #1 [menu] wait 0.4 + 9.5 s #1 [menu] click 415 945 + 9.8 s #1 [round=1 phase=PLANUNG] wait 2.5 + 32.2 s #2 [round=1 phase=PLANUNG] click 988 916 + 32.3 s #2 [round=1 phase=PLANUNG] wait 0.5 + 32.7 s #2 [round=1 phase=PLANUNG] move 1000 400 + 32.7 s #2 [round=1 phase=PLANUNG] key SPACE + 33.8 s #2 [round=1 phase=KAMPF] wait 1.5 + 35.1 s #2 [round=1 phase=KAMPF] shot r1-kampfbeginn + 35.5 s #2 [round=1 phase=KAMPF] click 1812 33 + 35.5 s #2 [round=1 phase=KAMPF] key F3 + 35.5 s #2 [round=1 phase=KAMPF] wait 5 + 65.2 s #3 [round=1 phase=KAMPF] key ESCAPE + 65.2 s #3 [round=1 phase=KAMPF] wait 0.3 + 65.5 s #3 [round=1 phase=KAMPF] click 1741 33 + 65.5 s #3 [round=1 phase=KAMPF] key F3 + 65.5 s #3 [round=1 phase=KAMPF] wait 5 + 96.1 s #4 [round=1 phase=KAMPF] key P + 96.1 s #4 [round=1 phase=KAMPF] wait 7 + 118.5 s #5 [round=1 phase=KAMPF] wait 12 + 153.0 s #6 [round=2 phase=PLANUNG] move 1000 450 + 153.0 s #6 [round=2 phase=PLANUNG] key 4 + 153.0 s #6 [round=2 phase=PLANUNG] wait 0.4 + 153.4 s #6 [round=2 phase=PLANUNG] move 180 205 + 153.4 s #6 [round=2 phase=PLANUNG] wait 0.8 + 172.5 s #7 [round=2 phase=PLANUNG] move 1000 450 + 172.6 s #7 [round=2 phase=PLANUNG] key SPACE + 172.8 s #7 [round=2 phase=KAMPF] wait 0.6 + 173.3 s #7 [round=2 phase=KAMPF] click 1741 33 + 173.3 s #7 [round=2 phase=KAMPF] key F3 + 173.3 s #7 [round=2 phase=KAMPF] wait 8 + 202.8 s #8 [round=3 phase=PLANUNG] wait 10 + 233.1 s #9 [round=3 phase=PLANUNG] move 1000 450 + 233.1 s #9 [round=3 phase=PLANUNG] key 3 + 233.1 s #9 [round=3 phase=PLANUNG] wait 0.4 + 233.5 s #9 [round=3 phase=PLANUNG] click 1165 925 + 233.5 s #9 [round=3 phase=PLANUNG] wait 0.6 + 252.1 s #10 [round=3 phase=PLANUNG] key ESCAPE + 252.1 s #10 [round=3 phase=PLANUNG] move 1000 450 + 252.1 s #10 [round=3 phase=PLANUNG] key SPACE + 252.7 s #10 [round=3 phase=KAMPF] wait 20 + 291.3 s #11 [round=4 phase=PLANUNG] move 1000 450 + 291.3 s #11 [round=4 phase=PLANUNG] key SPACE + 291.4 s #11 [round=4 phase=KAMPF] wait 22 + 332.1 s #12 [round=5 phase=PLANUNG] click 1430 960 + 332.1 s #12 [round=5 phase=PLANUNG] wait 1.0 + 349.0 s #13 [round=5 phase=PLANUNG] click 615 838 + 349.0 s #13 [round=5 phase=PLANUNG] wait 1.0 + 367.2 s #14 [round=5 phase=PLANUNG] key ESCAPE + 367.2 s #14 [round=5 phase=PLANUNG] move 1000 450 + 367.2 s #14 [round=5 phase=PLANUNG] wait 0.3 + 367.5 s #14 [round=5 phase=PLANUNG] key SPACE + 368.1 s #14 [round=5 phase=KAMPF] wait 9 + 398.4 s #15 [round=6 phase=PLANUNG] wait 16 + 440.3 s #16 [round=6 phase=PLANUNG] move 1000 450 + 440.3 s #16 [round=6 phase=PLANUNG] key 2 + 440.3 s #16 [round=6 phase=PLANUNG] key 1 + 440.3 s #16 [round=6 phase=PLANUNG] wait 0.5 + 440.8 s #16 [round=6 phase=PLANUNG] shot r6-gekauft + 458.6 s #17 [round=6 phase=PLANUNG] click 996 995 + 458.6 s #17 [round=6 phase=PLANUNG] wait 0.5 + 475.8 s #18 [round=6 phase=PLANUNG] click 1752 470 + 475.9 s #18 [round=6 phase=PLANUNG] wait 0.5 + 476.4 s #18 [round=6 phase=PLANUNG] key F4 + 476.4 s #18 [round=6 phase=PLANUNG] wait 0.8 + 499.9 s #19 [round=6 phase=PLANUNG] key ESCAPE + 499.9 s #19 [round=6 phase=PLANUNG] move 1000 450 + 499.9 s #19 [round=6 phase=PLANUNG] key SPACE + 500.0 s #19 [round=6 phase=KAMPF] wait 1 + 501.0 s #19 [round=6 phase=KAMPF] key F4 + 501.0 s #19 [round=6 phase=KAMPF] wait 22 + 543.9 s #20 [round=7 phase=PLANUNG] click 1430 960 + 544.0 s #20 [round=7 phase=PLANUNG] wait 0.6 + 544.5 s #20 [round=7 phase=PLANUNG] click 1133 258 + 544.5 s #20 [round=7 phase=PLANUNG] wait 0.6 + 565.5 s #21 [round=7 phase=PLANUNG] click 1463 365 + 565.6 s #21 [round=7 phase=PLANUNG] wait 0.5 + 566.1 s #21 [round=7 phase=PLANUNG] click 1018 258 + 566.1 s #21 [round=7 phase=PLANUNG] wait 0.6 + 639.6 s #22 [round=7 phase=PLANUNG] key ESCAPE + 639.6 s #22 [round=7 phase=PLANUNG] wait 0.3 + 639.9 s #22 [round=7 phase=PLANUNG] click 494 885 + 640.0 s #22 [round=7 phase=PLANUNG] wait 0.6 + 661.1 s #23 [round=7 phase=PLANUNG] click 393 985 + 661.1 s #23 [round=7 phase=PLANUNG] wait 0.6 + 677.6 s #24 [round=7 phase=PLANUNG] click 272 885 + 677.6 s #24 [round=7 phase=PLANUNG] move 1000 450 + 677.6 s #24 [round=7 phase=PLANUNG] key SPACE + 677.6 s #24 [round=7 phase=KAMPF] wait 24 + 1155.4 s #25 [round=8 phase=PLANUNG] quit diff --git a/docs/luneraria/logs/g3/verify-g3.json b/docs/luneraria/logs/g3/verify-g3.json new file mode 100644 index 0000000..1f1d40b --- /dev/null +++ b/docs/luneraria/logs/g3/verify-g3.json @@ -0,0 +1,133 @@ +{ + "checks": 715, + "facts": { + "boss_badges": [ + "BOSS VERFÜGBAR", + "COOLDOWN – 2 RUNDEN", + "EINSATZ NÄCHSTE WELLE", + "BOSS GREIFT AN · MID", + "BOSS UMKÄMPFT" + ], + "checks_per_part": { + "A layout": 593, + "B status": 27, + "C hero and units": 24, + "D champions and boss": 19, + "E ready, bots, network": 16, + "F input": 20, + "G events and motion": 8, + "H rates and portraits": 8 + }, + "layouts": [ + [ + "1280x720 @90%", + "(1280, 720)", + "(1706.0, 960.0)", + 0.9 + ], + [ + "1280x720 @100%", + "(1280, 720)", + "(1706.0, 960.0)", + 1.0 + ], + [ + "1280x720 @130%", + "(1280, 720)", + "(1706.0, 960.0)", + 1.09090909090909 + ], + [ + "1920x1080 @90%", + "(1920, 1080)", + "(1706.0, 960.0)", + 0.9 + ], + [ + "1920x1080 @100%", + "(1920, 1080)", + "(1706.0, 960.0)", + 1.0 + ], + [ + "1920x1080 @130%", + "(1920, 1080)", + "(1706.0, 960.0)", + 1.09090909090909 + ], + [ + "2560x1440 @90%", + "(2560, 1440)", + "(1706.0, 960.0)", + 0.9 + ], + [ + "2560x1440 @100%", + "(2560, 1440)", + "(1706.0, 960.0)", + 1.0 + ], + [ + "2560x1440 @130%", + "(2560, 1440)", + "(1706.0, 960.0)", + 1.09090909090909 + ], + [ + "2560x1080 @90%", + "(2560, 1080)", + "(2275.0, 960.0)", + 0.9 + ], + [ + "2560x1080 @100%", + "(2560, 1080)", + "(2275.0, 960.0)", + 1.0 + ], + [ + "2560x1080 @130%", + "(2560, 1080)", + "(2275.0, 960.0)", + 1.09090909090909 + ], + [ + "1152x720 @90%", + "(1152, 720)", + "(1600.0, 1000.0)", + 0.9 + ], + [ + "1152x720 @100%", + "(1152, 720)", + "(1600.0, 1000.0)", + 1.0 + ], + [ + "1152x720 @130%", + "(1152, 720)", + "(1600.0, 1000.0)", + 1.02564102564103 + ] + ], + "portraits": { + "cached": 24, + "rendered": 24 + }, + "rates": { + "combat_syncs_per_s": 4, + "hud_sync_usec": 1936.70488656746, + "minimap_markers": { + "boss": 0, + "champions": 0, + "clusters": 18, + "heroes": 6, + "units": 114 + }, + "minimap_redraws_per_s": 10, + "planning_syncs_per_s": 10 + }, + "seconds": 17.894 + }, + "failures": [] +} \ No newline at end of file diff --git a/docs/luneraria/screenshots/g3/.gdignore b/docs/luneraria/screenshots/g3/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/docs/luneraria/screenshots/g3/spieltest/00-befund-bereit-neben-tempo.png b/docs/luneraria/screenshots/g3/spieltest/00-befund-bereit-neben-tempo.png new file mode 100644 index 0000000..8daae6b Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/00-befund-bereit-neben-tempo.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/01-planung-welle1.png b/docs/luneraria/screenshots/g3/spieltest/01-planung-welle1.png new file mode 100644 index 0000000..34dde6c Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/01-planung-welle1.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/02-kampfbeginn.png b/docs/luneraria/screenshots/g3/spieltest/02-kampfbeginn.png new file mode 100644 index 0000000..83a434e Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/02-kampfbeginn.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/03-lane-tooltip.png b/docs/luneraria/screenshots/g3/spieltest/03-lane-tooltip.png new file mode 100644 index 0000000..a7bf257 Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/03-lane-tooltip.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/04-einheit-details.png b/docs/luneraria/screenshots/g3/spieltest/04-einheit-details.png new file mode 100644 index 0000000..6615b5e Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/04-einheit-details.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/05-held-anwerben.png b/docs/luneraria/screenshots/g3/spieltest/05-held-anwerben.png new file mode 100644 index 0000000..001de9f Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/05-held-anwerben.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/06-kampf-held-gefallen.png b/docs/luneraria/screenshots/g3/spieltest/06-kampf-held-gefallen.png new file mode 100644 index 0000000..1446f15 Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/06-kampf-held-gefallen.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/07-einsatz-bottom.png b/docs/luneraria/screenshots/g3/spieltest/07-einsatz-bottom.png new file mode 100644 index 0000000..c9cf0f8 Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/07-einsatz-bottom.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/08-talentwahl.png b/docs/luneraria/screenshots/g3/spieltest/08-talentwahl.png new file mode 100644 index 0000000..f3e4f71 Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/08-talentwahl.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/09-items-zu-teuer.png b/docs/luneraria/screenshots/g3/spieltest/09-items-zu-teuer.png new file mode 100644 index 0000000..5263701 Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/09-items-zu-teuer.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/10-champion-gebucht.png b/docs/luneraria/screenshots/g3/spieltest/10-champion-gebucht.png new file mode 100644 index 0000000..8a86bf7 Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/10-champion-gebucht.png differ diff --git a/docs/luneraria/screenshots/g3/spieltest/11-welle8-ankuendigung.png b/docs/luneraria/screenshots/g3/spieltest/11-welle8-ankuendigung.png new file mode 100644 index 0000000..c966e4b Binary files /dev/null and b/docs/luneraria/screenshots/g3/spieltest/11-welle8-ankuendigung.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/01-planung.png b/docs/luneraria/screenshots/g3/vorher-1280x720/01-planung.png new file mode 100644 index 0000000..219f3af Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/01-planung.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/02-held-ausgewaehlt.png b/docs/luneraria/screenshots/g3/vorher-1280x720/02-held-ausgewaehlt.png new file mode 100644 index 0000000..e55b400 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/02-held-ausgewaehlt.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/03-itemauswahl.png b/docs/luneraria/screenshots/g3/vorher-1280x720/03-itemauswahl.png new file mode 100644 index 0000000..ae75544 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/03-itemauswahl.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/04-einheit-ausgewaehlt.png b/docs/luneraria/screenshots/g3/vorher-1280x720/04-einheit-ausgewaehlt.png new file mode 100644 index 0000000..1e232b1 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/04-einheit-ausgewaehlt.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/05-angriffschampion.png b/docs/luneraria/screenshots/g3/vorher-1280x720/05-angriffschampion.png new file mode 100644 index 0000000..3ce75b5 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/05-angriffschampion.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/06-boss-verfuegbar.png b/docs/luneraria/screenshots/g3/vorher-1280x720/06-boss-verfuegbar.png new file mode 100644 index 0000000..a68fbf0 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/06-boss-verfuegbar.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/07-boss-angekuendigt.png b/docs/luneraria/screenshots/g3/vorher-1280x720/07-boss-angekuendigt.png new file mode 100644 index 0000000..8255f2c Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/07-boss-angekuendigt.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/08-kampf-top.png b/docs/luneraria/screenshots/g3/vorher-1280x720/08-kampf-top.png new file mode 100644 index 0000000..138f36d Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/08-kampf-top.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/09-kampf-mid.png b/docs/luneraria/screenshots/g3/vorher-1280x720/09-kampf-mid.png new file mode 100644 index 0000000..2b14f98 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/09-kampf-mid.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/10-kampf-bottom.png b/docs/luneraria/screenshots/g3/vorher-1280x720/10-kampf-bottom.png new file mode 100644 index 0000000..28402e4 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/10-kampf-bottom.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/11-eigener-ancient-kritisch.png b/docs/luneraria/screenshots/g3/vorher-1280x720/11-eigener-ancient-kritisch.png new file mode 100644 index 0000000..7c0ebee Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/11-eigener-ancient-kritisch.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/12-gegnerischer-ancient-kritisch.png b/docs/luneraria/screenshots/g3/vorher-1280x720/12-gegnerischer-ancient-kritisch.png new file mode 100644 index 0000000..c754a23 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/12-gegnerischer-ancient-kritisch.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/13-turm-zerstoert.png b/docs/luneraria/screenshots/g3/vorher-1280x720/13-turm-zerstoert.png new file mode 100644 index 0000000..6542428 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/13-turm-zerstoert.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1280x720/14-grosse-kampfansicht.png b/docs/luneraria/screenshots/g3/vorher-1280x720/14-grosse-kampfansicht.png new file mode 100644 index 0000000..6353a99 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1280x720/14-grosse-kampfansicht.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/01-planung.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/01-planung.png new file mode 100644 index 0000000..fffb685 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/01-planung.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/02-held-ausgewaehlt.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/02-held-ausgewaehlt.png new file mode 100644 index 0000000..d377f25 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/02-held-ausgewaehlt.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/03-itemauswahl.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/03-itemauswahl.png new file mode 100644 index 0000000..7d41502 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/03-itemauswahl.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/04-einheit-ausgewaehlt.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/04-einheit-ausgewaehlt.png new file mode 100644 index 0000000..35a5930 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/04-einheit-ausgewaehlt.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/05-angriffschampion.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/05-angriffschampion.png new file mode 100644 index 0000000..9c4d172 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/05-angriffschampion.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/06-boss-verfuegbar.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/06-boss-verfuegbar.png new file mode 100644 index 0000000..5ad2530 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/06-boss-verfuegbar.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/07-boss-angekuendigt.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/07-boss-angekuendigt.png new file mode 100644 index 0000000..5e7f25c Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/07-boss-angekuendigt.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/08-kampf-top.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/08-kampf-top.png new file mode 100644 index 0000000..d3353df Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/08-kampf-top.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/09-kampf-mid.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/09-kampf-mid.png new file mode 100644 index 0000000..44e3e99 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/09-kampf-mid.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/10-kampf-bottom.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/10-kampf-bottom.png new file mode 100644 index 0000000..7822e0c Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/10-kampf-bottom.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/11-eigener-ancient-kritisch.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/11-eigener-ancient-kritisch.png new file mode 100644 index 0000000..023c2a2 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/11-eigener-ancient-kritisch.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/12-gegnerischer-ancient-kritisch.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/12-gegnerischer-ancient-kritisch.png new file mode 100644 index 0000000..1390cd6 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/12-gegnerischer-ancient-kritisch.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/13-turm-zerstoert.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/13-turm-zerstoert.png new file mode 100644 index 0000000..0356df0 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/13-turm-zerstoert.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-1920x1080/14-grosse-kampfansicht.png b/docs/luneraria/screenshots/g3/vorher-1920x1080/14-grosse-kampfansicht.png new file mode 100644 index 0000000..433e6a7 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-1920x1080/14-grosse-kampfansicht.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/01-planung.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/01-planung.png new file mode 100644 index 0000000..fc461b5 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/01-planung.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/02-held-ausgewaehlt.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/02-held-ausgewaehlt.png new file mode 100644 index 0000000..7c9171e Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/02-held-ausgewaehlt.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/03-itemauswahl.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/03-itemauswahl.png new file mode 100644 index 0000000..aa94217 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/03-itemauswahl.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/04-einheit-ausgewaehlt.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/04-einheit-ausgewaehlt.png new file mode 100644 index 0000000..7cc8834 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/04-einheit-ausgewaehlt.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/05-angriffschampion.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/05-angriffschampion.png new file mode 100644 index 0000000..28c58a4 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/05-angriffschampion.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/06-boss-verfuegbar.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/06-boss-verfuegbar.png new file mode 100644 index 0000000..7870bd8 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/06-boss-verfuegbar.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/07-boss-angekuendigt.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/07-boss-angekuendigt.png new file mode 100644 index 0000000..9f7088c Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/07-boss-angekuendigt.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/08-kampf-top.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/08-kampf-top.png new file mode 100644 index 0000000..7e4601d Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/08-kampf-top.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/09-kampf-mid.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/09-kampf-mid.png new file mode 100644 index 0000000..365b995 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/09-kampf-mid.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/10-kampf-bottom.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/10-kampf-bottom.png new file mode 100644 index 0000000..fbb05d0 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/10-kampf-bottom.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/11-eigener-ancient-kritisch.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/11-eigener-ancient-kritisch.png new file mode 100644 index 0000000..50cdd75 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/11-eigener-ancient-kritisch.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/12-gegnerischer-ancient-kritisch.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/12-gegnerischer-ancient-kritisch.png new file mode 100644 index 0000000..60208d3 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/12-gegnerischer-ancient-kritisch.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/13-turm-zerstoert.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/13-turm-zerstoert.png new file mode 100644 index 0000000..42c7258 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/13-turm-zerstoert.png differ diff --git a/docs/luneraria/screenshots/g3/vorher-2560x1440/14-grosse-kampfansicht.png b/docs/luneraria/screenshots/g3/vorher-2560x1440/14-grosse-kampfansicht.png new file mode 100644 index 0000000..a093123 Binary files /dev/null and b/docs/luneraria/screenshots/g3/vorher-2560x1440/14-grosse-kampfansicht.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/01-planung.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/01-planung.png new file mode 100644 index 0000000..65b37dc Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/01-planung.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/02-held-ausgewaehlt.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/02-held-ausgewaehlt.png new file mode 100644 index 0000000..dab3d0f Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/02-held-ausgewaehlt.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/03-itemauswahl.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/03-itemauswahl.png new file mode 100644 index 0000000..3ec1e0a Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/03-itemauswahl.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/04-einheit-ausgewaehlt.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/04-einheit-ausgewaehlt.png new file mode 100644 index 0000000..d0f765a Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/04-einheit-ausgewaehlt.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/05-angriffschampion.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/05-angriffschampion.png new file mode 100644 index 0000000..fed403c Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/05-angriffschampion.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/06-boss-verfuegbar.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/06-boss-verfuegbar.png new file mode 100644 index 0000000..48c6ed8 Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/06-boss-verfuegbar.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/07-boss-angekuendigt.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/07-boss-angekuendigt.png new file mode 100644 index 0000000..f0d2e67 Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/07-boss-angekuendigt.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/08-kampf-top.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/08-kampf-top.png new file mode 100644 index 0000000..f728acb Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/08-kampf-top.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/09-kampf-mid.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/09-kampf-mid.png new file mode 100644 index 0000000..f287eee Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/09-kampf-mid.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/10-kampf-bottom.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/10-kampf-bottom.png new file mode 100644 index 0000000..0c7b723 Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/10-kampf-bottom.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/11-eigener-ancient-kritisch.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/11-eigener-ancient-kritisch.png new file mode 100644 index 0000000..a8f6a55 Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/11-eigener-ancient-kritisch.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/12-gegnerischer-ancient-kritisch.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/12-gegnerischer-ancient-kritisch.png new file mode 100644 index 0000000..a1e7b08 Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/12-gegnerischer-ancient-kritisch.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/13-turm-zerstoert.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/13-turm-zerstoert.png new file mode 100644 index 0000000..858ba9a Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/13-turm-zerstoert.png differ diff --git a/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/14-grosse-kampfansicht.png b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/14-grosse-kampfansicht.png new file mode 100644 index 0000000..cd34f1c Binary files /dev/null and b/docs/luneraria/screenshots/g3/zwischenstand-1920x1080/14-grosse-kampfansicht.png differ diff --git a/scripts/autoload/preferences.gd b/scripts/autoload/preferences.gd index 613e43e..1cc1d3b 100644 --- a/scripts/autoload/preferences.gd +++ b/scripts/autoload/preferences.gd @@ -8,6 +8,8 @@ var follow_camera: bool = true var fullscreen: bool = false ## Menu camera travel, idle drift, particles and fades are skipped when set. var reduced_motion: bool = false +## Size of the in-match HUD (Luneraria); the HUD caps it so its layout always fits. +var ui_scale: float = 1.0 var storage_path: String = "user://preferences.cfg" func _ready() -> void: @@ -37,6 +39,7 @@ func read_settings() -> void: follow_camera = bool(config.get_value("camera", "follow", true)) fullscreen = bool(config.get_value("display", "fullscreen", false)) reduced_motion = bool(config.get_value("display", "reduced_motion", false)) + ui_scale = clampf(float(config.get_value("display", "ui_scale", 1.0)), .75, 1.5) func apply() -> void: AudioServer.set_bus_volume_db(0, linear_to_db(maxf(.001, volume))) @@ -52,6 +55,7 @@ func save_settings() -> Error: config.set_value("camera", "follow", follow_camera) config.set_value("display", "fullscreen", fullscreen) config.set_value("display", "reduced_motion", reduced_motion) + config.set_value("display", "ui_scale", ui_scale) return config.save(storage_path) func commit() -> void: diff --git a/scripts/luneraria/lune_game.gd b/scripts/luneraria/lune_game.gd index 1e55ea2..1f6f411 100644 --- a/scripts/luneraria/lune_game.gd +++ b/scripts/luneraria/lune_game.gd @@ -55,6 +55,7 @@ func start(setup_options: Dictionary) -> void: hud = Hud.new() hud.match_state = match_state hud.human_slot = maxi(0, human_slot) + hud.session = session if _net() else null layer.add_child(hud) hud.command_requested.connect(_on_command) hud.undo_requested.connect(func() -> void: _feedback(_send({"type": "undo"}) if _net() else match_state.undo(human_slot))) @@ -64,11 +65,14 @@ func start(setup_options: Dictionary) -> void: hud.pause_requested.connect(_toggle_pause) hud.lane_focus_requested.connect(focus_lane) hud.map_focus_requested.connect(func(point: Vector3) -> void: camera.look_at_point(point)) + hud.camera_requested.connect(focus_target) hud.menu_requested.connect(func() -> void: exit_requested.emit()) hud.rematch_requested.connect(func() -> void: rematch_requested.emit()) if _net(): session.session_message.connect(func(text: String, is_error: bool) -> void: hud.notify(text, is_error)) session.state_received.connect(_rebuild_preview) + session.state_received.connect(hud.mark_dirty) + session.lobby_changed.connect(hud.mark_dirty) if human_slot >= 0: focus_lane(match_state.players[human_slot].lane, true) else: @@ -104,6 +108,21 @@ func focus_grotto(instant: bool = false) -> void: var center: Vector2 = match_state.map.grotto_center() camera.look_at_point(Vector3(center.x, 0, center.y), 22.0, instant) +func focus_enemy_base(instant: bool = false) -> void: + var core: Vector2 = match_state.map.core_position(1 - _own_team()) + camera.look_at_point(Vector3(core.x, 0, core.y), 26.0, instant) + +## Camera jumps requested by the HUD (boss card, event feed, banners). +func focus_target(target: String) -> void: + match target: + "grotto": focus_grotto() + "base": focus_base() + "enemy_base": focus_enemy_base() + "hero": focus_hero() + _: + if LuneMap.LANES.has(target): + focus_lane(target) + ## The own hero: its planning preview or, in combat, the live unit. func own_hero_unit() -> LuneUnit: if human_slot < 0: @@ -304,8 +323,39 @@ func _after_combat() -> void: var input_blocked: Callable = func() -> bool: return false func _unhandled_input(event: InputEvent) -> void: - if not input_blocked.call() and camera.handle_world_input(event): + if input_blocked.call(): + return + if camera.handle_world_input(event): get_viewport().set_input_as_handled() + elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + # Left click in the field selects an own unit (details in the HUD); it never orders anything. + var picked: LuneUnit = pick_own_unit(event.position) + if picked != null and picked.kind == "hero": + hud.open_hero() + elif picked != null: + hud.select_unit(picked.persistent_uid) + elif not hud.selected_uid.is_empty(): + hud.clear_selection() + get_viewport().set_input_as_handled() + +## The own troop or hero whose figure is nearest to a screen point (within 34 px), or null. +func pick_own_unit(screen: Vector2) -> LuneUnit: + if human_slot < 0: + return null + var best: LuneUnit = null + var best_distance: float = 34.0 + for view: Node3D in _views.values(): + var unit: LuneUnit = view.unit + if unit == null or not unit.alive or unit.owner_slot != human_slot or not (unit.kind in ["troop", "hero"]) or not view.visible: + continue + var at: Vector3 = view.aim_point() + if camera.is_position_behind(at): + continue + var distance: float = camera.unproject_position(at).distance_to(screen) + if distance < best_distance: + best_distance = distance + best = unit + return best ## Game keys run before the GUI so Space/Tab keep their meaning even when a button has focus. func _input(event: InputEvent) -> void: @@ -315,7 +365,9 @@ func _input(event: InputEvent) -> void: match event.keycode: KEY_SPACE: if match_state.phase == LuneMatch.Phase.PLANNING: - _on_ready() + # A Space meant as pause at the very end of a combat must not skip the planning. + if not hud.ready_blocked(): + _on_ready() else: _toggle_pause() KEY_P: @@ -343,8 +395,12 @@ func _input(event: InputEvent) -> void: camera.overview() KEY_1, KEY_2, KEY_3, KEY_4, KEY_5: if match_state.phase == LuneMatch.Phase.PLANNING and human_slot >= 0: - var ids: Array[String] = match_state.base_ids(match_state.players[human_slot].bund) - _on_command({"type": "buy", "type_id": ids[event.keycode - KEY_1]}) + if bool(match_state.players[human_slot].ready): + # After Ready the plan is binding (the host refuses); the keys do nothing. + hud.notify("Bereit gemeldet – die Planung ist verbindlich.", true) + else: + var ids: Array[String] = match_state.base_ids(match_state.players[human_slot].bund) + _on_command({"type": "buy", "type_id": ids[event.keycode - KEY_1]}) _: handled = false if handled: diff --git a/scripts/luneraria/ui/components/army_chip.gd b/scripts/luneraria/ui/components/army_chip.gd new file mode 100644 index 0000000..571df90 --- /dev/null +++ b/scripts/luneraria/ui/components/army_chip.gd @@ -0,0 +1,51 @@ +class_name LuneArmyChip +extends Button +## One own troop in the army strip: real-model portrait, a band with its lane (T, M, B or G) +## and stage (II for upgraded troops). Selected = pressed style. In combat a fallen troop is +## dimmed and crossed out. Size adapts to how many troops the strip must hold. + +const BAND: float = 15.0 + +var uid: String = "" +var type_id: String = "" +var lane: String = "mid" +var fallen: bool = false: + set(value): + if value != fallen: + fallen = value + portrait.dimmed = value + portrait.crossed = value +var portrait: LunePortraitRect + +func _init() -> void: + theme_type_variation = "HudChip" + toggle_mode = true + focus_mode = Control.FOCUS_ALL + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + portrait = LunePortraitRect.new() + add_child(portrait) + resized.connect(_place) + +func setup(entry: Dictionary, edge: float) -> void: + uid = str(entry.uid) + type_id = str(entry.type_id) + lane = str(entry.lane) + custom_minimum_size = Vector2(edge, edge + BAND) + portrait.show_model(type_id) + _place() + queue_redraw() + +func _place() -> void: + portrait.position = Vector2(2, 2) + portrait.size = Vector2(size.x - 4.0, size.y - BAND - 2.0) + +func _draw() -> void: + var band := Rect2(Vector2(2, size.y - BAND - 1.0), Vector2(size.x - 4.0, BAND)) + draw_rect(band, Color(0, 0, 0, .55)) + var font: Font = get_theme_font("font", "HudCaps") + var grotto: bool = lane == LuneMatch.GROTTO + var letter: String = "G" if grotto else str(LuneMap.LANE_NAMES.get(lane, "?")).left(1) + var size_px: int = 13 + draw_string(font, Vector2(5, size.y - 5.0), letter, HORIZONTAL_ALIGNMENT_LEFT, -1, size_px, LuneUiTheme.TEAM[2] if grotto else LuneUiTheme.BRONZE_LIGHT) + if LuneHudModel.stage_of(type_id) == 2: + draw_string(font, Vector2(size.x - 20.0, size.y - 5.0), "II", HORIZONTAL_ALIGNMENT_LEFT, -1, size_px, LuneUiTheme.GOLD) diff --git a/scripts/luneraria/ui/components/army_chip.gd.uid b/scripts/luneraria/ui/components/army_chip.gd.uid new file mode 100644 index 0000000..67da0d0 --- /dev/null +++ b/scripts/luneraria/ui/components/army_chip.gd.uid @@ -0,0 +1 @@ +uid://bug8bsj664xie diff --git a/scripts/luneraria/ui/components/boss_indicator.gd b/scripts/luneraria/ui/components/boss_indicator.gd new file mode 100644 index 0000000..ee0fc1b --- /dev/null +++ b/scripts/luneraria/ui/components/boss_indicator.gd @@ -0,0 +1,166 @@ +class_name LuneBossIndicator +extends PanelContainer +## The boss grotto and the Schlundkrone at a glance: BOSS VERFÜGBAR, BOSS UMKÄMPFT, BOSS +## EROBERT, EINSATZ NÄCHSTE WELLE, BOSS GREIFT AN, COOLDOWN – X RUNDEN. When a team owns the +## boss the card names target lane, deployment wave and the bounty the defenders earn; the +## player who steers the boss chooses its lane here. A click on the card moves the camera. + +signal focus_requested +signal lane_chosen(lane: String) + +var data: Dictionary = {} +var portrait: LunePortraitRect +var _badge: LuneStatusBadge +var _line: Label +var _risk: Label +var _bar: LuneHpBar +var _choice: HBoxContainer +var _lane_buttons: Dictionary = {} +var _owner: LuneGlyph +var _signature: String = "" +var compact: bool = false +var _own_team: int = 0 +var _round: int = 0 +var _planning: bool = true + +func _init() -> void: + theme_type_variation = "HudPanelTight" + mouse_filter = Control.MOUSE_FILTER_STOP + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(row) + portrait = LunePortraitRect.new() + portrait.custom_minimum_size = Vector2(64, 64) + portrait.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + row.add_child(portrait) + _owner = LuneGlyph.make("emblem_2", LuneUiTheme.TEAM[2], 18.0, portrait) + _owner.position = Vector2(2, 44) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 3) + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + column.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(column) + var head := HBoxContainer.new() + head.add_theme_constant_override("separation", 6) + head.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(head) + LuneGlyph.make("grotto", LuneUiTheme.TEAM[2], 15.0, head) + LuneUiTheme.label("BOSSGROTTE · G", "HudCaps", head) + _badge = LuneStatusBadge.new() + column.add_child(_badge) + _badge.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _line = LuneUiTheme.label("", "HudText", column) + _line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _risk = LuneUiTheme.label("", "HudMuted", column) + _risk.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _bar = LuneHpBar.new() + _bar.custom_minimum_size = Vector2(0, 5) + _bar.visible = false + column.add_child(_bar) + _choice = HBoxContainer.new() + _choice.add_theme_constant_override("separation", 4) + _choice.visible = false + column.add_child(_choice) + for lane: String in LuneMap.LANES: + var button := LuneUiTheme.button(LuneMap.LANE_NAMES[lane].to_upper(), "HudToggle", _choice) + button.toggle_mode = true + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.pressed.connect(func() -> void: lane_chosen.emit(lane)) + _lane_buttons[lane] = button + LuneTooltip.attach(self, _tip) + +## Short form while the right column shows a selected unit: badge only. +func set_compact(value: bool) -> void: + if value == compact: + return + compact = value + portrait.custom_minimum_size = Vector2(40, 40) if value else Vector2(64, 64) + _signature = "" + if not data.is_empty(): + apply(data, _own_team, _round, _planning) + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + focus_requested.emit() + accept_event() + +func apply(boss: Dictionary, own_team: int, round_number: int, planning: bool) -> void: + var signature: String = "%s|%d|%d|%s" % [str(boss), own_team, round_number, planning] + if signature == _signature: + return + _signature = signature + data = boss + _own_team = own_team + _round = round_number + _planning = planning + portrait.show_model("schlundkrone") + var owner: int = int(boss.team) + var own: bool = owner == own_team + var lane_text: String = LuneMap.LANE_NAMES.get(str(boss.lane), "?").to_upper() + var side: String = LuneUiTheme.team_name(owner) + _owner.visible = owner >= 0 + _owner.glyph = "emblem_%d" % clampi(owner, 0, 2) + _owner.color = LuneUiTheme.team_color(owner) + _bar.visible = false + _choice.visible = false + _risk.text = "" + match str(boss.state): + "available": + _badge.set_state("BOSS VERFÜGBAR", LuneUiTheme.TEAM[2], "crown") + _line.text = "Einheiten mit „Grotte“ entsenden. Wer den Boss besiegt, schickt ihn in der Folgewelle gegen den Gegner." + "contested": + _badge.set_state("BOSS UMKÄMPFT", LuneUiTheme.WARN, "melee") + _line.text = "Expeditionen kämpfen in der Grotte; sie fehlen ihren Lanes bis Kampfende." + "conquered": + _badge.set_state("BOSS EROBERT · %s" % side.to_upper(), LuneUiTheme.team_color(owner), "crown", true) + _line.text = "Einsatz in Welle %d für %s." % [int(boss.deploy_round), "dein Team" if own else side] + "failed": + _badge.set_state("EXPEDITION GESCHEITERT", LuneUiTheme.MUTED, "close") + _line.text = "Der Boss hat überlebt; niemand erhält ihn." + "deploy_next": + _badge.set_state("EINSATZ NÄCHSTE WELLE", LuneUiTheme.team_color(owner) if own else LuneUiTheme.DANGER, "crown", true) + if own: + _line.text = "Deine Schlundkrone greift in Welle %d auf %s an." % [int(boss.deploy_round), lane_text] + _risk.text = "Fällt sie, erhält %s %s Gold Kopfgeld." % [LuneUiTheme.team_name(1 - own_team), LuneUiTheme.number(boss.bounty)] + else: + _line.text = "%s: Schlundkrone greift in Welle %d auf %s an." % [side, int(boss.deploy_round), lane_text] + _risk.text = "Besiegt ihr sie, erhält dein Team %s Gold Kopfgeld." % LuneUiTheme.number(boss.bounty) + if bool(boss.chooser) and planning: + _choice.visible = true + for lane: String in _lane_buttons: + (_lane_buttons[lane] as Button).set_pressed_no_signal(lane == str(boss.lane)) + _risk.text += " Du wählst die Lane:" + "deploying": + _badge.set_state("BOSS GREIFT AN · %s" % lane_text, LuneUiTheme.team_color(owner) if own else LuneUiTheme.DANGER, "warning", true) + _line.text = ("Deine Schlundkrone greift %s an." if own else "Gegnerische Schlundkrone auf %s!") % lane_text + _risk.text = "Kopfgeld für die Verteidiger: %s Gold." % LuneUiTheme.number(boss.bounty) + if boss.has("hp"): + _bar.visible = true + _bar.fraction = float(boss.hp) / maxf(1.0, float(boss.max_hp)) + _bar.fill = LuneUiTheme.team_color(owner) + "defeated": + _badge.set_state("SCHLUNDKRONE BESIEGT", LuneUiTheme.GOOD if not own else LuneUiTheme.MUTED, "check") + _line.text = "Das Kopfgeld von %s Gold geht an %s." % [LuneUiTheme.number(boss.bounty), "den Gegner" if own else "dein Team"] + _: + var left: int = int(boss.rounds_left) + _badge.set_state("COOLDOWN – %d %s" % [left, "RUNDE" if left == 1 else "RUNDEN"], LuneUiTheme.MUTED, "clock") + _line.text = "Grotte wieder offen ab Welle %d." % int(boss.next_round) + _risk.visible = not _risk.text.is_empty() and not compact + _line.visible = not compact + _owner.visible = _owner.visible and not compact + if compact: + _choice.visible = false + if LuneTooltip.current != null: + LuneTooltip.current.refresh(self) + +## Live boss HP during its attack, without rebuilding the texts. +func set_boss_hp(fraction: float) -> void: + if _bar.visible: + _bar.fraction = fraction + +func _tip() -> Dictionary: + return {"title": "Bossgrotte · Schlundkrone", "glyph": "crown", "glyph_color": LuneUiTheme.TEAM[2], + "text": "Expeditionen kämpfen zuerst gegeneinander; der Boss erwacht, wenn höchstens ein Team übrig ist. Sieg: Der Boss greift in der Folgewelle für die Sieger an, die Verteidiger erhalten bei seinem Fall ein hohes Kopfgeld. Danach ist die Grotte %d Runden gesperrt." % LuneContent.BOSS_LOCK_ROUNDS, + "note": "Klick oder G: Kamera zur Grotte"} diff --git a/scripts/luneraria/ui/components/boss_indicator.gd.uid b/scripts/luneraria/ui/components/boss_indicator.gd.uid new file mode 100644 index 0000000..6b05661 --- /dev/null +++ b/scripts/luneraria/ui/components/boss_indicator.gd.uid @@ -0,0 +1 @@ +uid://dgdrwfx21xaxc diff --git a/scripts/luneraria/ui/components/champion_panel.gd b/scripts/luneraria/ui/components/champion_panel.gd new file mode 100644 index 0000000..8520b18 --- /dev/null +++ b/scripts/luneraria/ui/components/champion_panel.gd @@ -0,0 +1,119 @@ +class_name LuneChampionPanel +extends HBoxContainer +## Attack champions: target lane, the three champion cards (copper, hatched, "1 WELLE") and +## the own bookings ("Einsatz in Welle 15 – MID"). Champions are bought in planning n, fight +## once in wave n+1 and then vanish; they never join the permanent army. + +signal command_requested(command: Dictionary) + +var target_lane: String = "mid" +var cards: Dictionary = {} +var _lane_buttons: Dictionary = {} +var _bookings: VBoxContainer +var _count: Label +var _signature: String = "" + +func _init() -> void: + add_theme_constant_override("separation", 10) + var lanes := VBoxContainer.new() + lanes.add_theme_constant_override("separation", 4) + add_child(lanes) + LuneUiTheme.label("ZIEL", "HudCaps", lanes) + for lane: String in LuneMap.LANES: + var button := LuneUiTheme.button(LuneMap.LANE_NAMES[lane].to_upper(), "HudToggle", lanes) + button.toggle_mode = true + button.custom_minimum_size = Vector2(86, 0) + button.set_pressed_no_signal(lane == target_lane) + button.pressed.connect(func() -> void: + target_lane = lane + _signature = "" + for other: String in _lane_buttons: + (_lane_buttons[other] as Button).set_pressed_no_signal(other == lane)) + _lane_buttons[lane] = button + LuneTooltip.attach(button, func() -> Dictionary: return {"title": "Ziel-Lane %s" % LuneMap.LANE_NAMES[lane], "text": "Gebuchte Champions greifen in der nächsten Welle auf dieser Lane an."}) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + add_child(row) + for champion_id: String in LuneContent.CHAMPIONS: + var spec: Dictionary = LuneContent.CHAMPIONS[champion_id] + var card := LuneUnitCard.new() + row.add_child(card) + var role_key: String = {"ch_schutz": "schutz", "ch_druck": "druck", "ch_belagerung": "siege"}.get(champion_id, "melee") + card.setup(champion_id, str(spec.model), str(spec.name), role_key, int(spec.cost), "", true) + card.pressed.connect(func() -> void: command_requested.emit({"type": "champion_book", "champion_id": champion_id, "lane": target_lane})) + cards[champion_id] = card + LuneTooltip.attach(card, _tip.bind(champion_id)) + var right := VBoxContainer.new() + right.add_theme_constant_override("separation", 4) + right.size_flags_horizontal = Control.SIZE_EXPAND_FILL + right.custom_minimum_size.x = 200 + add_child(right) + var head := HBoxContainer.new() + right.add_child(head) + LuneUiTheme.label("EINSÄTZE", "HudCaps", head).size_flags_horizontal = Control.SIZE_EXPAND_FILL + _count = LuneUiTheme.label("", "HudMuted", head) + _bookings = VBoxContainer.new() + _bookings.add_theme_constant_override("separation", 3) + right.add_child(_bookings) + +var _model: LuneHudModel + +func apply(model: LuneHudModel) -> void: + _model = model + var bookings: Array = model.own_bookings() + var fresh: int = (model.me().draft.new_bookings as Array).size() if model.planning() else 0 + var gold: int = model.own_gold() + var signature: String = "%s|%d|%s|%s|%s|%d" % [str(bookings), gold, model.planning(), model.locked(), target_lane, model.state.round_number] + if signature == _signature: + return + _signature = signature + var can_act: bool = model.planning() and not model.locked() + for champion_id: String in cards: + var card: LuneUnitCard = cards[champion_id] + var cost: int = int(LuneContent.CHAMPIONS[champion_id].cost) + var why: String = "" + if not model.planning(): + why = "Buchungen nur in der Planung." + elif model.locked(): + why = "Bereit gemeldet – die Planung ist verbindlich." + elif fresh >= LuneContent.MAX_BOOKINGS: + why = "Höchstens %d Champions pro Runde." % LuneContent.MAX_BOOKINGS + elif gold < cost: + why = "Es fehlen %d Gold." % (cost - gold) + card.set_available(why.is_empty(), why, not can_act, gold < cost) + _count.text = "%d/%d diese Runde" % [fresh, LuneContent.MAX_BOOKINGS] + for child: Node in _bookings.get_children(): + child.free() + if bookings.is_empty(): + var hint := LuneUiTheme.label("Keine Champions gebucht. Kauf jetzt – Einsatz in Welle %d." % (model.state.round_number + (1 if model.planning() else 2)), "HudMuted", _bookings) + hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + return + for booking: Dictionary in bookings: + var line := HBoxContainer.new() + line.add_theme_constant_override("separation", 6) + _bookings.add_child(line) + LuneGlyph.make("hourglass", LuneUiTheme.CHAMPION, 14.0, line) + var running: bool = int(booking.deploy_round) == model.state.round_number + var texts := VBoxContainer.new() + texts.add_theme_constant_override("separation", -2) + texts.size_flags_horizontal = Control.SIZE_EXPAND_FILL + line.add_child(texts) + LuneUiTheme.label(str(LuneContent.CHAMPIONS[booking.champion_id].name), "HudText", texts) + LuneUiTheme.label("Einsatz in Welle %d – %s%s" % [int(booking.deploy_round), LuneMap.LANE_NAMES[booking.lane].to_upper(), " (jetzt)" if running else ""], "HudMuted", texts) + if bool(booking.get("fresh", false)): + var cancel := LuneUiTheme.button("×", "HudIconButton", line) + cancel.disabled = not can_act + var uid: String = str(booking.uid) + cancel.pressed.connect(func() -> void: command_requested.emit({"type": "champion_cancel", "uid": uid})) + LuneTooltip.attach(cancel, func() -> Dictionary: return {"title": "Buchung zurücknehmen", "text": "Volle Erstattung, solange die Planung läuft."}) + +func _tip(champion_id: String) -> Dictionary: + var spec: Dictionary = LuneContent.CHAMPIONS[champion_id] + var round_number: int = _model.state.round_number + 1 if _model != null else 2 + var definition: Dictionary = LuneContent.champion_definition(champion_id, round_number, _model.state.balance if _model != null else LuneBalance.default_balance()) + var card: LuneUnitCard = cards[champion_id] + return {"title": "%s · Angriffschampion" % spec.name, "glyph": "champion", "glyph_color": LuneUiTheme.CHAMPION, + "subtitle": "%s · %d Gold · Einsatz in Welle %d – %s" % [spec.role, int(spec.cost), round_number, LuneMap.LANE_NAMES[target_lane].to_upper()], + "stats": LuneStats.rows(definition), "text": str(spec.text), + "note": "Einmaliger Einsatz: kämpft eine Welle lang und verschwindet danach. Die Ankündigung ist für alle sichtbar; sein Tod bringt dem Gegner %d Gold." % int(definition.bounty), + "warn": card.reason} diff --git a/scripts/luneraria/ui/components/champion_panel.gd.uid b/scripts/luneraria/ui/components/champion_panel.gd.uid new file mode 100644 index 0000000..e71df72 --- /dev/null +++ b/scripts/luneraria/ui/components/champion_panel.gd.uid @@ -0,0 +1 @@ +uid://8h25mipeosry diff --git a/scripts/luneraria/ui/components/event_feed.gd b/scripts/luneraria/ui/components/event_feed.gd new file mode 100644 index 0000000..33bea7d --- /dev/null +++ b/scripts/luneraria/ui/components/event_feed.gd @@ -0,0 +1,72 @@ +class_name LuneEventFeed +extends VBoxContainer +## Short event list: tower falls, champions, boss, critical Ancients, round results. At most +## five entries, newest on top; each fades after a while. A click on an entry with a place +## moves the camera there. It never lists every kill. + +signal focus_requested(target: Dictionary) + +const LIMIT: int = 5 +const LIFETIME: float = 14.0 +const LEVEL_COLORS: Dictionary = {"info": LuneUiTheme.INFO, "good": LuneUiTheme.GOOD, "warn": LuneUiTheme.WARN, "danger": LuneUiTheme.DANGER} + +var entries: Array[Dictionary] = [] +var _clock: float = 0.0 + +func _init() -> void: + add_theme_constant_override("separation", 3) + mouse_filter = Control.MOUSE_FILTER_IGNORE + +## level: info, good, warn, danger. target: {} or {"lane": …} / {"point": Vector2} / {"camera": name}. +func push(text_value: String, level: String = "info", glyph: String = "", target: Dictionary = {}) -> void: + for entry: Dictionary in entries: + if entry.text == text_value and _clock - float(entry.born) < 4.0: + return + var button := Button.new() + button.theme_type_variation = "HudRow" + button.focus_mode = Control.FOCUS_NONE + button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if not target.is_empty() else Control.CURSOR_ARROW + button.custom_minimum_size = Vector2(0, 30) + var row := HBoxContainer.new() + row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + row.offset_left = 8 + row.offset_right = -8 + row.add_theme_constant_override("separation", 7) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + button.add_child(row) + var tint: Color = LEVEL_COLORS.get(level, LuneUiTheme.INFO) + LuneGlyph.make(glyph if not glyph.is_empty() else "star", tint, 15.0, row) + var label := LuneUiTheme.label(text_value, "HudText", row) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + label.clip_text = true + label.add_theme_color_override("font_color", tint.lerp(LuneUiTheme.TEXT, .45)) + button.pressed.connect(func() -> void: + if not target.is_empty(): + focus_requested.emit(target)) + LuneTooltip.attach(button, func() -> Dictionary: return {"title": text_value, "note": "Klick: Kamera dorthin" if not target.is_empty() else ""}) + add_child(button) + move_child(button, 0) + entries.push_front({"text": text_value, "level": level, "born": _clock, "node": button}) + while entries.size() > LIMIT: + var old: Dictionary = entries.pop_back() + (old.node as Node).queue_free() + visible = true + +func clear_all() -> void: + for entry: Dictionary in entries: + (entry.node as Node).queue_free() + entries.clear() + +func _process(delta: float) -> void: + _clock += delta + for index: int in range(entries.size() - 1, -1, -1): + var entry: Dictionary = entries[index] + var age: float = _clock - float(entry.born) + var node: Control = entry.node + if age > LIFETIME: + node.queue_free() + entries.remove_at(index) + elif age > LIFETIME - 2.0: + node.modulate.a = clampf((LIFETIME - age) / 2.0, 0.0, 1.0) + visible = not entries.is_empty() diff --git a/scripts/luneraria/ui/components/event_feed.gd.uid b/scripts/luneraria/ui/components/event_feed.gd.uid new file mode 100644 index 0000000..23f3598 --- /dev/null +++ b/scripts/luneraria/ui/components/event_feed.gd.uid @@ -0,0 +1 @@ +uid://rswjv3w8uesi diff --git a/scripts/luneraria/ui/components/hero_card.gd b/scripts/luneraria/ui/components/hero_card.gd new file mode 100644 index 0000000..209e124 --- /dev/null +++ b/scripts/luneraria/ui/components/hero_card.gd @@ -0,0 +1,207 @@ +class_name LuneHeroCard +extends Button +## The hero in the bottom bar: portrait, name, level, experience progress, HP, role, the +## three item slots and the talent state. A click opens the hero window (details, items, +## talents) or, without a hero, the recruiting window. No MOBA action bar: heroes act alone. + +var hero_id: String = "" +var _portrait: LunePortraitRect +var _name: Label +var _level: Label +var _role_glyph: LuneGlyph +var _role: Label +var _xp: LuneHpBar +var _xp_text: Label +var _hp_glyph: LuneGlyph +var _hp: Label +var _hp_bar: LuneHpBar +var _slots: Array[LuneGlyph] = [] +var _slot_boxes: Array[PanelContainer] = [] +var _talent: LuneStatusBadge +var _empty: VBoxContainer +var _filled: VBoxContainer +var _faces: Array[LunePortraitRect] = [] +var data: Dictionary = {} +var _signature: String = "" +var _empty_title: Label +var _empty_price: Label +var _empty_coin: LuneGlyph +var _faces_row: HBoxContainer + +func _init() -> void: + theme_type_variation = "HudCard" + focus_mode = Control.FOCUS_ALL + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + custom_minimum_size = Vector2(320, 132) + var row := HBoxContainer.new() + row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + row.offset_left = 8 + row.offset_right = -8 + row.offset_top = 8 + row.offset_bottom = -8 + row.add_theme_constant_override("separation", 10) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(row) + _portrait = LunePortraitRect.new() + _portrait.custom_minimum_size = Vector2(88, 0) + row.add_child(_portrait) + _filled = VBoxContainer.new() + _filled.add_theme_constant_override("separation", 2) + _filled.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _filled.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(_filled) + _name = LuneUiTheme.label("", "HudName", _filled) + _name.clip_text = true + _name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + var role_row := HBoxContainer.new() + role_row.add_theme_constant_override("separation", 5) + role_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + _filled.add_child(role_row) + LuneGlyph.make("star", LuneUiTheme.HERO, 13.0, role_row) + _level = LuneUiTheme.label("", "HudValue", role_row) + _role_glyph = LuneGlyph.make("tank", LuneUiTheme.MUTED, 14.0, role_row) + _role = LuneUiTheme.label("", "HudMuted", role_row) + var xp_row := HBoxContainer.new() + xp_row.add_theme_constant_override("separation", 6) + xp_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + _filled.add_child(xp_row) + _xp = LuneHpBar.new() + _xp.custom_minimum_size = Vector2(60, 6) + _xp.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _xp.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _xp.fill = LuneUiTheme.BRONZE_LIGHT + xp_row.add_child(_xp) + _xp_text = LuneUiTheme.label("", "HudMuted", xp_row) + var hp_row := HBoxContainer.new() + hp_row.add_theme_constant_override("separation", 5) + hp_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + _filled.add_child(hp_row) + _hp_glyph = LuneGlyph.make("heart", LuneUiTheme.DANGER.lightened(.2), 13.0, hp_row) + _hp = LuneUiTheme.label("", "HudText", hp_row) + _hp_bar = LuneHpBar.new() + _hp_bar.custom_minimum_size = Vector2(50, 5) + _hp_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _hp_bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER + hp_row.add_child(_hp_bar) + var item_row := HBoxContainer.new() + item_row.add_theme_constant_override("separation", 4) + item_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + _filled.add_child(item_row) + for index: int in range(LuneContent.ITEM_SLOTS): + var box := PanelContainer.new() + box.add_theme_stylebox_override("panel", LuneUiTheme.slot_style()) + box.custom_minimum_size = Vector2(26, 26) + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + item_row.add_child(box) + var glyph := LuneGlyph.new() + glyph.custom_minimum_size = Vector2(18, 18) + box.add_child(glyph) + _slots.append(glyph) + _slot_boxes.append(box) + _talent = LuneStatusBadge.new() + item_row.add_child(_talent) + _empty = VBoxContainer.new() + _empty.add_theme_constant_override("separation", 3) + _empty.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _empty.alignment = BoxContainer.ALIGNMENT_CENTER + _empty.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(_empty) + _empty_title = LuneUiTheme.label("HELD ANWERBEN", "HudName", _empty) + var faces := HBoxContainer.new() + _faces_row = faces + faces.add_theme_constant_override("separation", 4) + faces.mouse_filter = Control.MOUSE_FILTER_IGNORE + _empty.add_child(faces) + for hero: String in LuneContent.HEROES: + var face := LunePortraitRect.new() + face.custom_minimum_size = Vector2(38, 38) + faces.add_child(face) + _faces.append(face) + var price := HBoxContainer.new() + price.add_theme_constant_override("separation", 4) + price.mouse_filter = Control.MOUSE_FILTER_IGNORE + _empty.add_child(price) + _empty_coin = LuneGlyph.make("coin", LuneUiTheme.GOLD, 14.0, price) + _empty_price = LuneUiTheme.label("%d · ein Held pro Spieler" % int(LuneContent.HEROES.values()[0].cost), "HudMuted", price) + LuneTooltip.attach(self, _tip) + +## hero: own hero dictionary (draft in planning) or {}; live: the combat unit or null. +func apply(hero: Dictionary, model: LuneHudModel, live: LuneUnit) -> void: + var has_hero: bool = not hero.is_empty() + if live != null and has_hero: + _hp.text = ("%s / %s" % [LuneUiTheme.number(live.hp), LuneUiTheme.number(live.max_hp)]) if live.alive else "gefallen" + _hp_bar.fraction = live.hp / maxf(1.0, live.max_hp) if live.alive else 0.0 + _hp_bar.fill = LuneHpBar.health_color(_hp_bar.fraction, LuneUiTheme.GOOD) + _portrait.crossed = not live.alive + var signature: String = "%s|%s|%d|%s" % [str(hero), live != null, model.own_gold(), model.planning()] + if signature == _signature: + return + _signature = signature + _filled.visible = has_hero + _empty.visible = not has_hero + if not has_hero: + hero_id = "" + data = {} + _portrait.show_model("w04") + _portrait.dimmed = true + var index: int = 0 + for id: String in LuneContent.HEROES: + _faces[index].show_model(str(LuneContent.HEROES[id].model)) + index += 1 + # In combat nobody can recruit: say so instead of offering it. + var planning: bool = model.planning() + _empty_title.text = "HELD ANWERBEN" if planning else "KEIN HELD" + _empty_price.text = ("%d · ein Held pro Spieler" % int(LuneContent.HEROES.values()[0].cost)) if planning else "Anwerben in der Planung" + _empty_coin.visible = planning + _faces_row.visible = planning + return + hero_id = str(hero.hero_id) + var spec: Dictionary = LuneContent.HEROES[hero_id] + var definition: Dictionary = model.hero_definition(hero) + data = {"hero": hero, "definition": definition} + _portrait.dimmed = false + _portrait.crossed = live != null and not live.alive + _portrait.show_model(str(spec.model)) + _name.text = str(spec.name) + var xp: int = int(hero.xp) + var level: int = LuneContent.level_for(xp) + _level.text = "STUFE %d" % level + _role_glyph.glyph = LuneStats.role_glyph(str(definition.role)) + _role.text = LuneStats.role_name(str(definition.role)) + var low: int = LuneContent.LEVEL_XP[level - 1] + var top: bool = level >= LuneContent.LEVEL_XP.size() + var high: int = LuneContent.LEVEL_XP[mini(level, LuneContent.LEVEL_XP.size() - 1)] + _xp.fraction = 1.0 if top else float(xp - low) / maxf(1.0, float(high - low)) + _xp_text.text = "EP %d / %d" % [xp, high] if not top else "EP %d · max" % xp + _hp_bar.visible = live != null + if live == null: + _hp.text = "%s Leben" % LuneUiTheme.number(definition.max_hp) + var items: Array = hero.items + for index: int in range(_slots.size()): + var item_id: String = str(items[index]) if index < items.size() else "" + _slots[index].glyph = item_id + _slots[index].color = LuneItemSlot.color_of(item_id) + var talents: Array = hero.talents + var open_tier: int = talents.size() + if open_tier < LuneContent.TALENT_LEVELS.size() and level >= LuneContent.TALENT_LEVELS[open_tier]: + _talent.set_state("TALENT!", LuneUiTheme.HERO, "star", true) + _talent.visible = model.planning() + elif open_tier < LuneContent.TALENT_LEVELS.size(): + _talent.set_state("TALENT AB %d" % LuneContent.TALENT_LEVELS[open_tier], LuneUiTheme.MUTED) + _talent.visible = true + else: + _talent.visible = false + +func _tip() -> Dictionary: + if data.is_empty(): + return {"title": "Held anwerben", "glyph": "crown", "glyph_color": LuneUiTheme.HERO, + "text": "Drei Helden stehen zur Wahl; jeder Spieler führt einen. Helden sammeln Erfahrung, steigen bis Stufe 10 auf, tragen drei Items und wählen zwei Talente.", + "note": "Klick: Heldenauswahl öffnen"} + var hero: Dictionary = data.hero + var spec: Dictionary = LuneContent.HEROES[hero.hero_id] + var names: PackedStringArray = [] + for item_id: String in hero.items: + names.append(LuneContent.ITEMS[item_id].name) + return {"title": "%s · Stufe %d" % [spec.name, LuneContent.level_for(int(hero.xp))], "glyph": "crown", "glyph_color": LuneUiTheme.HERO, + "subtitle": str(spec.title), "stats": LuneStats.rows(data.definition), + "text": "Items: " + (", ".join(names) if not names.is_empty() else "keine"), "note": "Klick: Werte, Items und Talente · H: Kamera zum Helden"} diff --git a/scripts/luneraria/ui/components/hero_card.gd.uid b/scripts/luneraria/ui/components/hero_card.gd.uid new file mode 100644 index 0000000..98e7f0c --- /dev/null +++ b/scripts/luneraria/ui/components/hero_card.gd.uid @@ -0,0 +1 @@ +uid://cfy1k2rm6vc7j diff --git a/scripts/luneraria/ui/components/hero_detail.gd b/scripts/luneraria/ui/components/hero_detail.gd new file mode 100644 index 0000000..3620629 --- /dev/null +++ b/scripts/luneraria/ui/components/hero_detail.gd @@ -0,0 +1,347 @@ +class_name LuneHeroDetail +extends VBoxContainer +## Hero window content. Without a hero: the three heroes with portrait, role, real stats and +## price to recruit one. With a hero: portrait, level and experience, lane order, and tabs for +## values and ability (from the rules' definitions, including level, items and talents), the +## three item slots with the item shop, and the two talent tiers. Only shows data; every +## change is a command through the game. + +signal command_requested(command: Dictionary) + +const TAB_VALUES: int = 0 +const TAB_ITEMS: int = 1 +const TAB_TALENTS: int = 2 + +var tab: int = TAB_VALUES +var _model: LuneHudModel +var _signature: String = "" +var item_cards: Dictionary = {} +var recruit_buttons: Dictionary = {} +var lane_buttons: Dictionary = {} +var tabs: LuneTabBar + +func _init() -> void: + add_theme_constant_override("separation", 10) + size_flags_vertical = Control.SIZE_EXPAND_FILL + +func show_tab(index: int) -> void: + tab = index + _signature = "" + if _model != null: + apply(_model) + +func title_text(model: LuneHudModel) -> String: + var hero: Dictionary = model.own_hero() + if hero.is_empty(): + return "HELD ANWERBEN" + return "HELD · %s" % str(LuneContent.HEROES[hero.hero_id].name).to_upper() + +func apply(model: LuneHudModel) -> void: + _model = model + var hero: Dictionary = model.own_hero() + var signature: String = "%s|%d|%s|%s|%d|%s|%d" % [str(hero), model.own_gold(), model.planning(), model.locked(), tab, model.state.grotto_available(), model.state.round_number] + if signature == _signature: + return + _signature = signature + for child: Node in get_children(): + child.free() + item_cards.clear() + recruit_buttons.clear() + lane_buttons.clear() + if hero.is_empty(): + _recruit(model) + else: + _hero(model, hero) + +func _can_act(model: LuneHudModel) -> bool: + return model.planning() and not model.locked() + +func _why_not(model: LuneHudModel) -> String: + if not model.planning(): + return "Nur in der Planung." + if model.locked(): + return "Bereit gemeldet – die Planung ist verbindlich." + return "" + +# ---------------------------------------------------------------- recruiting +func _recruit(model: LuneHudModel) -> void: + LuneUiTheme.label("Jeder Spieler führt einen Helden. Er kämpft auf seiner Lane mit, sammelt Erfahrung aus Gefechten in seiner Nähe und aus jeder Runde, trägt drei Items und wählt ab Stufe 3 und 6 je ein Talent.", "HudMuted", self).autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 12) + row.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(row) + var gold: int = model.own_gold() + for hero_id: String in LuneContent.HEROES: + var spec: Dictionary = LuneContent.HEROES[hero_id] + var panel := PanelContainer.new() + panel.theme_type_variation = "HudPanelTight" + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(panel) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 5) + panel.add_child(column) + var portrait := LunePortraitRect.new() + portrait.custom_minimum_size = Vector2(0, 170) + column.add_child(portrait) + portrait.show_model(str(spec.model), true) + LuneUiTheme.label(str(spec.name), "HudTitle", column) + LuneUiTheme.label(str(spec.title), "HudMuted", column) + var role := HBoxContainer.new() + role.add_theme_constant_override("separation", 5) + column.add_child(role) + var definition: Dictionary = model.hero_definition({"hero_id": hero_id, "xp": 0, "items": [], "talents": []}) + LuneGlyph.make(LuneStats.role_glyph(str(definition.role)), LuneUiTheme.BRONZE_LIGHT, 15.0, role) + LuneUiTheme.label(str(spec.role), "HudText", role) + var grid := GridContainer.new() + grid.columns = 3 + grid.add_theme_constant_override("h_separation", 6) + column.add_child(grid) + var rows: Array = LuneStats.rows(definition) + for entry: Array in rows.slice(0, 5): + LuneGlyph.make(str(entry[0]), LuneUiTheme.MUTED, 13.0, grid) + LuneUiTheme.label(str(entry[1]), "HudMuted", grid) + LuneUiTheme.label(str(entry[2]), "HudText", grid) + var summary := LuneUiTheme.label(str(spec.summary), "HudText", column) + summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + summary.size_flags_vertical = Control.SIZE_EXPAND_FILL + var cost: int = int(spec.cost) + var buy := LuneUiTheme.button("ANWERBEN · %d G" % cost, "HudPrimary", column) + var why: String = _why_not(model) + if why.is_empty() and gold < cost: + why = "Es fehlen %d Gold." % (cost - gold) + buy.disabled = not why.is_empty() + buy.pressed.connect(func() -> void: command_requested.emit({"type": "hero_buy", "hero_id": hero_id})) + recruit_buttons[hero_id] = buy + if not why.is_empty(): + LuneUiTheme.label(why, "HudMuted", column).add_theme_color_override("font_color", LuneUiTheme.DANGER if gold < cost else LuneUiTheme.MUTED) + LuneUiTheme.label("Vorläufige Rolle des vorhandenen Modells.", "HudMuted", column) + +# ---------------------------------------------------------------- hero +func _hero(model: LuneHudModel, hero: Dictionary) -> void: + var spec: Dictionary = LuneContent.HEROES[hero.hero_id] + var definition: Dictionary = model.hero_definition(hero) + var can_act: bool = _can_act(model) + var body := HBoxContainer.new() + body.add_theme_constant_override("separation", 18) + body.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(body) + var left := VBoxContainer.new() + left.add_theme_constant_override("separation", 6) + left.custom_minimum_size.x = 290 + body.add_child(left) + var portrait := LunePortraitRect.new() + portrait.custom_minimum_size = Vector2(290, 230) + left.add_child(portrait) + portrait.show_model(str(spec.model), true) + LuneUiTheme.label(str(spec.title), "HudMuted", left) + var role := HBoxContainer.new() + role.add_theme_constant_override("separation", 6) + left.add_child(role) + LuneGlyph.make(LuneStats.role_glyph(str(definition.role)), LuneUiTheme.BRONZE_LIGHT, 16.0, role) + LuneUiTheme.label(str(spec.role), "HudText", role) + var xp: int = int(hero.xp) + var level: int = LuneContent.level_for(xp) + var level_row := HBoxContainer.new() + level_row.add_theme_constant_override("separation", 6) + left.add_child(level_row) + LuneGlyph.make("star", LuneUiTheme.HERO, 16.0, level_row) + LuneUiTheme.label("STUFE %d / %d" % [level, LuneContent.LEVEL_XP.size()], "HudValue", level_row) + var bar := LuneHpBar.new() + bar.custom_minimum_size = Vector2(0, 8) + bar.fill = LuneUiTheme.BRONZE_LIGHT + left.add_child(bar) + var top: bool = level >= LuneContent.LEVEL_XP.size() + var low: int = LuneContent.LEVEL_XP[level - 1] + var high: int = LuneContent.LEVEL_XP[mini(level, LuneContent.LEVEL_XP.size() - 1)] + bar.fraction = 1.0 if top else float(xp - low) / maxf(1.0, float(high - low)) + LuneUiTheme.label(("%d / %d EP · noch %d bis Stufe %d" % [xp, high, high - xp, level + 1]) if not top else "%d EP · Höchststufe" % xp, "HudMuted", left) + LuneUiTheme.label("EINSATZ", "HudCaps", left) + var lanes := HBoxContainer.new() + lanes.add_theme_constant_override("separation", 4) + left.add_child(lanes) + for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]: + var title: String = "GROTTE" if lane == LuneMatch.GROTTO else LuneMap.LANE_NAMES[lane].to_upper() + var button := LuneUiTheme.button(title, "HudToggle", lanes) + button.toggle_mode = true + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.set_pressed_no_signal(str(hero.lane) == lane) + button.disabled = not can_act or (lane == LuneMatch.GROTTO and not model.state.grotto_available()) + var uid: String = str(hero.uid) + button.pressed.connect(func() -> void: command_requested.emit({"type": "assign", "uid": uid, "lane": lane})) + lane_buttons[lane] = button + if str(hero.lane) == LuneMatch.GROTTO: + var warning := LuneUiTheme.label("Der Held fehlt seiner Lane in der nächsten Kampfphase.", "HudText", left) + warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + warning.add_theme_color_override("font_color", LuneUiTheme.WARN) + if bool(hero.get("fresh", false)) and model.planning(): + var refund: int = int(hero.invested) + for item_id: String in hero.items: + refund += int(LuneContent.ITEMS[item_id].cost) + var cancel := LuneUiTheme.button("Held zurückgeben +%d" % refund, "HudButton", left) + cancel.disabled = not can_act + cancel.pressed.connect(func() -> void: command_requested.emit({"type": "hero_cancel"})) + var right := VBoxContainer.new() + right.add_theme_constant_override("separation", 8) + right.size_flags_horizontal = Control.SIZE_EXPAND_FILL + body.add_child(right) + tabs = LuneTabBar.new() + right.add_child(tabs) + tabs.add_tab("WERTE & FÄHIGKEIT") + tabs.add_tab("ITEMS %d/%d" % [(hero.items as Array).size(), LuneContent.ITEM_SLOTS]) + var open_tier: int = (hero.talents as Array).size() + var talent_ready: bool = open_tier < LuneContent.TALENT_LEVELS.size() and level >= LuneContent.TALENT_LEVELS[open_tier] + tabs.add_tab("TALENTE" + (" ★" if talent_ready else "")) + tabs.select(tab) + tabs.tab_changed.connect(show_tab) + var page := VBoxContainer.new() + page.add_theme_constant_override("separation", 8) + page.size_flags_vertical = Control.SIZE_EXPAND_FILL + right.add_child(page) + match tab: + TAB_ITEMS: + _items(model, hero, level, page) + TAB_TALENTS: + _talents(model, hero, level, page) + _: + _values(hero, definition, spec, page) + +func _values(hero: Dictionary, definition: Dictionary, spec: Dictionary, page: VBoxContainer) -> void: + var grid := GridContainer.new() + grid.columns = 3 + grid.add_theme_constant_override("h_separation", 10) + grid.add_theme_constant_override("v_separation", 3) + page.add_child(grid) + var rows: Array = LuneStats.rows(definition, true) + rows.append(["sword", "Schaden/Sekunde", "≈ %s (ohne Fähigkeit)" % LuneUiTheme.decimal(LuneStats.dps(definition))]) + for entry: Array in rows: + LuneGlyph.make(str(entry[0]), LuneUiTheme.MUTED, 15.0, grid) + LuneUiTheme.label(str(entry[1]), "HudMuted", grid) + LuneUiTheme.label(str(entry[2]), "HudText", grid) + var level: int = int(definition.level) + var base: Dictionary = spec.def + var growth := LuneUiTheme.label("Grundwerte %s Leben, %s Schaden; je Stufe +%d %% Leben und +%d %% Schaden (jetzt Stufe %d)." % [ + LuneUiTheme.number(base.max_hp), LuneUiTheme.decimal(base.damage), int(LuneContent.HP_PER_LEVEL * 100.0), int(LuneContent.DAMAGE_PER_LEVEL * 100.0), level], "HudMuted", page) + growth.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + LuneUiTheme.label("FÄHIGKEIT", "HudCaps", page) + var ability := LuneUiTheme.label(str(DataCatalog.get_unit(str(spec.model)).get("ability_description", "")), "HudText", page) + ability.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + var chosen: PackedStringArray = [] + for tier: int in range((hero.talents as Array).size()): + for option: Dictionary in LuneContent.TALENTS[hero.hero_id][tier]: + if option.id == hero.talents[tier]: + chosen.append("%s: %s" % [option.name, option.text]) + if not chosen.is_empty(): + LuneUiTheme.label("TALENTE", "HudCaps", page) + var talents := LuneUiTheme.label("\n".join(chosen), "HudText", page) + talents.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + var xp := LuneUiTheme.label("Erfahrung: Tode von Gegnern in %d m (Monster 12 + Welle, Champion 30 + 2 × Welle, Gebäude %d, Boss %d) und je Kampf %d + %d × Welle." % [ + int(LuneContent.XP_RADIUS), LuneContent.BUILDING_XP, LuneContent.BOSS_XP, LuneContent.XP_PARTICIPATION, LuneContent.XP_PER_ROUND], "HudMuted", page) + xp.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + +func _items(model: LuneHudModel, hero: Dictionary, level: int, page: VBoxContainer) -> void: + var can_act: bool = _can_act(model) + var items: Array = hero.items + var slots := HBoxContainer.new() + slots.add_theme_constant_override("separation", 10) + page.add_child(slots) + for index: int in range(LuneContent.ITEM_SLOTS): + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 3) + box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + slots.add_child(box) + var slot := LuneItemSlot.new(true) + slot.custom_minimum_size = Vector2(46, 46) + slot.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + box.add_child(slot) + if index < items.size(): + var item_id: String = items[index] + var item: Dictionary = LuneContent.ITEMS[item_id] + slot.set_item(item_id, LuneItemSlot.State.EQUIPPED) + LuneTooltip.attach(slot, func() -> Dictionary: return {"title": str(item.name), "glyph": item_id, "glyph_color": LuneItemSlot.color_of(item_id), "text": str(item.text)}) + LuneUiTheme.label(str(item.name), "HudText", box) + 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) + var sell := LuneUiTheme.button("Verkaufen +%d" % refund, "HudButton", box) + sell.disabled = not can_act + sell.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + sell.pressed.connect(func() -> void: command_requested.emit({"type": "item_sell", "item_id": item_id})) + else: + slot.set_item("", LuneItemSlot.State.EMPTY) + LuneUiTheme.label("Platz %d frei" % (index + 1), "HudMuted", box) + LuneUiTheme.label("ITEMS · einzigartig je Held, bleiben dauerhaft, Rückgabe %d %%" % int(LuneContent.ITEM_REFUND * 100.0), "HudCaps", page) + var grid := GridContainer.new() + grid.columns = 2 + grid.add_theme_constant_override("h_separation", 8) + grid.add_theme_constant_override("v_separation", 8) + page.add_child(grid) + var gold: int = model.own_gold() + for item_id: String in LuneContent.ITEMS: + var item: Dictionary = LuneContent.ITEMS[item_id] + var card := LuneItemSlot.new(false) + card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + grid.add_child(card) + var state: int = LuneItemSlot.State.AVAILABLE + if items.has(item_id): + state = LuneItemSlot.State.UNIQUE + elif not can_act: + state = LuneItemSlot.State.LOCKED + elif level < int(item.get("min_level", 1)): + state = LuneItemSlot.State.INCOMPATIBLE + elif items.size() >= LuneContent.ITEM_SLOTS: + state = LuneItemSlot.State.FULL + elif gold < int(item.cost): + state = LuneItemSlot.State.UNAFFORDABLE + card.set_item(item_id, state) + item_cards[item_id] = card + var uid: String = str(hero.uid) + card.pressed.connect(func() -> void: + if card.state == LuneItemSlot.State.AVAILABLE: + command_requested.emit({"type": "item_buy", "item_id": item_id, "uid": uid})) + var reasons: Dictionary = {LuneItemSlot.State.UNIQUE: "Dein Held trägt es bereits; Items sind einzigartig.", + LuneItemSlot.State.LOCKED: _why_not(model), LuneItemSlot.State.INCOMPATIBLE: "Erfordert Heldenstufe %d." % int(item.get("min_level", 1)), + LuneItemSlot.State.FULL: "Alle %d Plätze belegt; verkaufe zuerst ein Item." % LuneContent.ITEM_SLOTS, + LuneItemSlot.State.UNAFFORDABLE: "Es fehlen %d Gold." % (int(item.cost) - gold), LuneItemSlot.State.AVAILABLE: "Klick: kaufen und ausrüsten."} + var why: String = str(reasons.get(state, "")) + LuneTooltip.attach(card, func() -> Dictionary: return {"title": "%s · %d Gold" % [item.name, int(item.cost)], "glyph": item_id, + "glyph_color": LuneItemSlot.color_of(item_id), "text": str(item.text), "note": why}) + +func _talents(model: LuneHudModel, hero: Dictionary, level: int, page: VBoxContainer) -> void: + var can_act: bool = _can_act(model) + var talents: Array = hero.talents + for tier: int in range(LuneContent.TALENT_LEVELS.size()): + var required: int = LuneContent.TALENT_LEVELS[tier] + var state_text: String = "gewählt" if tier < talents.size() else ("jetzt wählbar" if level >= required and tier == talents.size() else "ab Stufe %d" % required) + LuneUiTheme.label("TALENT %d · STUFE %d · %s" % [tier + 1, required, state_text.to_upper()], "HudCaps", page) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + page.add_child(row) + var options: Array = LuneContent.TALENTS[hero.hero_id][tier] + for choice: int in range(options.size()): + var option: Dictionary = options[choice] + var card := Button.new() + card.theme_type_variation = "HudCard" + card.toggle_mode = true + card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + card.custom_minimum_size = Vector2(0, 96) + card.focus_mode = Control.FOCUS_ALL + row.add_child(card) + var column := VBoxContainer.new() + column.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + column.offset_left = 10 + column.offset_right = -10 + column.offset_top = 8 + column.offset_bottom = -8 + column.mouse_filter = Control.MOUSE_FILTER_IGNORE + card.add_child(column) + LuneUiTheme.label(str(option.name), "HudValue", column) + var text := LuneUiTheme.label(str(option.text), "HudMuted", column) + text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + var chosen: bool = tier < talents.size() and talents[tier] == option.id + card.set_pressed_no_signal(chosen) + card.disabled = not (can_act and tier == talents.size() and level >= required) and not chosen + var command: Dictionary = {"type": "talent", "tier": tier, "choice": choice} + card.pressed.connect(func() -> void: + card.set_pressed_no_signal(chosen) + if not chosen: + command_requested.emit(command)) + LuneUiTheme.label("Die Wahl ist endgültig; jede Stufe bietet zwei Richtungen (Einzelziel oder Fläche, Schutz oder Druck).", "HudMuted", page).autowrap_mode = TextServer.AUTOWRAP_WORD_SMART diff --git a/scripts/luneraria/ui/components/hero_detail.gd.uid b/scripts/luneraria/ui/components/hero_detail.gd.uid new file mode 100644 index 0000000..2d7a064 --- /dev/null +++ b/scripts/luneraria/ui/components/hero_detail.gd.uid @@ -0,0 +1 @@ +uid://bfrq4nh62rqic diff --git a/scripts/luneraria/ui/components/hp_bar.gd b/scripts/luneraria/ui/components/hp_bar.gd new file mode 100644 index 0000000..4e507a9 --- /dev/null +++ b/scripts/luneraria/ui/components/hp_bar.gd @@ -0,0 +1,41 @@ +class_name LuneHpBar +extends Control +## Thin value bar drawn directly: dark track, fill, a top highlight and optional ticks. + +var fraction: float = 1.0: + set(value): + value = clampf(value, 0.0, 1.0) + if absf(value - fraction) > .0005: + fraction = value + queue_redraw() +var fill: Color = LuneUiTheme.GOOD: + set(value): + if value != fill: + fill = value + queue_redraw() +var ticks: int = 0 +var track: Color = Color(0, 0, 0, .55) + +func _init() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + custom_minimum_size = Vector2(60, 6) + +func _draw() -> void: + var rect := Rect2(Vector2.ZERO, size) + draw_rect(rect, track) + if fraction > 0.0: + var filled := Rect2(Vector2.ZERO, Vector2(maxf(1.0, size.x * fraction), size.y)) + draw_rect(filled, fill) + draw_line(Vector2(0, .5), Vector2(filled.size.x, .5), Color(1, 1, 1, .28), 1.0) + for index: int in range(1, ticks): + var x: float = roundf(size.x * float(index) / float(ticks)) + draw_line(Vector2(x, 0), Vector2(x, size.y), Color(0, 0, 0, .45), 1.0) + draw_rect(rect, Color(1, 1, 1, .12), false, 1.0) + +## Green above 60 %, amber above 30 %, red below; the team colour when given. +static func health_color(value: float, base: Color = LuneUiTheme.GOOD) -> Color: + if value > .6: + return base + if value > .3: + return LuneUiTheme.WARN + return LuneUiTheme.DANGER diff --git a/scripts/luneraria/ui/components/hp_bar.gd.uid b/scripts/luneraria/ui/components/hp_bar.gd.uid new file mode 100644 index 0000000..a050173 --- /dev/null +++ b/scripts/luneraria/ui/components/hp_bar.gd.uid @@ -0,0 +1 @@ +uid://p0d75xwrg6g0 diff --git a/scripts/luneraria/ui/components/item_slot.gd b/scripts/luneraria/ui/components/item_slot.gd new file mode 100644 index 0000000..67f3ffd --- /dev/null +++ b/scripts/luneraria/ui/components/item_slot.gd @@ -0,0 +1,93 @@ +class_name LuneItemSlot +extends Button +## A hero item: either one of the three slots (compact: symbol only) or a card in the item +## shop (symbol, name, cost, main effect, state). States say why something cannot be done: +## equipped, available, unaffordable, incompatible (hero level), unique (already carried), +## slots full, locked (plan committed) or an empty slot. + +enum State { EMPTY, EQUIPPED, AVAILABLE, UNAFFORDABLE, INCOMPATIBLE, UNIQUE, FULL, LOCKED } +const STATE_TEXT: Dictionary = {State.EQUIPPED: "AUSGERÜSTET", State.AVAILABLE: "KAUFBAR", State.UNAFFORDABLE: "ZU TEUER", + State.INCOMPATIBLE: "STUFE FEHLT", State.UNIQUE: "GETRAGEN", State.FULL: "PLÄTZE VOLL", State.LOCKED: "GESPERRT", State.EMPTY: "LEER"} +const ITEM_COLORS: Dictionary = {"basaltschild": Color("9fb2c8"), "glutklinge": Color("ff9a5a"), "rankenmantel": Color("8fd08a"), + "funkenfokus": Color("f5d76a"), "quellamulett": Color("7ad6e6"), "kristallsplitter": Color("c7a2ff")} + +var item_id: String = "" +var state: int = State.EMPTY +var compact: bool = true +var _glyph: LuneGlyph +var _name: Label +var _effect: Label +var _cost: Label +var _badge: LuneStatusBadge + +static func color_of(id: String) -> Color: + return ITEM_COLORS.get(id, LuneUiTheme.MUTED) + +func _init(is_compact: bool = true) -> void: + compact = is_compact + theme_type_variation = "HudChip" if compact else "HudCard" + focus_mode = Control.FOCUS_ALL + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + if compact: + custom_minimum_size = Vector2(30, 30) + _glyph = LuneGlyph.new() + _glyph.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _glyph.offset_left = 5 + _glyph.offset_top = 5 + _glyph.offset_right = -5 + _glyph.offset_bottom = -5 + add_child(_glyph) + return + custom_minimum_size = Vector2(0, 74) + var row := HBoxContainer.new() + row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + row.offset_left = 10 + row.offset_right = -8 + row.offset_top = 6 + row.offset_bottom = -6 + row.add_theme_constant_override("separation", 10) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(row) + _glyph = LuneGlyph.make("", Color.WHITE, 34.0, row) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 0) + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + column.alignment = BoxContainer.ALIGNMENT_CENTER + column.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(column) + var head := HBoxContainer.new() + head.add_theme_constant_override("separation", 6) + head.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(head) + _name = LuneUiTheme.label("", "HudValue", head) + _name.size_flags_horizontal = Control.SIZE_EXPAND_FILL + LuneGlyph.make("coin", LuneUiTheme.GOLD, 14.0, head) + _cost = LuneUiTheme.label("", "HudValue", head) + _effect = LuneUiTheme.label("", "HudMuted", column) + _effect.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _effect.clip_text = true + _badge = LuneStatusBadge.new() + column.add_child(_badge) + _badge.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + +func set_item(id: String, new_state: int) -> void: + item_id = id + state = new_state + var item: Dictionary = LuneContent.ITEMS.get(id, {}) + var tint: Color = color_of(id) + var usable: bool = new_state in [State.AVAILABLE, State.EQUIPPED] + _glyph.glyph = id if not id.is_empty() else "" + _glyph.color = tint if usable or compact else Color(tint, .45) + if compact: + disabled = id.is_empty() + return + _name.text = str(item.get("name", "")) + _cost.text = str(int(item.get("cost", 0))) + _cost.add_theme_color_override("font_color", LuneUiTheme.DANGER if new_state == State.UNAFFORDABLE else LuneUiTheme.BRIGHT) + _effect.text = str(item.get("text", "")) + var badge_color: Color = LuneUiTheme.GOOD if new_state == State.EQUIPPED else (LuneUiTheme.BRONZE_LIGHT if new_state == State.AVAILABLE else LuneUiTheme.MUTED) + if new_state == State.UNAFFORDABLE: + badge_color = LuneUiTheme.DANGER + var glyphs: Dictionary = {State.EQUIPPED: "check", State.INCOMPATIBLE: "lock", State.LOCKED: "lock", State.UNIQUE: "check"} + _badge.set_state(STATE_TEXT.get(new_state, ""), badge_color, glyphs.get(new_state, ""), new_state == State.EQUIPPED) + disabled = not (new_state == State.AVAILABLE or new_state == State.EQUIPPED) diff --git a/scripts/luneraria/ui/components/item_slot.gd.uid b/scripts/luneraria/ui/components/item_slot.gd.uid new file mode 100644 index 0000000..47d98dc --- /dev/null +++ b/scripts/luneraria/ui/components/item_slot.gd.uid @@ -0,0 +1 @@ +uid://xas3b6sqx7ry diff --git a/scripts/luneraria/ui/components/lane_indicator.gd b/scripts/luneraria/ui/components/lane_indicator.gd new file mode 100644 index 0000000..5050efe --- /dev/null +++ b/scripts/luneraria/ui/components/lane_indicator.gd @@ -0,0 +1,202 @@ +class_name LuneLaneIndicator +extends Button +## One lane in the lane overview: name and hotkey, the threat level (text, symbol and colour), +## the tower chain from the own base to the enemy base, the two lane players, announced +## champions or the boss, and in combat the unit count of both sides. A click moves the camera. +## The threat rises at once but only falls after a short hold, so it does not flicker. + +const HOLD: float = 2.5 + +var lane: String = "top" +var hotkey: String = "" +var level: int = 0 +var data: Dictionary = {} +var towers: Array[LuneTowerIndicator] = [] +var _title: Label +var _badge: LuneStatusBadge +var _own: Label +var _enemy: Label +var _own_hero: LunePortraitRect +var _enemy_hero: LunePortraitRect +var _army: Label +var _dangers: HBoxContainer +var _mine: LuneGlyph +var _own_shape: LuneGlyph +var _enemy_shape: LuneGlyph +var _lower_hold: float = 0.0 +var _pulse: float = 0.0 +var _signature: String = "" + +func _init(lane_name: String = "top", key: String = "") -> void: + lane = lane_name + hotkey = key + theme_type_variation = "HudRow" + focus_mode = Control.FOCUS_ALL + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + custom_minimum_size = Vector2(0, 92) + var column := VBoxContainer.new() + column.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + column.offset_left = 10 + column.offset_right = -9 + column.offset_top = 6 + column.offset_bottom = -6 + column.add_theme_constant_override("separation", 3) + column.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(column) + var head := HBoxContainer.new() + head.add_theme_constant_override("separation", 6) + head.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(head) + LuneGlyph.make("lane_" + lane, LuneUiTheme.BRONZE_LIGHT, 17.0, head) + _title = LuneUiTheme.label(LuneMap.LANE_NAMES[lane].to_upper(), "HudLane", head) + _mine = LuneGlyph.make("star", LuneUiTheme.BRONZE_LIGHT, 12.0, head) + _mine.visible = false + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE + head.add_child(spacer) + _badge = LuneStatusBadge.new() + head.add_child(_badge) + var chain := HBoxContainer.new() + chain.add_theme_constant_override("separation", 3) + chain.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(chain) + _own_shape = LuneGlyph.make("emblem_0", LuneUiTheme.TEAM[0], 13.0, chain) + for index: int in range(4): + var tower := LuneTowerIndicator.new() + chain.add_child(tower) + towers.append(tower) + LuneTooltip.attach(tower, _tower_tip.bind(index)) + if index == 1: + var middle := HBoxContainer.new() + middle.size_flags_horizontal = Control.SIZE_EXPAND_FILL + middle.alignment = BoxContainer.ALIGNMENT_CENTER + middle.add_theme_constant_override("separation", 4) + middle.mouse_filter = Control.MOUSE_FILTER_IGNORE + chain.add_child(middle) + _army = LuneUiTheme.label("", "HudValue", middle) + _dangers = HBoxContainer.new() + _dangers.add_theme_constant_override("separation", 2) + _dangers.mouse_filter = Control.MOUSE_FILTER_IGNORE + middle.add_child(_dangers) + _enemy_shape = LuneGlyph.make("emblem_1", LuneUiTheme.TEAM[1], 13.0, chain) + var names := HBoxContainer.new() + names.add_theme_constant_override("separation", 5) + names.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(names) + # Heroes of both lane players (Dota-style "who stands where"), committed heroes only. + _own_hero = LunePortraitRect.new() + _own_hero.custom_minimum_size = Vector2(22, 22) + names.add_child(_own_hero) + _own = LuneUiTheme.label("", "HudMuted", names) + _own.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _own.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _own.clip_text = true + _enemy = LuneUiTheme.label("", "HudMuted", names) + _enemy.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _enemy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _enemy.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _enemy.clip_text = true + _enemy_hero = LunePortraitRect.new() + _enemy_hero.custom_minimum_size = Vector2(22, 22) + names.add_child(_enemy_hero) + LuneTooltip.attach(self, _tip) + +## Applies a lane dictionary from LuneHudModel.lane(); returns true if something changed. +func apply(entry: Dictionary, own_team: int, delta: float, reduced_motion: bool) -> void: + data = entry + var threat: int = int(entry.threat.level) + if threat >= level: + if threat == LuneLaneThreat.Level.CRITICAL and level != threat and not reduced_motion: + _pulse = 1.6 + level = threat + _lower_hold = HOLD + else: + _lower_hold -= delta + if _lower_hold <= 0.0: + level = threat + _lower_hold = HOLD + var tint: Color = LuneUiTheme.THREAT[level] + _badge.set_state(LuneLaneThreat.NAMES[level], tint, LuneLaneThreat.GLYPHS[level] if level >= 2 else "", level == LuneLaneThreat.Level.CRITICAL) + _own_shape.glyph = "emblem_%d" % own_team + _own_shape.color = LuneUiTheme.team_color(own_team) + _enemy_shape.glyph = "emblem_%d" % (1 - own_team) + _enemy_shape.color = LuneUiTheme.team_color(1 - own_team) + var list: Array = entry.towers + for index: int in range(4): + var tower: Dictionary = list[index] + towers[index].team = int(tower.team) + towers[index].tier = str(tower.tier) + towers[index].set_state(bool(tower.alive), float(tower.fraction), bool(tower.attackable)) + _own.text = str(entry.own_player) + _own.add_theme_color_override("font_color", LuneUiTheme.BRONZE_LIGHT if entry.own_is_me else LuneUiTheme.MUTED) + _enemy.text = str(entry.enemy_player) + for pair: Array in [[_own_hero, entry.get("own_hero", {})], [_enemy_hero, entry.get("enemy_hero", {})]]: + var portrait: LunePortraitRect = pair[0] + var hero: Dictionary = pair[1] + portrait.visible = not hero.is_empty() + if not hero.is_empty(): + portrait.show_model(str(hero.model)) + _mine.visible = bool(entry.own_is_me) + _army.text = "%d : %d" % [int(entry.own_count), int(entry.enemy_count)] if entry.has("own_count") else "" + var signature: String = str(entry.dangers) + if signature != _signature: + _signature = signature + for child: Node in _dangers.get_children(): + child.free() + for danger: Dictionary in entry.dangers: + var hostile: bool = int(danger.team) != own_team + var glyph: String = "crown" if danger.kind == "boss" else "champion" + LuneGlyph.make(glyph, LuneUiTheme.DANGER if hostile else LuneUiTheme.team_color(int(danger.team)), 16.0, _dangers) + if _pulse > 0.0: + _pulse = maxf(0.0, _pulse - delta) + _badge.modulate = Color(1, 1, 1, .55 + .45 * absf(cos(_pulse * PI * 2.0))) + if _pulse <= 0.0: + _badge.modulate = Color.WHITE + elif reduced_motion and _badge.modulate != Color.WHITE: + _badge.modulate = Color.WHITE + if LuneTooltip.current != null: + LuneTooltip.current.refresh(self) + +func _tip() -> Dictionary: + if data.is_empty(): + return {} + var lines: PackedStringArray = [] + for reason: String in data.threat.reasons: + lines.append("• " + reason) + if lines.is_empty(): + lines.append("Keine Bedrohung aus Türmen, Kampflage oder Ankündigungen.") + var stats: Array = [] + var list: Array = data.towers + for index: int in [0, 1]: + var tower: Dictionary = list[index] + stats.append(["tower", "Eigene " + LuneHudModel.building_title(tower.id), _tower_value(tower)]) + for index: int in [2, 3]: + var tower: Dictionary = list[index] + stats.append(["tower", "Gegner " + LuneHudModel.building_title(tower.id), _tower_value(tower)]) + if data.has("own_count"): + stats.append(["melee", "Einheiten", "%d eigene · %d gegnerische" % [int(data.own_count), int(data.enemy_count)]]) + var notes: PackedStringArray = [] + for danger: Dictionary in data.dangers: + var side: String = LuneUiTheme.team_name(int(danger.team)) + notes.append(("Schlundkrone (%s) greift an" % side) if danger.kind == "boss" else ("%s (%s) angekündigt" % [LuneContent.CHAMPIONS[danger.champion_id].name, side])) + return {"title": "%s-LANE · %s" % [data.title, LuneLaneThreat.NAMES[level]], "glyph": "lane_" + lane, + "subtitle": "%s gegen %s" % [_player_text(data.own_player, data.get("own_hero", {})), _player_text(data.enemy_player, data.get("enemy_hero", {}))], "stats": stats, "text": "\n".join(lines), + "note": ("; ".join(notes) + "\n" if not notes.is_empty() else "") + "Klick: Kamera auf die Lane" + (" (%s)" % hotkey if not hotkey.is_empty() else "")} + +static func _player_text(player: String, hero: Dictionary) -> String: + return player if hero.is_empty() else "%s (%s, Stufe %d)" % [player, hero.name, int(hero.level)] + +func _tower_tip(index: int) -> Dictionary: + if data.is_empty(): + return {} + var tower: Dictionary = data.towers[index] + var title: String = "%s · %s" % [LuneHudModel.building_title(tower.id), LuneUiTheme.team_name(int(tower.team))] + var text: String = "Zerstört – die Ruine blockiert nicht." if not tower.alive else ("Angreifbar." if tower.attackable else "Noch geschützt: erst fällt der Turm davor.") + return {"title": title, "glyph": "tower", "glyph_color": LuneUiTheme.team_color(int(tower.team)), + "stats": [["heart", "Leben", _tower_value(tower)]], "text": text} + +static func _tower_value(tower: Dictionary) -> String: + if not tower.alive: + return "zerstört" + return "%s / %s (%d %%)" % [LuneUiTheme.number(tower.hp), LuneUiTheme.number(tower.max_hp), int(round(float(tower.fraction) * 100.0))] diff --git a/scripts/luneraria/ui/components/lane_indicator.gd.uid b/scripts/luneraria/ui/components/lane_indicator.gd.uid new file mode 100644 index 0000000..012f1e4 --- /dev/null +++ b/scripts/luneraria/ui/components/lane_indicator.gd.uid @@ -0,0 +1 @@ +uid://d2rwsckejynpm diff --git a/scripts/luneraria/ui/components/lune_glyph.gd b/scripts/luneraria/ui/components/lune_glyph.gd new file mode 100644 index 0000000..6017bf1 --- /dev/null +++ b/scripts/luneraria/ui/components/lune_glyph.gd @@ -0,0 +1,32 @@ +class_name LuneGlyph +extends Control +## A LuneGlyphs symbol as a control (icons in cards, badges and rows). Ignores the mouse. + +var glyph: String = "": + set(value): + if value != glyph: + glyph = value + queue_redraw() +var color: Color = LuneUiTheme.TEXT: + set(value): + if value != color: + color = value + queue_redraw() +var accent: Color = Color(0, 0, 0, 0) + +static func make(name_value: String, tint: Color, edge: float = 18.0, parent: Node = null) -> LuneGlyph: + var result := LuneGlyph.new() + result.glyph = name_value + result.color = tint + result.custom_minimum_size = Vector2(edge, edge) + result.size_flags_vertical = Control.SIZE_SHRINK_CENTER + if parent != null: + parent.add_child(result) + return result + +func _init() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + +func _draw() -> void: + if not glyph.is_empty(): + LuneGlyphs.draw(self, glyph, Rect2(Vector2.ZERO, size), color, accent) diff --git a/scripts/luneraria/ui/components/lune_glyph.gd.uid b/scripts/luneraria/ui/components/lune_glyph.gd.uid new file mode 100644 index 0000000..3101bf4 --- /dev/null +++ b/scripts/luneraria/ui/components/lune_glyph.gd.uid @@ -0,0 +1 @@ +uid://7ltitpqbfktb diff --git a/scripts/luneraria/ui/components/lune_tooltip.gd b/scripts/luneraria/ui/components/lune_tooltip.gd new file mode 100644 index 0000000..e78bf4d --- /dev/null +++ b/scripts/luneraria/ui/components/lune_tooltip.gd @@ -0,0 +1,141 @@ +class_name LuneTooltip +extends PanelContainer +## The one tooltip of the HUD. A single panel is reused for every hover: it appears after a +## short delay next to the hovered control, stays inside the window, never takes mouse input +## and hides as soon as the control is left, hidden, disabled for input, freed or clicked. +## Content is a small dictionary: title, subtitle, stats ([glyph, name, value] rows), text, +## note and warn. Components register with `LuneTooltip.attach(control, builder)`. + +const DELAY: float = .28 +const QUICK: float = .05 +const WIDTH: float = 300.0 + +static var current: LuneTooltip + +var _owner: Control +var _builder: Callable +var _wait: float = 0.0 +var _last_hide: float = -10.0 +var _clock: float = 0.0 +var _column: VBoxContainer +## Last pointer position from input events (canvas coordinates); real and synthetic motion. +var _pointer: Vector2 = Vector2(-1, -1) + +func _init() -> void: + theme_type_variation = "HudTooltip" + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + z_index = 100 + _column = VBoxContainer.new() + _column.add_theme_constant_override("separation", 4) + _column.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_column) + current = self + +static func attach(control: Control, builder: Callable) -> void: + control.mouse_entered.connect(func() -> void: + if current != null and is_instance_valid(current): + current.hover(control, builder)) + control.mouse_exited.connect(func() -> void: + if current != null and is_instance_valid(current): + current.leave(control)) + control.gui_input.connect(func(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and current != null and is_instance_valid(current): + current.leave(control)) + +func hover(control: Control, builder: Callable) -> void: + if _pointer.x < 0.0: + _pointer = control.get_global_mouse_position() + _owner = control + _builder = builder + _wait = QUICK if _clock - _last_hide < .6 else DELAY + visible = false + +func leave(control: Control) -> void: + if control == _owner: + _hide() + +func _hide() -> void: + if visible: + _last_hide = _clock + _owner = null + visible = false + +## Refreshes the text of an open tooltip (values changed while hovering). +func refresh(control: Control) -> void: + if control == _owner and visible: + _show() + +func _input(event: InputEvent) -> void: + if event is InputEventMouse: + _pointer = (event as InputEventMouse).position + +func _process(delta: float) -> void: + _clock += delta + if _owner == null: + return + if not is_instance_valid(_owner) or not _owner.is_visible_in_tree() or not _owner.get_global_rect().has_point(_pointer): + _hide() + return + if not visible: + _wait -= delta + if _wait <= 0.0: + _show() + +func _show() -> void: + var content: Dictionary = _builder.call() if _builder.is_valid() else {} + if content.is_empty(): + visible = false + return + for child: Node in _column.get_children(): + child.free() + if content.has("title"): + var head := HBoxContainer.new() + head.add_theme_constant_override("separation", 6) + head.mouse_filter = Control.MOUSE_FILTER_IGNORE + _column.add_child(head) + if content.has("glyph"): + LuneGlyph.make(str(content.glyph), content.get("glyph_color", LuneUiTheme.BRONZE_LIGHT), 18.0, head) + LuneUiTheme.label(str(content.title), "HudValue", head) + if content.has("subtitle"): + LuneUiTheme.label(str(content.subtitle), "HudMuted", _column) + var stats: Array = content.get("stats", []) + if not stats.is_empty(): + var grid := GridContainer.new() + grid.columns = 3 + grid.add_theme_constant_override("h_separation", 8) + grid.add_theme_constant_override("v_separation", 2) + grid.mouse_filter = Control.MOUSE_FILTER_IGNORE + _column.add_child(grid) + for row: Array in stats: + LuneGlyph.make(str(row[0]), LuneUiTheme.MUTED, 14.0, grid) + LuneUiTheme.label(str(row[1]), "HudMuted", grid) + var value := LuneUiTheme.label(str(row[2]), "HudText", grid) + value.size_flags_horizontal = Control.SIZE_EXPAND_FILL + for key: String in ["text", "note", "warn"]: + if content.has(key) and not str(content[key]).is_empty(): + var line := LuneUiTheme.label(str(content[key]), "HudText" if key == "text" else "HudMuted", _column) + line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + line.custom_minimum_size.x = WIDTH - 24.0 + if key == "warn": + line.add_theme_color_override("font_color", LuneUiTheme.DANGER) + visible = true + reset_size() + custom_minimum_size.x = 0.0 + await get_tree().process_frame + if not visible or _owner == null or not is_instance_valid(_owner): + return + _place() + +func _place() -> void: + reset_size() + var parent_rect: Rect2 = (get_parent() as Control).get_global_rect() if get_parent() is Control else get_viewport_rect() + var anchor: Rect2 = _owner.get_global_rect() + var scale_factor: float = get_global_transform().get_scale().x + var extent: Vector2 = size * scale_factor + var at := Vector2(anchor.get_center().x - extent.x * .5, anchor.position.y - extent.y - 8.0) + if at.y < parent_rect.position.y + 6.0: + at.y = anchor.end.y + 8.0 + at.x = clampf(at.x, parent_rect.position.x + 6.0, parent_rect.end.x - extent.x - 6.0) + at.y = clampf(at.y, parent_rect.position.y + 6.0, parent_rect.end.y - extent.y - 6.0) + global_position = at diff --git a/scripts/luneraria/ui/components/lune_tooltip.gd.uid b/scripts/luneraria/ui/components/lune_tooltip.gd.uid new file mode 100644 index 0000000..d9f10ca --- /dev/null +++ b/scripts/luneraria/ui/components/lune_tooltip.gd.uid @@ -0,0 +1 @@ +uid://comnp3s0ir8hg diff --git a/scripts/luneraria/ui/components/modal_panel.gd b/scripts/luneraria/ui/components/modal_panel.gd new file mode 100644 index 0000000..8811d78 --- /dev/null +++ b/scripts/luneraria/ui/components/modal_panel.gd @@ -0,0 +1,74 @@ +class_name LuneModalPanel +extends Control +## A window over the field for detail work (hero, recruiting). A light backdrop keeps clicks +## away from the world and closes the window; Esc and the × close it too. The window never +## covers the whole screen, and it only opens on request. + +signal closed + +var panel: PanelContainer +var body: VBoxContainer +var title: Label +var _close: Button +var _box: Vector2 + +func _init(window_size: Vector2 = Vector2(980, 600)) -> void: + _box = window_size + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_STOP + visible = false + var backdrop := ColorRect.new() + backdrop.color = Color(.01, .015, .025, .38) + backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + backdrop.gui_input.connect(func(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + close()) + add_child(backdrop) + panel = PanelContainer.new() + panel.theme_type_variation = "HudModal" + panel.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(panel) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 12) + panel.add_child(column) + var head := HBoxContainer.new() + column.add_child(head) + title = LuneUiTheme.label("", "HudTitle", head) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _close = LuneUiTheme.button("", "HudIconButton", head) + _close.custom_minimum_size = Vector2(36, 34) + var cross := LuneGlyph.make("close", LuneUiTheme.MUTED, 14.0, _close) + cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + _close.pressed.connect(close) + LuneTooltip.attach(_close, func() -> Dictionary: return {"title": "Schließen", "text": "Esc oder Klick neben das Fenster."}) + body = VBoxContainer.new() + body.add_theme_constant_override("separation", 10) + body.size_flags_vertical = Control.SIZE_EXPAND_FILL + column.add_child(body) + resized.connect(_layout) + +func open(heading: String) -> void: + title.text = heading + visible = true + _layout() + +func close() -> void: + if visible: + visible = false + closed.emit() + +func _layout() -> void: + var edge: Vector2 = Vector2(minf(_box.x, size.x - 40.0), minf(_box.y, size.y - 40.0)) + panel.custom_minimum_size = edge + panel.size = edge + panel.position = ((size - edge) * .5).floor() + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + accept_event() + +func _unhandled_key_input(event: InputEvent) -> void: + if visible and event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + close() + get_viewport().set_input_as_handled() diff --git a/scripts/luneraria/ui/components/modal_panel.gd.uid b/scripts/luneraria/ui/components/modal_panel.gd.uid new file mode 100644 index 0000000..705c25f --- /dev/null +++ b/scripts/luneraria/ui/components/modal_panel.gd.uid @@ -0,0 +1 @@ +uid://km6vgwysbros diff --git a/scripts/luneraria/ui/components/next_wave_panel.gd b/scripts/luneraria/ui/components/next_wave_panel.gd new file mode 100644 index 0000000..37374bf --- /dev/null +++ b/scripts/luneraria/ui/components/next_wave_panel.gd @@ -0,0 +1,123 @@ +class_name LuneNextWave +extends PanelContainer +## What the coming wave brings: monster types with counts (identical for both sides and every +## lane), new types, announced attack champions and an announced boss. Dangers for the own +## team are highlighted. In combat the panel shrinks to one line about the following wave. + +var _title: Label +var _round: Label +var _rows: VBoxContainer +var _extra: VBoxContainer +var _note: Label +var _signature: String = "" +var compact: bool = false + +func _init() -> void: + theme_type_variation = "HudPanelTight" + mouse_filter = Control.MOUSE_FILTER_STOP + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 4) + column.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(column) + var head := HBoxContainer.new() + head.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(head) + _title = LuneUiTheme.label("NÄCHSTE WELLE", "HudCaps", head) + _title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _round = LuneUiTheme.label("", "HudValue", head) + _rows = VBoxContainer.new() + _rows.add_theme_constant_override("separation", 2) + _rows.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(_rows) + _note = LuneUiTheme.label("", "HudMuted", column) + _note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _extra = VBoxContainer.new() + _extra.add_theme_constant_override("separation", 3) + _extra.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(_extra) + +func apply(wave: Dictionary, own_team: int, fighting: bool) -> void: + var signature: String = "%s|%d|%s" % [str(wave), own_team, fighting] + if signature == _signature: + return + _signature = signature + compact = fighting + _title.text = "DANACH" if fighting else "NÄCHSTE WELLE" + _round.text = "WELLE %d" % int(wave.round) + for child: Node in _rows.get_children(): + child.free() + for child: Node in _extra.get_children(): + child.free() + var monsters: Array = wave.monsters + if fighting: + var parts: PackedStringArray = [] + for monster: Dictionary in monsters: + parts.append("%d× %s%s" % [int(monster.count), monster.name, " (neu)" if monster.new else ""]) + _note.text = ", ".join(parts) + " je Lane" + return + for monster: Dictionary in monsters: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 7) + row.mouse_filter = Control.MOUSE_FILTER_PASS + _rows.add_child(row) + var portrait := LunePortraitRect.new() + portrait.custom_minimum_size = Vector2(30, 30) + row.add_child(portrait) + portrait.show_model(str(monster.id)) + var count := LuneUiTheme.label("%d×" % int(monster.count), "HudValue", row) + count.custom_minimum_size.x = 30 + var title := LuneUiTheme.label(str(monster.name), "HudText", row) + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.clip_text = true + title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + LuneGlyph.make("armor", LuneUiTheme.MUTED, 13.0, row) + LuneUiTheme.label(DataCatalog.ARMOR_NAMES[int(monster.armor)], "HudMuted", row) + if monster.new: + var badge := LuneStatusBadge.new() + badge.set_state("NEU", LuneUiTheme.WARN) + row.add_child(badge) + var data: Dictionary = monster + LuneTooltip.attach(row, func() -> Dictionary: return {"title": str(data.name), "subtitle": "Monster der Welle %d" % int(wave.round), + "stats": [["heart", "Leben", LuneUiTheme.number(data.hp)], ["sword", "Schaden/Treffer", LuneUiTheme.decimal(data.damage)], + ["range", "Reichweite", "%s m" % LuneUiTheme.decimal(data.range)], ["armor", "Rüstung", DataCatalog.ARMOR_NAMES[int(data.armor)]]], + "note": "Beide Teams erhalten auf jeder Lane dieselbe Welle."}) + # Counter hint as in Legion TD 2: the armour most of the wave wears and the damage type + # that hits it for 130 % (LuneUnit.DAMAGE_MATRIX). + var armour_count: Array[int] = [0, 0, 0] + for monster: Dictionary in monsters: + armour_count[int(monster.armor)] += int(monster.count) + var main_armour: int = armour_count.find(armour_count.max()) + var counter: int = 0 + for attack: int in range(3): + if is_equal_approx(float(LuneUnit.DAMAGE_MATRIX[attack][main_armour]), 1.30): + counter = attack + _note.text = "%d Monster je Lane · beide Seiten gleich\nMeist %s: %s trifft stark" % [int(wave.count), DataCatalog.ARMOR_NAMES[main_armour], DataCatalog.DAMAGE_NAMES[counter]] + var champions: Array = wave.champions + var boss: Dictionary = wave.boss + if champions.is_empty() and boss.is_empty(): + return + LuneUiTheme.label("ANGEKÜNDIGT", "HudCaps", _extra) + for entry: Dictionary in champions: + var hostile: bool = not bool(entry.own) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + _extra.add_child(row) + LuneGlyph.make("champion", LuneUiTheme.DANGER if hostile else LuneUiTheme.CHAMPION, 15.0, row) + var text := LuneUiTheme.label("%s → %s" % [entry.name, LuneMap.LANE_NAMES[entry.lane].to_upper()], "HudText", row) + text.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var badge := LuneStatusBadge.new() + badge.set_state("GEGEN EUCH" if hostile else "FÜR EUCH", LuneUiTheme.DANGER if hostile else LuneUiTheme.GOOD) + row.add_child(badge) + if not boss.is_empty(): + var hostile_boss: bool = not bool(boss.own) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + _extra.add_child(row) + LuneGlyph.make("crown", LuneUiTheme.DANGER if hostile_boss else LuneUiTheme.team_color(own_team), 16.0, row) + var text := LuneUiTheme.label("Schlundkrone → %s" % LuneMap.LANE_NAMES[boss.lane].to_upper(), "HudText", row) + text.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var badge := LuneStatusBadge.new() + badge.set_state("GEGEN EUCH" if hostile_boss else "FÜR EUCH", LuneUiTheme.DANGER if hostile_boss else LuneUiTheme.GOOD, "crown") + row.add_child(badge) diff --git a/scripts/luneraria/ui/components/next_wave_panel.gd.uid b/scripts/luneraria/ui/components/next_wave_panel.gd.uid new file mode 100644 index 0000000..17523a7 --- /dev/null +++ b/scripts/luneraria/ui/components/next_wave_panel.gd.uid @@ -0,0 +1 @@ +uid://ys74554k5ior diff --git a/scripts/luneraria/ui/components/portrait_cache.gd b/scripts/luneraria/ui/components/portrait_cache.gd new file mode 100644 index 0000000..297cd99 --- /dev/null +++ b/scripts/luneraria/ui/components/portrait_cache.gd @@ -0,0 +1,167 @@ +class_name LunePortraits +extends Node +## Portraits from the real game models. One offscreen SubViewport renders one model at a time +## with the same orthographic three-quarter camera, the same key, fill and rim light and a +## transparent background; every model is framed from its own bounds, so small creatures and +## the Schlundkrone fill the card alike. Each portrait is rendered once and kept as a texture, +## so cards never hold live viewports. Without a display (headless tests) nothing renders and +## callers keep their placeholder. + +signal portrait_ready(key: String) + +const SMALL: int = 160 +const LARGE: int = 400 +const YAW: float = .42 +const PITCH: float = .2 +const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") + +static var _shared: LunePortraits +static var textures: Dictionary = {} + +var rendered: int = 0 +var _viewport: SubViewport +var _stage: Node3D +var _camera: Camera3D +var _queue: Array[String] = [] +var _busy: bool = false +var _current: String = "" + +static func shared() -> LunePortraits: + if _shared == null or not is_instance_valid(_shared): + _shared = LunePortraits.new() + _shared.name = "LunePortraits" + var tree := Engine.get_main_loop() as SceneTree + tree.root.add_child.call_deferred(_shared) + return _shared + +static func key_for(model_id: String, large: bool = false) -> String: + return model_id + ("@L" if large else "") + +static func available() -> bool: + return DisplayServer.get_name() != "headless" + +## The cached portrait, or null while it is queued (portrait_ready follows). +func portrait(model_id: String, large: bool = false) -> Texture2D: + var key: String = key_for(model_id, large) + if textures.has(key): + return textures[key] + if available() and not _queue.has(key) and key != _current: + _queue.append(key) + return null + +func pending() -> int: + return _queue.size() + int(_busy) + +func _ready() -> void: + _viewport = SubViewport.new() + _viewport.own_world_3d = true + _viewport.transparent_bg = true + _viewport.msaa_3d = Viewport.MSAA_4X + _viewport.size = Vector2i(SMALL, SMALL) + _viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED + add_child(_viewport) + _stage = Node3D.new() + _viewport.add_child(_stage) + var world := WorldEnvironment.new() + var settings := Environment.new() + settings.background_mode = Environment.BG_CLEAR_COLOR + settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + settings.ambient_light_color = Color("a8b4c8") + settings.ambient_light_energy = .62 + settings.tonemap_mode = Environment.TONE_MAPPER_ACES + settings.tonemap_white = 4.0 + settings.adjustment_enabled = true + settings.adjustment_contrast = 1.08 + settings.adjustment_saturation = 1.12 + world.environment = settings + _viewport.add_child(world) + for spec: Array in [[Vector3(-38, -32, 0), Color("ffe2bc"), 1.75], [Vector3(-18, 150, 0), Color("9fc4ff"), .9], [Vector3(-8, 40, 0), Color("c8d4e4"), .35]]: + var light := DirectionalLight3D.new() + light.rotation_degrees = spec[0] + light.light_color = spec[1] + light.light_energy = spec[2] + _viewport.add_child(light) + _camera = Camera3D.new() + _camera.projection = Camera3D.PROJECTION_ORTHOGONAL + _camera.current = true + _viewport.add_child(_camera) + +func _process(_delta: float) -> void: + if not _busy and not _queue.is_empty(): + _render(_queue.pop_front()) + +func _render(key: String) -> void: + _busy = true + _current = key + var large: bool = key.ends_with("@L") + var model_id: String = key.trim_suffix("@L") + var edge: int = LARGE if large else SMALL + _viewport.size = Vector2i(edge, edge) + var model: Node3D = _instantiate(model_id) + if model == null: + textures[key] = AssetLibrary.icon(model_id) + _busy = false + _current = "" + portrait_ready.emit(key) + return + _stage.add_child(model) + _pose(model) + _frame(model) + _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + await RenderingServer.frame_post_draw + await RenderingServer.frame_post_draw + var image: Image = _viewport.get_texture().get_image() + model.queue_free() + if image != null and not image.is_empty(): + image.generate_mipmaps() + textures[key] = ImageTexture.create_from_image(image) + rendered += 1 + else: + textures[key] = AssetLibrary.icon(model_id) + _busy = false + _current = "" + portrait_ready.emit(key) + +func _instantiate(model_id: String) -> Node3D: + var model: Node3D = AssetLibrary.instantiate_fighter(model_id) if AssetLibrary.has_model(model_id) else null + if model == null: + return null + if model_id == "schlundkrone": + for surface: GeometryInstance3D in Materials.dress(model): + surface.set_instance_shader_parameter("tint", Materials.team_color(2, "crystal")) + surface.set_instance_shader_parameter("seed", 1.7) + surface.set_instance_shader_parameter("energy", 1.0) + return model + +func _pose(model: Node3D) -> void: + if model.has_method("preview"): + model.preview("idle", .4) + elif model.has_method("pose"): + model.pose(.4, false, 0.0, 0.0, 0.0) + +## Frames the model's bounds: the same view direction for every portrait, size from bounds. +func _frame(model: Node3D) -> void: + var box: AABB = _bounds(model) + var center: Vector3 = box.get_center() + var radius: float = maxf(.3, box.size.length() * .5) + var direction := Vector3(sin(YAW) * cos(PITCH), sin(PITCH), cos(YAW) * cos(PITCH)) + _camera.size = maxf(box.size.y, maxf(box.size.x, box.size.z) * .92) * 1.12 + _camera.near = .05 + _camera.far = radius * 8.0 + 10.0 + _camera.position = center + direction * (radius * 3.0 + 2.0) + _camera.look_at(center, Vector3.UP) + +func _bounds(model: Node3D) -> AABB: + if model.has_method("get_aabb"): + var own: AABB = model.get_aabb() + if own.size.length() > .01: + return own + var result := AABB() + var first: bool = true + for node: Node in model.find_children("*", "VisualInstance3D", true, false): + var visual := node as VisualInstance3D + var local: Transform3D = model.global_transform.affine_inverse() * visual.global_transform + var box: AABB = local * visual.get_aabb() + result = box if first else result.merge(box) + first = false + return result if not first else AABB(Vector3(-.5, 0, -.5), Vector3(1, 1.6, 1)) diff --git a/scripts/luneraria/ui/components/portrait_cache.gd.uid b/scripts/luneraria/ui/components/portrait_cache.gd.uid new file mode 100644 index 0000000..db0cbdb --- /dev/null +++ b/scripts/luneraria/ui/components/portrait_cache.gd.uid @@ -0,0 +1 @@ +uid://d0xhmijqsnb7u diff --git a/scripts/luneraria/ui/components/portrait_rect.gd b/scripts/luneraria/ui/components/portrait_rect.gd new file mode 100644 index 0000000..e7edd5f --- /dev/null +++ b/scripts/luneraria/ui/components/portrait_rect.gd @@ -0,0 +1,87 @@ +class_name LunePortraitRect +extends Control +## A real-model portrait on the neutral card background (dark slate gradient, soft floor). +## It asks LunePortraits for the texture and fills in when the render is done. + +var model_id: String = "" +var large: bool = false +var dimmed: bool = false: + set(value): + if value != dimmed: + dimmed = value + queue_redraw() +var ring: Color = Color(0, 0, 0, 0): + set(value): + if value != ring: + ring = value + queue_redraw() +## Diagonal hatching over the background (attack champions: temporary units). +var hatch: Color = Color(0, 0, 0, 0): + set(value): + if value != hatch: + hatch = value + queue_redraw() +## Fallen units: a red cross over the portrait. +var crossed: bool = false: + set(value): + if value != crossed: + crossed = value + queue_redraw() +var _texture: Texture2D +var _connected: bool = false + +func _init() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + clip_contents = true + +func show_model(id: String, is_large: bool = false) -> void: + if id == model_id and is_large == large and _texture != null: + return + model_id = id + large = is_large + var cache: LunePortraits = LunePortraits.shared() + if not _connected: + cache.portrait_ready.connect(_on_ready) + _connected = true + _texture = cache.portrait(id, is_large) + queue_redraw() + +func has_portrait() -> bool: + return _texture != null + +func _on_ready(key: String) -> void: + if key == LunePortraits.key_for(model_id, large): + _texture = LunePortraits.textures.get(key) + queue_redraw() + +func _draw() -> void: + var rect := Rect2(Vector2.ZERO, size) + var top := Color("202b37") + var bottom := Color("0d1218") + draw_polygon(PackedVector2Array([rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]), + PackedColorArray([top, top, bottom, bottom])) + var floor_points := PackedVector2Array() + for index: int in range(20): + var angle: float = TAU * float(index) / 20.0 + floor_points.append(Vector2(size.x * .5 + cos(angle) * size.x * .36, size.y * .88 + sin(angle) * size.y * .06)) + draw_colored_polygon(floor_points, Color(0, 0, 0, .35)) + if hatch.a > 0.0: + var x: float = -size.y + while x < size.x: + var from := Vector2(maxf(x, 0.0), maxf(0.0, -x)) + var to := Vector2(minf(x + size.y, size.x), minf(size.y, size.x - x)) + draw_line(from, to, hatch, 2.0) + x += 9.0 + if _texture != null: + var edge: float = minf(size.x, size.y) + var target := Rect2((size - Vector2(edge, edge)) * .5, Vector2(edge, edge)) + draw_texture_rect(_texture, target, false, Color(1, 1, 1, .45) if dimmed else Color.WHITE) + else: + LuneGlyphs.draw(self, "human", rect.grow(-minf(size.x, size.y) * .22), Color(1, 1, 1, .08)) + if ring.a > 0.0: + draw_rect(rect.grow(-1.0), ring, false, 2.0) + if crossed: + var inset: float = minf(size.x, size.y) * .22 + draw_line(Vector2(inset, inset), size - Vector2(inset, inset), Color(1, .42, .36, .85), 2.0, true) + draw_line(Vector2(size.x - inset, inset), Vector2(inset, size.y - inset), Color(1, .42, .36, .85), 2.0, true) diff --git a/scripts/luneraria/ui/components/portrait_rect.gd.uid b/scripts/luneraria/ui/components/portrait_rect.gd.uid new file mode 100644 index 0000000..84c94cb --- /dev/null +++ b/scripts/luneraria/ui/components/portrait_rect.gd.uid @@ -0,0 +1 @@ +uid://cu1falqirphcr diff --git a/scripts/luneraria/ui/components/resource_display.gd b/scripts/luneraria/ui/components/resource_display.gd new file mode 100644 index 0000000..784b890 --- /dev/null +++ b/scripts/luneraria/ui/components/resource_display.gd @@ -0,0 +1,39 @@ +class_name LuneResourceDisplay +extends HBoxContainer +## The viewer's own gold with the coin symbol and the base income of the next settlement. +## It is only ever fed the own seat's gold; other players' gold never reaches the HUD. + +var amount: Label +var detail: Label +var _coin: LuneGlyph +var _shown: int = -1 +var _flash: float = 0.0 + +func _init() -> void: + add_theme_constant_override("separation", 8) + mouse_filter = Control.MOUSE_FILTER_PASS + _coin = LuneGlyph.make("coin", LuneUiTheme.GOLD, 30.0, self) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", -2) + column.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(column) + amount = LuneUiTheme.label("0", "HudGold", column) + detail = LuneUiTheme.label("", "HudMuted", column) + +func set_values(gold: int, income: int, show_income: bool = true) -> void: + if gold != _shown: + if _shown >= 0 and not Preferences.reduced_motion: + _flash = .45 + set_process(true) + _shown = gold + amount.text = LuneUiTheme.number(gold) + var line: String = ("+%s nächste Runde" % LuneUiTheme.number(income)) if show_income else "" + if detail.text != line: + detail.text = line + detail.visible = show_income + +func _process(delta: float) -> void: + _flash = maxf(0.0, _flash - delta) + amount.modulate = Color(1, 1, 1).lerp(Color(1.35, 1.2, .8), _flash / .45) + if _flash <= 0.0: + set_process(false) diff --git a/scripts/luneraria/ui/components/resource_display.gd.uid b/scripts/luneraria/ui/components/resource_display.gd.uid new file mode 100644 index 0000000..50e5914 --- /dev/null +++ b/scripts/luneraria/ui/components/resource_display.gd.uid @@ -0,0 +1 @@ +uid://bsto8muqajrtc diff --git a/scripts/luneraria/ui/components/status_badge.gd b/scripts/luneraria/ui/components/status_badge.gd new file mode 100644 index 0000000..ad6712f --- /dev/null +++ b/scripts/luneraria/ui/components/status_badge.gd @@ -0,0 +1,30 @@ +class_name LuneStatusBadge +extends PanelContainer +## Small labelled state (RUHIG, KRITISCH, BEREIT, BOT, COOLDOWN …). Always colour plus text +## and, where it helps, a symbol; never colour alone. Styles come from the theme cache. + +var _row: HBoxContainer +var _glyph: LuneGlyph +var _label: Label +var text: String = "" + +func _init() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + size_flags_vertical = Control.SIZE_SHRINK_CENTER + _row = HBoxContainer.new() + _row.add_theme_constant_override("separation", 4) + _row.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_row) + _glyph = LuneGlyph.make("", Color.WHITE, 12.0, _row) + _glyph.visible = false + _label = LuneUiTheme.label("", "HudBadge", _row) + +func set_state(text_value: String, color: Color, glyph: String = "", filled: bool = false) -> void: + text = text_value + _label.text = text_value + var ink: Color = Color("15110b") if filled else color.lightened(.15) + _label.add_theme_color_override("font_color", ink) + _glyph.visible = not glyph.is_empty() + _glyph.glyph = glyph + _glyph.color = ink + add_theme_stylebox_override("panel", LuneUiTheme.badge_style(color, filled)) diff --git a/scripts/luneraria/ui/components/status_badge.gd.uid b/scripts/luneraria/ui/components/status_badge.gd.uid new file mode 100644 index 0000000..1e00e54 --- /dev/null +++ b/scripts/luneraria/ui/components/status_badge.gd.uid @@ -0,0 +1 @@ +uid://bbtgx3yc7j72l diff --git a/scripts/luneraria/ui/components/tab_bar.gd b/scripts/luneraria/ui/components/tab_bar.gd new file mode 100644 index 0000000..6d45b5e --- /dev/null +++ b/scripts/luneraria/ui/components/tab_bar.gd @@ -0,0 +1,33 @@ +class_name LuneTabBar +extends HBoxContainer +## Tabs with a bronze underline for the open one. `select` changes only the display; +## `tab_changed` fires on player input. + +signal tab_changed(index: int) + +var current: int = 0 +var tabs: Array[Button] = [] +var _group := ButtonGroup.new() + +func _init() -> void: + add_theme_constant_override("separation", 2) + +func add_tab(title: String, tooltip_title: String = "", tooltip_text: String = "") -> Button: + var tab := LuneUiTheme.button(title, "HudTab", self) + tab.toggle_mode = true + tab.button_group = _group + var index: int = tabs.size() + tab.pressed.connect(func() -> void: + current = index + tab_changed.emit(index)) + if not tooltip_title.is_empty(): + LuneTooltip.attach(tab, func() -> Dictionary: return {"title": tooltip_title, "text": tooltip_text}) + tabs.append(tab) + if tabs.size() == 1: + tab.set_pressed_no_signal(true) + return tab + +func select(index: int) -> void: + current = clampi(index, 0, tabs.size() - 1) + for i: int in range(tabs.size()): + tabs[i].set_pressed_no_signal(i == current) diff --git a/scripts/luneraria/ui/components/tab_bar.gd.uid b/scripts/luneraria/ui/components/tab_bar.gd.uid new file mode 100644 index 0000000..385442b --- /dev/null +++ b/scripts/luneraria/ui/components/tab_bar.gd.uid @@ -0,0 +1 @@ +uid://qnfryq6upwdu diff --git a/scripts/luneraria/ui/components/team_panel.gd b/scripts/luneraria/ui/components/team_panel.gd new file mode 100644 index 0000000..c3ec16c --- /dev/null +++ b/scripts/luneraria/ui/components/team_panel.gd @@ -0,0 +1,89 @@ +class_name LuneTeamPanel +extends VBoxContainer +## The own team: per seat the name, lane, hero (committed hero of the others, own draft for +## the viewer), readiness during planning and whether a human, a bot or – after a disconnect – +## a bot on a human's seat plays it. Gold or plans of the others are never shown. + +var _rows: Array[Dictionary] = [] +var _header: Label +var _emblem: LuneGlyph +var _signature: String = "" + +func _init() -> void: + add_theme_constant_override("separation", 3) + mouse_filter = Control.MOUSE_FILTER_IGNORE + var head := HBoxContainer.new() + head.add_theme_constant_override("separation", 6) + head.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(head) + _emblem = LuneGlyph.make("emblem_0", LuneUiTheme.TEAM[0], 15.0, head) + _header = LuneUiTheme.label("DEIN TEAM", "HudCaps", head) + for index: int in range(3): + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + row.mouse_filter = Control.MOUSE_FILTER_PASS + row.custom_minimum_size.y = 28 + add_child(row) + var status := LuneGlyph.make("human", LuneUiTheme.MUTED, 15.0, row) + var portrait := LunePortraitRect.new() + portrait.custom_minimum_size = Vector2(26, 26) + portrait.size_flags_vertical = Control.SIZE_SHRINK_CENTER + row.add_child(portrait) + var name_label := LuneUiTheme.label("", "HudText", row) + name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + name_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + name_label.clip_text = true + var lane_label := LuneUiTheme.label("", "HudMuted", row) + var badge := LuneStatusBadge.new() + row.add_child(badge) + var entry: Dictionary = {"row": row, "status": status, "portrait": portrait, "name": name_label, "lane": lane_label, "badge": badge, "data": {}} + _rows.append(entry) + LuneTooltip.attach(row, _tip.bind(index)) + +func apply(members: Array, team: int, planning: bool, networked: bool) -> void: + var signature: String = "%s|%d|%s|%s" % [str(members), team, planning, networked] + if signature == _signature: + return + _signature = signature + _emblem.glyph = "emblem_%d" % team + _emblem.color = LuneUiTheme.team_color(team) + _header.text = "DEIN TEAM · %s" % LuneUiTheme.team_name(team).to_upper() + for index: int in range(mini(3, members.size())): + var member: Dictionary = members[index] + var row: Dictionary = _rows[index] + row.data = member + var status: String = str(member.status) + (row.status as LuneGlyph).glyph = {"self": "star", "human": "human", "bot": "bot", "offline": "offline"}.get(status, "human") + (row.status as LuneGlyph).color = LuneUiTheme.BRONZE_LIGHT if status == "self" else (LuneUiTheme.DANGER if status == "offline" else LuneUiTheme.MUTED) + var name_label: Label = row.name + name_label.text = str(member.name) + name_label.add_theme_color_override("font_color", LuneUiTheme.BRONZE_LIGHT if member.is_self else LuneUiTheme.TEXT) + (row.lane as Label).text = LuneMap.LANE_NAMES[member.lane].to_upper() + var portrait: LunePortraitRect = row.portrait + var hero_id: String = str(member.hero_id) + portrait.visible = not hero_id.is_empty() + if not hero_id.is_empty(): + portrait.show_model(str(LuneContent.HEROES[hero_id].model)) + var badge: LuneStatusBadge = row.badge + if status == "offline": + badge.set_state("BOT ÜBERNIMMT", LuneUiTheme.DANGER, "offline") + elif status == "bot": + badge.set_state("BOT", LuneUiTheme.MUTED, "bot") + elif planning and member.ready: + badge.set_state("BEREIT", LuneUiTheme.GOOD, "check") + elif planning: + badge.set_state("PLANT", LuneUiTheme.WARN, "clock") + else: + badge.set_state("IM KAMPF", LuneUiTheme.INFO) + +func _tip(index: int) -> Dictionary: + var member: Dictionary = _rows[index].data + if member.is_empty(): + return {} + var kind: String = {"self": "Du", "human": "Mensch", "bot": "Bot", "offline": "Verbindung getrennt – ein Bot spielt den Platz weiter"}.get(str(member.status), "") + var hero: String = "kein Held" + if not str(member.hero_id).is_empty(): + hero = "%s · Stufe %d" % [LuneContent.HEROES[member.hero_id].name, int(member.hero_level)] + return {"title": str(member.name), "subtitle": "%s-Lane · %s" % [LuneMap.LANE_NAMES[member.lane], DataCatalog.FACTION_NAMES.get(member.bund, "")], + "stats": [["human", "Spieler", kind], ["crown", "Held", hero], ["check", "Planung", "bereit" if member.ready else "plant noch"]], + "note": "Gold und laufende Planung anderer Spieler bleiben privat." if not member.is_self else ""} diff --git a/scripts/luneraria/ui/components/team_panel.gd.uid b/scripts/luneraria/ui/components/team_panel.gd.uid new file mode 100644 index 0000000..b62a6c9 --- /dev/null +++ b/scripts/luneraria/ui/components/team_panel.gd.uid @@ -0,0 +1 @@ +uid://3et5leltjecu diff --git a/scripts/luneraria/ui/components/tower_indicator.gd b/scripts/luneraria/ui/components/tower_indicator.gd new file mode 100644 index 0000000..f2954f8 --- /dev/null +++ b/scripts/luneraria/ui/components/tower_indicator.gd @@ -0,0 +1,41 @@ +class_name LuneTowerIndicator +extends Control +## One building as a symbol in the team's shape language (round = Blau, angular = Rot) with +## its HP as a bar underneath. Destroyed buildings remain as a hollow outline with a cross. + +var team: int = 0 +var tier: String = "outer" +var alive: bool = true +var fraction: float = 1.0 +var attackable: bool = false + +func _init() -> void: + mouse_filter = Control.MOUSE_FILTER_PASS + custom_minimum_size = Vector2(22, 27) + +func set_state(is_alive: bool, hp_fraction: float, is_attackable: bool) -> bool: + var value: float = snappedf(clampf(hp_fraction, 0.0, 1.0), .01) + if is_alive == alive and is_equal_approx(value, fraction) and is_attackable == attackable: + return false + alive = is_alive + fraction = value + attackable = is_attackable + queue_redraw() + return true + +func _draw() -> void: + var edge: float = minf(size.x, size.y - 5.0) + var box := Rect2(Vector2((size.x - edge) * .5, 0), Vector2(edge, edge)) + var tint: Color = LuneUiTheme.team_color(team) + var shape: String = "shape_%d" % clampi(team, 0, 1) + if alive: + LuneGlyphs.draw(self, shape, box, LuneUiTheme.TEAM_DARK[clampi(team, 0, 1)].lightened(.1)) + LuneGlyphs.draw(self, shape, box.grow(-edge * .08), Color(tint, .35)) + LuneGlyphs.draw(self, "ancient" if tier == "core" else "tower", box.grow(-edge * .24), Color("f4efe4")) + var bar := Rect2(Vector2(box.position.x + 1, size.y - 3), Vector2(box.size.x - 2, 3)) + draw_rect(bar, Color(0, 0, 0, .6)) + draw_rect(Rect2(bar.position, Vector2(bar.size.x * fraction, bar.size.y)), LuneHpBar.health_color(fraction, tint)) + else: + var outline := Color(.5, .52, .55, .8) + LuneGlyphs.draw(self, shape, box, Color(0, 0, 0, .35)) + LuneGlyphs.draw(self, "close", box.grow(-edge * .18), outline) diff --git a/scripts/luneraria/ui/components/tower_indicator.gd.uid b/scripts/luneraria/ui/components/tower_indicator.gd.uid new file mode 100644 index 0000000..42ddf53 --- /dev/null +++ b/scripts/luneraria/ui/components/tower_indicator.gd.uid @@ -0,0 +1 @@ +uid://dmh0cx8xyy2p2 diff --git a/scripts/luneraria/ui/components/unit_card.gd b/scripts/luneraria/ui/components/unit_card.gd new file mode 100644 index 0000000..7a52482 --- /dev/null +++ b/scripts/luneraria/ui/components/unit_card.gd @@ -0,0 +1,104 @@ +class_name LuneUnitCard +extends Button +## Purchase card: real-model portrait, name, role, gold price, hotkey and availability. +## The champion variant uses the copper card style, a hatched portrait field and an +## "EINMALIG" tag, so temporary attack champions never read like permanent troops. + +const ROLE_GLYPHS: Dictionary = {"front": "tank", "melee": "melee", "ranged": "ranged", "splash": "splash", "support": "support", + "schutz": "tank", "druck": "tempo", "siege": "siege"} +const ROLE_TEXT: Dictionary = {"front": "Tank", "melee": "Nahkampf", "ranged": "Fernkampf", "splash": "Fläche", "support": "Support", + "schutz": "Schutz", "druck": "Druck", "siege": "Belagerung"} + +var type_id: String = "" +var champion: bool = false +var cost: int = 0 +var reason: String = "" +var portrait: LunePortraitRect +var _name: Label +var _role_glyph: LuneGlyph +var _role: Label +var _coin: LuneGlyph +var _cost: Label +var _key: Label +var _tag: HBoxContainer +var _lock: LuneGlyph + +func _init() -> void: + theme_type_variation = "HudCard" + focus_mode = Control.FOCUS_ALL + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + custom_minimum_size = Vector2(116, 132) + var column := VBoxContainer.new() + column.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + column.offset_left = 6 + column.offset_right = -6 + column.offset_top = 6 + column.offset_bottom = -5 + column.add_theme_constant_override("separation", 1) + column.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(column) + portrait = LunePortraitRect.new() + portrait.custom_minimum_size = Vector2(0, 62) + portrait.size_flags_vertical = Control.SIZE_EXPAND_FILL + column.add_child(portrait) + _key = LuneUiTheme.label("", "HudCaps", portrait) + _key.position = Vector2(4, 1) + _key.add_theme_color_override("font_color", LuneUiTheme.BRONZE_LIGHT) + _tag = HBoxContainer.new() + _tag.add_theme_constant_override("separation", 2) + _tag.mouse_filter = Control.MOUSE_FILTER_IGNORE + _tag.visible = false + portrait.add_child(_tag) + _tag.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT) + _tag.grow_horizontal = Control.GROW_DIRECTION_BEGIN + _tag.offset_right = -3 + _tag.offset_top = 1 + LuneGlyph.make("hourglass", LuneUiTheme.CHAMPION, 13.0, _tag) + var once := LuneUiTheme.label("1 WELLE", "HudCaps", _tag) + once.add_theme_color_override("font_color", LuneUiTheme.CHAMPION) + _lock = LuneGlyph.make("lock", LuneUiTheme.MUTED, 18.0, portrait) + _lock.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + _lock.visible = false + _name = LuneUiTheme.label("", "HudText", column) + _name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _name.clip_text = true + var role_row := HBoxContainer.new() + role_row.alignment = BoxContainer.ALIGNMENT_CENTER + role_row.add_theme_constant_override("separation", 4) + role_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(role_row) + _role_glyph = LuneGlyph.make("", LuneUiTheme.MUTED, 14.0, role_row) + _role = LuneUiTheme.label("", "HudMuted", role_row) + var cost_row := HBoxContainer.new() + cost_row.alignment = BoxContainer.ALIGNMENT_CENTER + cost_row.add_theme_constant_override("separation", 4) + cost_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(cost_row) + _coin = LuneGlyph.make("coin", LuneUiTheme.GOLD, 15.0, cost_row) + _cost = LuneUiTheme.label("", "HudValue", cost_row) + +func setup(id: String, model_id: String, title: String, role_key: String, price: int, hotkey: String = "", is_champion: bool = false) -> void: + type_id = id + champion = is_champion + cost = price + theme_type_variation = "HudChampionCard" if champion else "HudCard" + portrait.show_model(model_id) + _name.text = title + _role_glyph.glyph = ROLE_GLYPHS.get(role_key, "melee") + _role.text = ROLE_TEXT.get(role_key, role_key) + _cost.text = str(price) + _key.text = hotkey + _key.visible = not hotkey.is_empty() + _tag.visible = champion + portrait.hatch = Color(LuneUiTheme.CHAMPION, .13) if champion else Color(0, 0, 0, 0) + +## ok = purchasable now; otherwise `why` explains it (tooltip) and the card greys out. +func set_available(ok: bool, why: String = "", locked: bool = false, unaffordable: bool = false) -> void: + reason = why + if disabled != (not ok): + disabled = not ok + portrait.dimmed = not ok + _lock.visible = locked + _cost.add_theme_color_override("font_color", LuneUiTheme.DANGER if unaffordable else LuneUiTheme.BRIGHT) + _name.add_theme_color_override("font_color", LuneUiTheme.MUTED if not ok else LuneUiTheme.TEXT) diff --git a/scripts/luneraria/ui/components/unit_card.gd.uid b/scripts/luneraria/ui/components/unit_card.gd.uid new file mode 100644 index 0000000..6b055a2 --- /dev/null +++ b/scripts/luneraria/ui/components/unit_card.gd.uid @@ -0,0 +1 @@ +uid://sn6e1d8jkim7 diff --git a/scripts/luneraria/ui/components/unit_detail.gd b/scripts/luneraria/ui/components/unit_detail.gd new file mode 100644 index 0000000..c09cca2 --- /dev/null +++ b/scripts/luneraria/ui/components/unit_detail.gd @@ -0,0 +1,197 @@ +class_name LuneUnitDetail +extends PanelContainer +## The selected normal unit: portrait, name, stage, role, HP, damage, tempo, range, armour, +## ability, upgrade with cost and stat change, return value, and the lane order with TOP, +## MID, BOTTOM and GROTTE (only allowed options active). Normal units carry no items, so this +## panel has no item slots. In combat or after Ready it only informs. + +signal command_requested(command: Dictionary) +signal closed + +const GROTTO_WARNING := "Diese Einheit fehlt deiner Lane in der nächsten Kampfphase." + +var uid: String = "" +var _portrait: LunePortraitRect +var _name: Label +var _sub: Label +var _live: LuneHpBar +var _stats: GridContainer +var _ability: Label +var _types: Label +var _upgrade_box: VBoxContainer +var _upgrade_title: Label +var _upgrade_delta: Label +var _upgrade: Button +var _sell: Button +var _sell_note: Label +var _lanes: Dictionary = {} +var _lane_row: HBoxContainer +var _warning: Label +var _locked: Label +var _signature: String = "" + +func _init() -> void: + theme_type_variation = "HudPanel" + mouse_filter = Control.MOUSE_FILTER_STOP + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 5) + add_child(column) + var head := HBoxContainer.new() + head.add_theme_constant_override("separation", 10) + column.add_child(head) + _portrait = LunePortraitRect.new() + _portrait.custom_minimum_size = Vector2(76, 76) + head.add_child(_portrait) + var titles := VBoxContainer.new() + titles.size_flags_horizontal = Control.SIZE_EXPAND_FILL + titles.add_theme_constant_override("separation", 2) + head.add_child(titles) + _name = LuneUiTheme.label("", "HudName", titles) + _name.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _sub = LuneUiTheme.label("", "HudMuted", titles) + _live = LuneHpBar.new() + _live.custom_minimum_size = Vector2(0, 6) + titles.add_child(_live) + var close := LuneUiTheme.button("", "HudIconButton", head) + close.custom_minimum_size = Vector2(32, 30) + close.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + var cross := LuneGlyph.make("close", LuneUiTheme.MUTED, 12.0, close) + cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + close.pressed.connect(func() -> void: closed.emit()) + LuneTooltip.attach(close, func() -> Dictionary: return {"title": "Auswahl aufheben", "text": "Esc"}) + _stats = GridContainer.new() + _stats.columns = 4 + _stats.add_theme_constant_override("h_separation", 6) + _stats.add_theme_constant_override("v_separation", 2) + column.add_child(_stats) + LuneTooltip.attach(_stats, _stats_tip) + _types = LuneUiTheme.label("", "HudMuted", column) + _types.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _ability = LuneUiTheme.label("", "HudText", column) + _ability.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _upgrade_box = VBoxContainer.new() + _upgrade_box.add_theme_constant_override("separation", 2) + column.add_child(_upgrade_box) + var upgrade_row := HBoxContainer.new() + upgrade_row.add_theme_constant_override("separation", 8) + _upgrade_box.add_child(upgrade_row) + _upgrade_title = LuneUiTheme.label("", "HudText", upgrade_row) + _upgrade_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _upgrade_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _upgrade = LuneUiTheme.button("", "HudPrimary", upgrade_row) + _upgrade.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _upgrade.pressed.connect(func() -> void: command_requested.emit({"type": "upgrade", "uid": uid})) + _upgrade_delta = LuneUiTheme.label("", "HudMuted", _upgrade_box) + _upgrade_delta.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _lane_row = HBoxContainer.new() + _lane_row.add_theme_constant_override("separation", 4) + column.add_child(_lane_row) + for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]: + var title: String = "GROTTE" if lane == LuneMatch.GROTTO else LuneMap.LANE_NAMES[lane].to_upper() + var button := LuneUiTheme.button(title, "HudToggle", _lane_row) + button.toggle_mode = true + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.pressed.connect(func() -> void: + command_requested.emit({"type": "assign", "uid": uid, "lane": lane})) + _lanes[lane] = button + LuneTooltip.attach(button, func() -> Dictionary: return {"title": "Einsatz: %s" % title, + "text": GROTTO_WARNING if lane == LuneMatch.GROTTO else "Kämpft in der nächsten Welle auf der %s-Lane." % LuneMap.LANE_NAMES[lane]}) + _warning = LuneUiTheme.label(GROTTO_WARNING, "HudText", column) + _warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _warning.add_theme_color_override("font_color", LuneUiTheme.WARN) + var sell_row := HBoxContainer.new() + sell_row.add_theme_constant_override("separation", 8) + column.add_child(sell_row) + _sell = LuneUiTheme.button("", "HudButton", sell_row) + _sell.pressed.connect(func() -> void: command_requested.emit({"type": "sell", "uid": uid})) + _sell_note = LuneUiTheme.label("", "HudMuted", sell_row) + _sell_note.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _sell_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _locked = LuneUiTheme.label("", "HudMuted", column) + _locked.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + +## entry: own roster entry (uid, type_id, lane, invested, fresh); live: LuneUnit in combat or null. +func apply(entry: Dictionary, model: LuneHudModel, live: LuneUnit) -> void: + uid = str(entry.uid) + var type_id: String = str(entry.type_id) + var data: Dictionary = model.troop(type_id) + var planning: bool = model.planning() + var locked: bool = model.locked() + var gold: int = model.own_gold() + var signature: String = "%s|%s|%s|%d|%s|%s|%d" % [str(entry), planning, locked, gold, model.state.grotto_available(), live != null, model.state.round_number] + if live != null: + _live.fraction = live.hp / maxf(1.0, live.max_hp) if live.alive else 0.0 + _live.fill = LuneHpBar.health_color(_live.fraction, LuneUiTheme.team_color(model.team())) + _live.visible = live != null + if signature == _signature: + return + _signature = signature + _portrait.show_model(type_id) + _name.text = str(data.display_name) + var lane_text: String = "Grotte" if entry.lane == LuneMatch.GROTTO else LuneMap.LANE_NAMES[entry.lane] + _sub.text = "%s · Stufe %s · %s" % [LuneStats.role_name(data.role), "II" if int(data.stage) == 2 else "I", lane_text] + if live != null and not live.alive: + _sub.text += " · gefallen" + for child: Node in _stats.get_children(): + child.free() + var rows: Array = LuneStats.short_rows(data) + for row: Array in rows.slice(0, 4): + LuneGlyph.make(str(row[0]), LuneUiTheme.MUTED, 14.0, _stats) + var cell := LuneUiTheme.label(str(row[1]), "HudText", _stats) + cell.size_flags_horizontal = Control.SIZE_EXPAND_FILL + cell.clip_text = true + cell.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + var attack_type: int = int(data.attack_type) + _types.text = "Rüstung %s · Angriff %s, stark gegen %s" % [DataCatalog.ARMOR_NAMES[int(data.armor_type)], DataCatalog.DAMAGE_NAMES[attack_type], LuneStats.strong_against(attack_type)] + _ability.text = str(data.get("ability_description", "")) + var upgrade_id: String = str(data.get("upgrade_id", "")) + var can_act: bool = planning and not locked + if upgrade_id.is_empty(): + _upgrade_title.text = "Höchste Stufe erreicht." + _upgrade.visible = false + _upgrade_delta.text = "" + else: + var next: Dictionary = DataCatalog.get_unit(upgrade_id) + var cost: int = int(next.cost) + _upgrade_title.text = "Aufwerten → %s" % next.display_name + _upgrade.visible = true + _upgrade.text = "↑ %d" % cost + _upgrade.disabled = not can_act or gold < cost + var delta: String = "Leben %s → %s · Schaden %s → %s" % [LuneUiTheme.number(data.max_hp), LuneUiTheme.number(next.max_hp), LuneUiTheme.decimal(data.damage), LuneUiTheme.decimal(next.damage)] + if not is_equal_approx(float(data.attack_range), float(next.attack_range)): + delta += " · Reichweite %s → %s m" % [LuneUiTheme.decimal(data.attack_range), LuneUiTheme.decimal(next.attack_range)] + if can_act and gold < cost: + delta += " · es fehlen %d Gold" % (cost - gold) + _upgrade_delta.text = delta + var fresh: bool = bool(entry.get("fresh", false)) + var refund: int = int(entry.invested) if fresh else floori(float(entry.invested) * model.state.balance.sell_refund) + _sell.text = "Zurückgeben +%d" % refund + _sell.disabled = not can_act + _sell.visible = planning + _sell_note.text = "voller Preis" if fresh else "%d %% von %d" % [int(round(model.state.balance.sell_refund * 100.0)), int(entry.invested)] + _sell_note.visible = planning + var grotto_open: bool = model.state.grotto_available() + for lane: String in _lanes: + var button: Button = _lanes[lane] + button.set_pressed_no_signal(str(entry.lane) == lane) + button.disabled = not can_act or (lane == LuneMatch.GROTTO and not grotto_open) + _warning.visible = str(entry.lane) == LuneMatch.GROTTO + if not planning: + _locked.text = "Im Kampf gibt es keine Befehle; Aufwertung, Rückgabe und Einsatz wieder in der Planung." + elif locked: + _locked.text = "Bereit gemeldet: Die Planung ist verbindlich." + elif not grotto_open: + _locked.text = "Grotte gesperrt bis Welle %d." % int(model.state.boss.next_available_round) + else: + _locked.text = "" + _locked.visible = not _locked.text.is_empty() + +func _stats_tip() -> Dictionary: + var entry_type: String = "" + if _portrait != null: + entry_type = _portrait.model_id + if entry_type.is_empty(): + return {} + var data: Dictionary = DataCatalog.get_unit(entry_type) + return {"title": str(data.display_name), "stats": LuneStats.rows(data, true), + "note": "Ein Angriff pro Sekunde mal Tempo; Schaden je Treffer vor Rüstung. Stark: 130 %, schwach: 75 % (Rüstungsmatrix)."} diff --git a/scripts/luneraria/ui/components/unit_detail.gd.uid b/scripts/luneraria/ui/components/unit_detail.gd.uid new file mode 100644 index 0000000..31a4c83 --- /dev/null +++ b/scripts/luneraria/ui/components/unit_detail.gd.uid @@ -0,0 +1 @@ +uid://cv0bm6mhspnex diff --git a/scripts/luneraria/ui/lune_hero_panel.gd b/scripts/luneraria/ui/lune_hero_panel.gd deleted file mode 100644 index 348fe4d..0000000 --- a/scripts/luneraria/ui/lune_hero_panel.gd +++ /dev/null @@ -1,186 +0,0 @@ -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 diff --git a/scripts/luneraria/ui/lune_hero_panel.gd.uid b/scripts/luneraria/ui/lune_hero_panel.gd.uid deleted file mode 100644 index 89f86f0..0000000 --- a/scripts/luneraria/ui/lune_hero_panel.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cwjygujajwr7r diff --git a/scripts/luneraria/ui/lune_hud.gd b/scripts/luneraria/ui/lune_hud.gd index a0e20fe..4fa231f 100644 --- a/scripts/luneraria/ui/lune_hud.gd +++ b/scripts/luneraria/ui/lune_hud.gd @@ -1,6 +1,16 @@ extends Control -## Champions of Luneraria HUD. Presentation only: it reads LuneMatch and sends the human -## player's commands through the owning LuneGame; UI state never creates ownership. +## Champions of Luneraria in-match HUD (G3). Presentation only: it reads the match through +## LuneHudModel (the privacy filter) and sends the human player's commands through LuneGame; +## no UI state creates ownership or changes a rule. +## +## Layout in logical pixels (1706×960 at 16:9, UI scale applied on top): +## top match status: own Ancient, WELLE n and phase, enemy Ancient; options and menu +## left lanes (threat, towers, players, announced dangers), own team, event feed +## bottom gold, unit cards or attack champions, own army, hero +## right context only: boss grotto, next wave, the selected unit +## corner Bereit (planning) or pause and tempo (combat) above the minimap +## Updates follow changes: every component compares a signature and redraws only when its +## data changed; the HUD polls at 10 Hz in planning and 5 Hz in combat, the minimap at 10 Hz. signal command_requested(command: Dictionary) signal undo_requested @@ -10,256 +20,423 @@ signal speed_requested(value: int) signal pause_requested signal lane_focus_requested(lane: String) signal map_focus_requested(point: Vector3) +signal camera_requested(target: String) signal menu_requested signal rematch_requested signal settings_requested const Minimap = preload("res://scripts/luneraria/ui/lune_minimap.gd") -const HeroPanel = preload("res://scripts/luneraria/ui/lune_hero_panel.gd") -const ReinforcePanel = preload("res://scripts/luneraria/ui/lune_reinforce_panel.gd") -const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55")] -const TEAM_NAMES: Array[String] = ["Blau", "Rot"] -const ROLE_NAMES: Dictionary = {"front": "Front", "melee": "Nahkampf", "ranged": "Fernkampf", "splash": "Fläche", "support": "Unterstützung"} +const MARGIN: float = 12.0 +const LEFT_WIDTH: float = 312.0 +const RIGHT_WIDTH: float = 322.0 +const MAP_EDGE: float = 224.0 +const READY_HEIGHT: float = 84.0 +const READY_COMBAT: float = 54.0 +const BAR_PLANNING: float = 176.0 +const BAR_COMBAT: float = 128.0 +## Smallest logical area the layout is built for; the UI scale never shrinks below it. +const MIN_AREA := Vector2(1560, 880) +const LANE_KEYS: Dictionary = {"top": "F2", "mid": "F3", "bot": "F4"} +const PLANNING_RATE: float = .1 +const COMBAT_RATE: float = .2 +const MAP_RATE: float = .1 +const FEED_RATE: float = .25 var match_state: LuneMatch var human_slot: int = 1 +## Optional LuneSession (network display only: seats, connection). +var session: Node +var model: LuneHudModel var minimap: Control -var _phase: Label -var _round: Label -var _core_bars: Array[ProgressBar] = [] -var _core_labels: Array[Label] = [] -var _gold: Label -var _income: Label -var _identity: Label +var tooltip: LuneTooltip +var ui_scale: float = 1.0 +var hero_card: LuneHeroCard +var hero_detail: LuneHeroDetail +var champion_panel: LuneChampionPanel +var boss_card: LuneBossIndicator +var next_wave: LuneNextWave +var unit_detail: LuneUnitDetail +var feed: LuneEventFeed +var lanes: Dictionary = {} +var selected_uid: String = "" +## Counters for tests and the performance log. +var syncs: int = 0 +var sync_usec: float = 0.0 + +var _top: PanelContainer +var _ancients: Array[Dictionary] = [] +var _wave: Label +var _phase_badge: LuneStatusBadge +var _system: HBoxContainer +var _left: VBoxContainer +var _team: LuneTeamPanel +var _right: VBoxContainer +var _bottom: PanelContainer +var _gold: LuneResourceDisplay +var _undo: Button +var _starter: Button +var _purchase: VBoxContainer +var _purchase_tabs: LuneTabBar +var _troop_row: HBoxContainer +var _shop_buttons: Dictionary = {} +var _army: VBoxContainer +var _army_title: Label +var _army_lanes: Label +var _strip: HFlowContainer +var _chips: Dictionary = {} +var _chip_signature: String = "@" +var _ready_box: PanelContainer +var _ready_button: Button +var _ready_note: Label +var _ready_check: LuneGlyph +var _combat_row: HBoxContainer +var _clock: Label +var _auto: Label +## Bereit ignores clicks and Space for a moment after a combat ended (stale clicks). +var _ready_block_until: int = 0 var _pause: Button var _speed: Button -var _lane_rows: Dictionary = {} -var _team_list: Label -var _plan_panel: PanelContainer -var _shop: GridContainer -var _shop_buttons: Dictionary = {} -var _roster: VBoxContainer -var _roster_title: Label -var _ready_button: Button -var _starter: Button -var _undo: Button -var _combat_panel: PanelContainer -var _combat_info: Label +var _map_panel: PanelContainer +var _banners: VBoxContainer +var _banner_signature: String = "@" +var _status: PanelContainer var _report: Label var _toast: Label var _toast_left: float = 0.0 +var _modal: LuneModalPanel var _over: Control var _over_title: Label var _over_body: Label -var _roster_signature: String = "@" -var hero_panel: VBoxContainer -var reinforce_panel: VBoxContainer -var _pages: Array[Control] = [] -var _tabs: ChoiceGroup -var _boss_status: Label +var _tick: float = 0.0 +var _map_tick: float = 0.0 +var _feed_tick: float = 0.0 +var _dirty: bool = true +var _last_phase: int = -1 +var _last_round: int = -1 +var _layout_phase: int = -1 +var _known_alive: Dictionary = {} +var _ancient_level: Array[int] = [0, 0] +var _boss_seen: Dictionary = {} +var _hero_level: int = 0 +# ---------------------------------------------------------------- build func _ready() -> void: - theme = SaumwachtTheme.shared() - set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + theme = LuneUiTheme.shared() mouse_filter = Control.MOUSE_FILTER_IGNORE - _top_bar() - _lane_panel() - _plan_side() - _bottom() - _game_over() + model = LuneHudModel.new(match_state, human_slot, session) + _build_top() + _build_system() + _build_left() + _build_right() + _build_bottom() + _build_ready() + _build_map() + _build_banner() + _build_status() + _build_modal() + _build_over() + tooltip = LuneTooltip.new() + add_child(tooltip) + # Mouse wheel over any HUD panel must not zoom the world (Godot passes scroll events on by default). + for node: Node in find_children("*", "Control", true, false): + var control := node as Control + if control.mouse_filter == Control.MOUSE_FILTER_STOP: + control.mouse_force_pass_scroll_events = false + get_viewport().size_changed.connect(_fit) + Preferences.changed.connect(_fit) + _fit() + _remember_buildings() + _hero_level = _own_hero_level() -func _panel(variation: String = "CaptionPanel") -> PanelContainer: +func _panel(variation: String = "HudPanel") -> PanelContainer: var panel := PanelContainer.new() panel.theme_type_variation = variation panel.mouse_filter = Control.MOUSE_FILTER_STOP add_child(panel) return panel -func _label(text_value: String, variation: String, parent: Node, size: int = 0) -> Label: - var label := Label.new() - label.text = text_value - label.theme_type_variation = variation - label.mouse_filter = Control.MOUSE_FILTER_IGNORE - if size > 0: - label.add_theme_font_size_override("font_size", size) - parent.add_child(label) - return label +func _box(vertical: bool, separation: int, parent: Node) -> BoxContainer: + var box: BoxContainer = VBoxContainer.new() if vertical else HBoxContainer.new() + box.add_theme_constant_override("separation", separation) + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + parent.add_child(box) + return box -func _button(text_value: String, parent: Node, action: Callable, variation: String = "Button") -> Button: - var button := Button.new() - button.text = text_value - button.theme_type_variation = variation - button.focus_mode = Control.FOCUS_ALL - button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND - button.pressed.connect(action) - parent.add_child(button) +func _spacer(parent: Node) -> Control: + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE + parent.add_child(spacer) + return spacer + +func _build_top() -> void: + _top = _panel() + var row: HBoxContainer = _box(false, 20, _top) + for side: int in range(2): + var block: Dictionary = _ancient_block(side == 0) + _ancients.append(block) + if side == 0: + row.add_child(block.box) + var center: VBoxContainer = _box(true, 2, row) + center.custom_minimum_size.x = 190 + center.alignment = BoxContainer.ALIGNMENT_CENTER + _wave = LuneUiTheme.label("WELLE 1", "HudWave", center) + _wave.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _phase_badge = LuneStatusBadge.new() + _phase_badge.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + center.add_child(_phase_badge) + LuneTooltip.attach(_top, _wave_tip) + else: + row.add_child(block.box) + _top.minimum_size_changed.connect(_layout) + +## Ancient block: team emblem, name, HP bar and value, the two base towers, critical badge. +func _ancient_block(own: bool) -> Dictionary: + var box := HBoxContainer.new() + box.add_theme_constant_override("separation", 10) + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.custom_minimum_size.x = 300 + var emblem := LuneGlyph.make("emblem_0", LuneUiTheme.TEAM[0], 36.0) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 3) + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + column.mouse_filter = Control.MOUSE_FILTER_IGNORE + if own: + box.add_child(emblem) + box.add_child(column) + else: + box.add_child(column) + box.add_child(emblem) + var head := HBoxContainer.new() + head.add_theme_constant_override("separation", 6) + head.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(head) + var caption := LuneUiTheme.label("", "HudCaps") + var badge := LuneStatusBadge.new() + badge.visible = false + if own: + head.add_child(caption) + _spacer(head) + head.add_child(badge) + else: + head.add_child(badge) + _spacer(head) + head.add_child(caption) + var bar := LuneHpBar.new() + bar.custom_minimum_size = Vector2(240, 10) + bar.ticks = 4 + column.add_child(bar) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 5) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + column.add_child(row) + var value := LuneUiTheme.label("", "HudValue") + var base_label := LuneUiTheme.label("BASIS", "HudMuted") + var towers: Array[LuneTowerIndicator] = [] + for index: int in range(2): + var tower := LuneTowerIndicator.new() + tower.custom_minimum_size = Vector2(18, 22) + tower.tier = "base" + towers.append(tower) + if own: + row.add_child(value) + _spacer(row) + row.add_child(base_label) + for tower: LuneTowerIndicator in towers: + row.add_child(tower) + else: + for tower: LuneTowerIndicator in towers: + row.add_child(tower) + row.add_child(base_label) + _spacer(row) + row.add_child(value) + var block: Dictionary = {"box": box, "emblem": emblem, "caption": caption, "badge": badge, "bar": bar, "value": value, + "towers": towers, "pulse": 0.0, "own": own, "data": {}} + LuneTooltip.attach(box, _ancient_tip.bind(block)) + for index: int in range(2): + LuneTooltip.attach(towers[index], _base_tip.bind(block, index)) + return block + +func _build_system() -> void: + _system = HBoxContainer.new() + _system.add_theme_constant_override("separation", 6) + _system.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_system) + # Pause and tempo live up here, far from Bereit: a late click at the end of a combat must + # never land on Bereit (found in the G3 playtest). + _pause = _icon_button("pause", "P", _system) + _pause.visible = false + _pause.pressed.connect(func() -> void: pause_requested.emit()) + LuneTooltip.attach(_pause, func() -> Dictionary: return {"title": "Pause", "text": "Hält die Simulation an (offline). Im Netzspiel bestimmt der Host.", "note": "P oder Leertaste"}) + _speed = LuneUiTheme.button("1×", "HudIconButton", _system) + _speed.custom_minimum_size = Vector2(46, 36) + _speed.visible = false + _speed.pressed.connect(func() -> void: speed_requested.emit(0)) + LuneTooltip.attach(_speed, func() -> Dictionary: return {"title": "Tempo", "text": "Wechselt 1× · 2× · 4×; die Regeln bleiben gleich."}) + var options := _icon_button("gear", "F1", _system) + options.pressed.connect(func() -> void: settings_requested.emit()) + LuneTooltip.attach(options, func() -> Dictionary: return {"title": "Optionen", "text": "Lautstärke, Vollbild, Bewegung reduzieren, UI-Größe und alle Tasten.", "note": "F1"}) + var menu := _icon_button("menu", "Menü", _system) + menu.pressed.connect(func() -> void: menu_requested.emit()) + LuneTooltip.attach(menu, func() -> Dictionary: return {"title": "Hauptmenü", "text": "Verlässt die Partie."}) + _system.minimum_size_changed.connect(_layout) + +func _icon_button(glyph: String, text_value: String, parent: Node) -> Button: + var button := LuneUiTheme.button("", "HudIconButton", parent) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 5) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + row.alignment = BoxContainer.ALIGNMENT_CENTER + button.add_child(row) + LuneGlyph.make(glyph, LuneUiTheme.TEXT, 15.0, row) + var label := LuneUiTheme.label(text_value, "HudText", row) + button.custom_minimum_size = Vector2(maxf(40.0, 30.0 + text_value.length() * 8.5), 36) + button.set_meta("label", label) return button -func _bar(color: Color, parent: Node) -> ProgressBar: - var bar := ProgressBar.new() - bar.show_percentage = false - bar.custom_minimum_size = Vector2(190, 10) - var back := StyleBoxFlat.new() - back.bg_color = Color(0, 0, 0, .55) - back.border_color = Color("5c4a33") - back.set_border_width_all(1) - var fill := StyleBoxFlat.new() - fill.bg_color = color - bar.add_theme_stylebox_override("background", back) - bar.add_theme_stylebox_override("fill", fill) - parent.add_child(bar) - return bar - -func _top_bar() -> void: - var top := _panel() - top.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) - top.offset_left = 16 - top.offset_right = -16 - top.offset_top = 12 - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 22) - top.add_child(row) - var title := VBoxContainer.new() - title.add_theme_constant_override("separation", 0) - row.add_child(title) - _label("CHAMPIONS OF LUNERARIA", "EyebrowLabel", title) - _phase = _label("PLANUNG", "SectionLabel", title, 20) - _round = _label("Welle 1", "MutedLabel", title) - for team: int in range(2): - var box := VBoxContainer.new() - box.add_theme_constant_override("separation", 3) - row.add_child(box) - _core_labels.append(_label("Ancient %s" % TEAM_NAMES[team], "MutedLabel", box, 15)) - _core_bars.append(_bar(TEAM_COLORS[team], box)) - var economy := VBoxContainer.new() - economy.add_theme_constant_override("separation", 0) - row.add_child(economy) - _gold = _label("0 Gold", "CardTitleLabel", economy, 24) - _income = _label("", "MutedLabel", economy, 15) - _identity = _label("", "MutedLabel", row, 15) - _identity.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _identity.size_flags_vertical = Control.SIZE_SHRINK_CENTER - _pause = _button("Pause · P", row, func() -> void: pause_requested.emit()) - _speed = _button("1×", row, func() -> void: speed_requested.emit(0)) - _button("Optionen · F1", row, func() -> void: settings_requested.emit()) - _button("Menü", row, func() -> void: menu_requested.emit(), "QuietButton") - -func _lane_panel() -> void: - var panel := _panel() - panel.position = Vector2(16, 112) - panel.custom_minimum_size = Vector2(300, 0) - var column := VBoxContainer.new() - column.add_theme_constant_override("separation", 6) - panel.add_child(column) - _label("LANES · TÜRME", "SectionLabel", column) +func _build_left() -> void: + _left = _box(true, 8, self) as VBoxContainer + var lane_panel := PanelContainer.new() + lane_panel.theme_type_variation = "HudPanel" + _left.add_child(lane_panel) + var column: VBoxContainer = _box(true, 5, lane_panel) + var head: HBoxContainer = _box(false, 6, column) + LuneUiTheme.label("LANES", "HudCaps", head) + _spacer(head) + LuneUiTheme.label("Klick oder F2–F4", "HudMuted", head) for lane: String in LuneMap.LANES: - var button := _button("", column, func() -> void: lane_focus_requested.emit(lane), "ChoiceButton") - button.custom_minimum_size = Vector2(0, 52) - button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.tooltip_text = "Kamera auf %s" % LuneMap.LANE_NAMES[lane] - _lane_rows[lane] = button - _team_list = _label("", "MutedLabel", column, 14) - _boss_status = _label("", "Label", column, 15) - _boss_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _boss_status.custom_minimum_size.x = 264 + var indicator := LuneLaneIndicator.new(lane, LANE_KEYS[lane]) + column.add_child(indicator) + indicator.pressed.connect(func() -> void: lane_focus_requested.emit(lane)) + lanes[lane] = indicator + var team_panel := PanelContainer.new() + team_panel.theme_type_variation = "HudPanel" + _left.add_child(team_panel) + _team = LuneTeamPanel.new() + team_panel.add_child(_team) + feed = LuneEventFeed.new() + _left.add_child(feed) + feed.focus_requested.connect(_focus_target) -func _plan_side() -> void: - _plan_panel = _panel("MenuPanel") - _plan_panel.anchor_left = 1.0 - _plan_panel.anchor_right = 1.0 - _plan_panel.anchor_bottom = 1.0 - _plan_panel.offset_left = -500 - _plan_panel.offset_right = -16 - _plan_panel.offset_top = 112 - _plan_panel.offset_bottom = -270 - var outer := VBoxContainer.new() - outer.add_theme_constant_override("separation", 8) - _plan_panel.add_child(outer) - _tabs = ChoiceGroup.new(true) - _tabs.name = "PlanTabs" - _tabs.add_option("Truppen") - _tabs.add_option("Held") - _tabs.add_option("Verstärkung") - outer.add_child(_tabs) - _tabs.item_selected.connect(show_page) - var column := VBoxContainer.new() - column.add_theme_constant_override("separation", 8) - column.size_flags_vertical = Control.SIZE_EXPAND_FILL - outer.add_child(column) - _pages.append(column) - _label("TRUPPEN KAUFEN · 1–5", "SectionLabel", column) - _shop = GridContainer.new() - _shop.columns = 5 - _shop.add_theme_constant_override("h_separation", 6) - column.add_child(_shop) - _roster_title = _label("DEINE ARMEE", "SectionLabel", column) - var scroll := ScrollContainer.new() - scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - column.add_child(scroll) - _roster = VBoxContainer.new() - _roster.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _roster.add_theme_constant_override("separation", 4) - scroll.add_child(_roster) - 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() - outer.add_child(actions) - _starter = _button("Startaufstellung", actions, func() -> void: starter_requested.emit()) - _starter.tooltip_text = "Kauft Front, Nahkampf und Fernkampf deines Bundes für deine Lane." - _undo = _button("Rückgängig", actions, func() -> void: undo_requested.emit()) - _ready_button = _button("BEREIT · KAMPF ›", outer, func() -> void: ready_requested.emit(), "PrimaryButton") - _ready_button.custom_minimum_size.y = 58 - _ready_button.tooltip_text = "Leertaste · Befehle werden jetzt verbindlich übernommen." - _combat_panel = _panel() - _combat_panel.anchor_left = 1.0 - _combat_panel.anchor_right = 1.0 - _combat_panel.offset_left = -500 - _combat_panel.offset_right = -16 - _combat_panel.offset_top = 112 - _combat_info = _label("", "Label", _combat_panel, 16) +func _build_right() -> void: + _right = _box(true, 8, self) as VBoxContainer + boss_card = LuneBossIndicator.new() + _right.add_child(boss_card) + boss_card.focus_requested.connect(func() -> void: camera_requested.emit("grotto")) + boss_card.lane_chosen.connect(func(lane: String) -> void: command_requested.emit({"type": "boss_lane", "lane": lane})) + next_wave = LuneNextWave.new() + _right.add_child(next_wave) + unit_detail = LuneUnitDetail.new() + unit_detail.visible = false + _right.add_child(unit_detail) + unit_detail.command_requested.connect(func(command: Dictionary) -> void: command_requested.emit(command)) + unit_detail.closed.connect(clear_selection) -func _bottom() -> void: - var report_panel := _panel() - report_panel.anchor_top = 1.0 - report_panel.anchor_bottom = 1.0 - report_panel.offset_left = 16 - report_panel.offset_top = -120 - report_panel.offset_bottom = -16 - report_panel.offset_right = 760 - var column := VBoxContainer.new() - report_panel.add_child(column) - _report = _label("Planung: Kaufe Truppen für deine Lane und drücke Bereit.", "Label", column, 16) - _report.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _toast = _label("", "MutedLabel", column, 15) - var map_panel := _panel() - map_panel.anchor_left = 1.0 - map_panel.anchor_right = 1.0 - map_panel.anchor_top = 1.0 - map_panel.anchor_bottom = 1.0 - map_panel.offset_left = -270 - map_panel.offset_right = -16 - map_panel.offset_top = -258 - map_panel.offset_bottom = -16 +func _build_bottom() -> void: + _bottom = _panel() + var row: HBoxContainer = _box(false, 14, _bottom) + # Gold and undo. + var gold_column: VBoxContainer = _box(true, 4, row) + gold_column.custom_minimum_size.x = 150 + LuneUiTheme.label("GOLD", "HudCaps", gold_column) + _gold = LuneResourceDisplay.new() + gold_column.add_child(_gold) + LuneTooltip.attach(_gold, _gold_tip) + _undo = LuneUiTheme.button("Rückgängig", "HudButton", gold_column) + _undo.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _undo.pressed.connect(func() -> void: undo_requested.emit()) + LuneTooltip.attach(_undo, func() -> Dictionary: return {"title": "Rückgängig", "text": "Nimmt den letzten Befehl dieser Planung zurück (Kauf, Einsatz, Item, Buchung)."}) + # Purchases: troops (1–5) or attack champions. + _purchase = _box(true, 4, row) as VBoxContainer + _purchase_tabs = LuneTabBar.new() + _purchase.add_child(_purchase_tabs) + _purchase_tabs.add_tab("TRUPPEN · 1–5", "Dauerhafte Truppen", "Bleiben in deiner Armee, kämpfen jede Welle, lassen sich aufwerten.") + _purchase_tabs.add_tab("ANGRIFFSCHAMPIONS", "Angriffschampions", "Einmalige Söldner: jetzt kaufen, eine Welle später auf der Ziel-Lane angreifen, danach weg.") + _purchase_tabs.tab_changed.connect(_show_purchase) + _troop_row = _box(false, 6, _purchase) as HBoxContainer + for index: int in range(5): + var card := LuneUnitCard.new() + _troop_row.add_child(card) + champion_panel = LuneChampionPanel.new() + champion_panel.visible = false + _purchase.add_child(champion_panel) + champion_panel.command_requested.connect(func(command: Dictionary) -> void: command_requested.emit(command)) + # Own army, ordered by lane. + _army = _box(true, 4, row) as VBoxContainer + _army.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _army.resized.connect(mark_dirty) + var head: HBoxContainer = _box(false, 8, _army) + _army_title = LuneUiTheme.label("ARMEE", "HudCaps", head) + _army_lanes = LuneUiTheme.label("", "HudMuted", head) + _army_lanes.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _army_lanes.clip_text = true + _strip = HFlowContainer.new() + _strip.add_theme_constant_override("h_separation", 4) + _strip.add_theme_constant_override("v_separation", 4) + _strip.mouse_filter = Control.MOUSE_FILTER_IGNORE + _army.add_child(_strip) + _starter = LuneUiTheme.button("Startaufstellung", "HudButton", _army) + _starter.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _starter.pressed.connect(func() -> void: starter_requested.emit()) + LuneTooltip.attach(_starter, _starter_tip) + hero_card = LuneHeroCard.new() + row.add_child(hero_card) + hero_card.pressed.connect(func() -> void: open_hero()) + +func _build_ready() -> void: + _ready_box = _panel("HudPanelTight") + var column: VBoxContainer = _box(true, 2, _ready_box) + _ready_button = LuneUiTheme.button("BEREIT", "PrimaryButton", column) + _ready_button.custom_minimum_size.y = 44 + _ready_button.pressed.connect(func() -> void: + if not ready_blocked(): + ready_requested.emit()) + LuneTooltip.attach(_ready_button, _ready_tip) + _ready_check = LuneGlyph.make("check", SaumwachtTheme.INK, 22.0, _ready_button) + _ready_check.set_anchors_and_offsets_preset(Control.PRESET_CENTER_LEFT) + _ready_check.position.x = 16 + _ready_check.visible = false + _ready_note = LuneUiTheme.label("Leertaste", "HudMuted", column) + _ready_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _combat_row = _box(false, 8, column) as HBoxContainer + _combat_row.visible = false + _combat_row.alignment = BoxContainer.ALIGNMENT_CENTER + LuneGlyph.make("melee", LuneUiTheme.DANGER.lightened(.15), 16.0, _combat_row) + _clock = LuneUiTheme.label("", "HudValue", _combat_row) + _auto = LuneUiTheme.label("", "HudMuted", _combat_row) + _auto.clip_text = true + _auto.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + +func _build_map() -> void: + _map_panel = _panel("HudPanelTight") minimap = Minimap.new() - map_panel.add_child(minimap) + minimap.match_state = match_state + _map_panel.add_child(minimap) minimap.focus_requested.connect(func(point: Vector3) -> void: map_focus_requested.emit(point)) + LuneTooltip.attach(minimap, func() -> Dictionary: return {"title": "Minimap", "text": "Klicken oder ziehen bewegt die Kamera. Runde Marker: Blau · eckige Marker: Rot. Krone: Boss, Pfeil: Angriffschampion, Goldring: Held.", "note": "Die Minimap löst nie Käufe oder Befehle aus."}) -func _game_over() -> void: +func _build_banner() -> void: + _banners = _box(true, 6, self) as VBoxContainer + +func _build_status() -> void: + _status = _panel("HudPanelTight") + var column: VBoxContainer = _box(true, 0, _status) + _report = LuneUiTheme.label("", "HudText", column) + _report.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _toast = LuneUiTheme.label("", "HudMuted", column) + _toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _status.visible = false + _status.minimum_size_changed.connect(_layout) + +func _build_modal() -> void: + _modal = LuneModalPanel.new(Vector2(1040, 660)) + add_child(_modal) + hero_detail = LuneHeroDetail.new() + _modal.body.add_child(hero_detail) + hero_detail.command_requested.connect(func(command: Dictionary) -> void: command_requested.emit(command)) + +func _build_over() -> void: _over = ColorRect.new() (_over as ColorRect).color = Color(.02, .03, .04, .82) _over.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) @@ -273,212 +450,609 @@ func _game_over() -> void: panel.theme_type_variation = "MenuPanel" panel.custom_minimum_size = Vector2(620, 0) center.add_child(panel) - var column := VBoxContainer.new() - column.add_theme_constant_override("separation", 14) - panel.add_child(column) - _over_title = _label("", "TitleLabel", column, 60) + var column: VBoxContainer = _box(true, 14, panel) + _over_title = LuneUiTheme.label("", "TitleLabel", column, 60) _over_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _over_body = _label("", "BodyLabel", column) + _over_body = LuneUiTheme.label("", "BodyLabel", column) _over_body.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _over_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - var row := HBoxContainer.new() + var row: HBoxContainer = _box(false, 12, column) row.alignment = BoxContainer.ALIGNMENT_CENTER - row.add_theme_constant_override("separation", 12) - column.add_child(row) - _button("NEUE PARTIE", row, func() -> void: rematch_requested.emit(), "PrimaryButton") - _button("Hauptmenü", row, func() -> void: menu_requested.emit()) + LuneUiTheme.button("NEUE PARTIE", "PrimaryButton", row).pressed.connect(func() -> void: rematch_requested.emit()) + LuneUiTheme.button("Menü", "Button", row).pressed.connect(func() -> void: menu_requested.emit()) -# ---------------------------------------------------------------- refresh -func notify(message: String, error: bool = false) -> void: - _toast.text = message - _toast.add_theme_color_override("font_color", Color("f0a888") if error else Color("9fe0c8")) - _toast_left = 5.0 +# ---------------------------------------------------------------- layout +## UI scale: the preference, capped so the layout always has at least MIN_AREA logical pixels. +func _fit() -> void: + var area: Vector2 = get_viewport().get_visible_rect().size + var wanted: float = clampf(float(Preferences.get("ui_scale") if Preferences.get("ui_scale") != null else 1.0), .75, 1.5) + var fits: float = minf(area.x / MIN_AREA.x, area.y / MIN_AREA.y) + ui_scale = clampf(minf(wanted, fits), .6, 1.5) + position = Vector2.ZERO + scale = Vector2(ui_scale, ui_scale) + size = area / ui_scale + _layout() +func _layout() -> void: + if _over == null: + return + var w: float = size.x + var h: float = size.y + _top.size = _top.get_combined_minimum_size() + _top.position = Vector2(floorf((w - _top.size.x) * .5), MARGIN) + _system.size = _system.get_combined_minimum_size() + _system.position = Vector2(w - MARGIN - _system.size.x, MARGIN) + _left.position = Vector2(MARGIN, MARGIN) + _left.size = Vector2(LEFT_WIDTH, 0) + var box: float = MAP_EDGE + 16.0 + _map_panel.position = Vector2(w - MARGIN - box, h - MARGIN - box) + _map_panel.size = Vector2(box, box) + var planning: bool = match_state == null or match_state.phase == LuneMatch.Phase.PLANNING + var ready_height: float = READY_HEIGHT if planning else READY_COMBAT + _ready_box.position = Vector2(_map_panel.position.x, _map_panel.position.y - 8.0 - ready_height) + _ready_box.size = Vector2(box, ready_height) + var bar: float = maxf(BAR_PLANNING if planning else BAR_COMBAT, _bottom.get_combined_minimum_size().y) + _bottom.position = Vector2(MARGIN, h - MARGIN - bar) + _bottom.size = Vector2(_map_panel.position.x - 10.0 - MARGIN, bar) + _right.position = Vector2(w - MARGIN - RIGHT_WIDTH, MARGIN + _system.size.y + 8.0) + _right.size = Vector2(RIGHT_WIDTH, 0) + _banners.size = Vector2(minf(700.0, w - 2.0 * (LEFT_WIDTH + 2.0 * MARGIN)), 0) + _banners.position = Vector2(floorf((w - _banners.size.x) * .5), _top.position.y + _top.size.y + 8.0) + _status.size = _status.get_combined_minimum_size() + _status.position = Vector2(floorf(_bottom.position.x + (_bottom.size.x - _status.size.x) * .5), _bottom.position.y - 8.0 - _status.size.y) + _layout_phase = match_state.phase if match_state != null else -1 + +## Screen rectangles of the main regions (layout tests). +func regions() -> Dictionary: + var result: Dictionary = {"top": _top, "system": _system, "left": _left, "right": _right, "bottom": _bottom, "ready": _ready_box, "minimap": _map_panel} + var rects: Dictionary = {} + for key: String in result: + var control: Control = result[key] + if control.visible: + rects[key] = control.get_global_rect() + return rects + +# ---------------------------------------------------------------- per frame func _process(delta: float) -> void: + if match_state == null: + return _toast_left -= delta - if _toast_left <= 0.0: - _toast.text = "WASD / mittlere Maustaste: Kamera · Mausrad: Zoom · F2–F4: Top/Mid/Bottom · B Basis · G Grotte · H Held · O Karte · R Zurück" - _toast.remove_theme_color_override("font_color") - if match_state != null: - refresh() + if _toast_left <= 0.0 and not _toast.text.is_empty(): + _toast.text = "" + _status_visibility() + for block: Dictionary in _ancients: + if float(block.pulse) > 0.0: + block.pulse = maxf(0.0, float(block.pulse) - delta) + (block.bar as Control).modulate = Color(1, 1, 1, .45 + .55 * absf(cos(float(block.pulse) * PI * 2.0))) + if float(block.pulse) <= 0.0: + (block.bar as Control).modulate = Color.WHITE + _tick += delta + _map_tick += delta + _feed_tick += delta + var fighting: bool = match_state.phase == LuneMatch.Phase.COMBAT + if fighting and _map_tick >= MAP_RATE: + _map_tick = 0.0 + minimap.refresh(match_state.announcements()) + if _feed_tick >= FEED_RATE: + _track_events() + _feed_tick = 0.0 + if _dirty or match_state.phase != _last_phase or _tick >= (COMBAT_RATE if fighting else PLANNING_RATE): + var started: int = Time.get_ticks_usec() + _sync(_tick) + _tick = 0.0 + _dirty = false + syncs += 1 + sync_usec = lerpf(sync_usec, float(Time.get_ticks_usec() - started), .2) -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 mark_dirty() -> void: + _dirty = true -func refresh() -> void: - var player: Dictionary = match_state.players[human_slot] - if hero_panel.match_state != match_state: - hero_panel.match_state = match_state - hero_panel.slot = human_slot - reinforce_panel.match_state = match_state - reinforce_panel.slot = human_slot - _boss_status.text = _grotto_text() - var planning: bool = match_state.phase == LuneMatch.Phase.PLANNING - var combat: bool = match_state.phase == LuneMatch.Phase.COMBAT - var phase_name: String = "PLANUNG" if planning else ("KAMPF" if combat else "PARTIE BEENDET") - _phase.text = phase_name - _round.text = "Welle %d%s" % [match_state.round_number, (" · %02d:%02d" % [int(match_state.combat.elapsed) / 60, int(match_state.combat.elapsed) % 60]) if combat else ""] - for team: int in range(2): - var hp: float = match_state.core_hp(team) - if combat and match_state.combat.buildings.has("%s_ancient" % ("blau" if team == 0 else "rot")): - hp = (match_state.combat.buildings["%s_ancient" % ("blau" if team == 0 else "rot")] as LuneUnit).hp - _core_bars[team].max_value = match_state.balance.core_hp - _core_bars[team].value = hp - _core_labels[team].text = "Ancient %s · %d" % [TEAM_NAMES[team], ceili(hp)] - _gold.text = "%d Gold" % match_state.gold_of(human_slot) - _income.text = "+%d pro Runde · Armee %d / %d" % [match_state.balance.round_income(match_state.round_number), (player.draft.roster as Array).size() if planning else (player.roster as Array).size(), match_state.balance.supply_cap] - _identity.text = "%s · %s-Lane · %s" % [TEAM_NAMES[int(player.team)], LuneMap.LANE_NAMES[player.lane], DataCatalog.FACTION_NAMES[player.bund]] - _pause.visible = combat - _speed.visible = combat - for lane: String in LuneMap.LANES: - var button: Button = _lane_rows[lane] - var own: String = "●".repeat(match_state.towers_alive(0, lane, true)) + "○".repeat(2 - match_state.towers_alive(0, lane, true)) - var rival: String = "●".repeat(match_state.towers_alive(1, lane, true)) + "○".repeat(2 - match_state.towers_alive(1, lane, true)) - var blue_owner: String = match_state.players[match_state.slot_of(0, lane)].name - var red_owner: String = match_state.players[match_state.slot_of(1, lane)].name - button.text = "%s Blau %s Rot %s\n%s · %s" % [LuneMap.LANE_NAMES[lane].to_upper(), own, rival, blue_owner, red_owner] - var team_lines: PackedStringArray = [] - for team: int in range(2): - var names: PackedStringArray = [] - for slot: int in range(team * 3, team * 3 + 3): - names.append(match_state.players[slot].name) - team_lines.append("%s: %s · Basistürme %d/2" % [TEAM_NAMES[team], ", ".join(names), int(match_state.building_alive_now("%s_base_1" % ("blau" if team == 0 else "rot"))) + int(match_state.building_alive_now("%s_base_2" % ("blau" if team == 0 else "rot")))]) - _team_list.text = "\n".join(team_lines) - _plan_panel.visible = planning - _combat_panel.visible = combat - if planning: - _refresh_plan(player) - elif combat: - var lines: PackedStringArray = ["KAMPF LÄUFT · automatisch"] - for lane: String in LuneMap.LANES: - var counts: Array[int] = [0, 0] - for unit: LuneUnit in match_state.combat.units: - if unit.alive and unit.is_mobile() and unit.lane == lane: - counts[unit.team] += 1 - lines.append("%s Blau %d · Rot %d Einheiten" % [LuneMap.LANE_NAMES[lane], counts[0], counts[1]]) - lines.append("Leertaste/P: Pause · Tempo 1×/2×/4×") - _combat_info.text = "\n".join(lines) - if combat: - _report.text = "Welle %d läuft. Armeen und Monsterwellen rücken über alle drei Lanes vor; Türme fallen in fester Reihenfolge." % match_state.round_number - elif planning and match_state.last_settlement.is_empty(): - _report.text = "Planung: Kaufe Truppen für deine Lane, teile sie auf Top, Mid oder Bottom ein und drücke Bereit." - if not match_state.last_settlement.is_empty() and planning: - var s: Dictionary = match_state.last_settlement - var lost: PackedStringArray = [] - for id: String in s.buildings_lost: - lost.append(_building_name(id)) - _report.text = "Welle %d abgeschlossen (%s, %ds) · dein Ertrag +%d Gold (Kills %d, Einkommen %d)%s" % [s.round, _reason(s.end_reason), int(s.combat_time), int(s.gains[human_slot]) + int(s.income), int(s.gains[human_slot]), int(s.income), ("\nGefallen: " + ", ".join(lost)) if not lost.is_empty() else ""] - _over.visible = match_state.phase == LuneMatch.Phase.OVER +## True for one second after a combat ended: Bereit (click or Space) is ignored meanwhile. +func ready_blocked() -> bool: + return Time.get_ticks_msec() < _ready_block_until + +func _sync(delta: float) -> void: + var phase: int = match_state.phase + if phase != _last_phase: + _phase_changed(_last_phase, phase) + _last_phase = phase + if _layout_phase != phase: + _layout() + var planning: bool = model.planning() + var fighting: bool = model.fighting() + _sync_top() + var reduced: bool = Preferences.reduced_motion + for lane: String in lanes: + (lanes[lane] as LuneLaneIndicator).apply(model.lane(lane), model.team(), delta, reduced) + _team.apply(model.members(model.team()), model.team(), planning, model.networked()) + _sync_right(planning, fighting) + _sync_bottom(planning, fighting) + _sync_ready(planning, fighting) + _sync_banners() + if not fighting: + minimap.viewer_team = model.team() + minimap.human_slot = human_slot + minimap.refresh(match_state.announcements()) + if _modal.visible: + hero_detail.apply(model) + _modal.title.text = hero_detail.title_text(model) + _over.visible = model.over() if _over.visible: - var own_team: int = int(player.team) + var own_team: int = model.team() _over_title.text = "UNENTSCHIEDEN" if match_state.winner == LuneMatch.DRAW else ("SIEG" if match_state.winner == own_team else "NIEDERLAGE") _over_body.text = "%s\nNach %d Wellen." % [match_state.end_note, match_state.round_number] - minimap.match_state = match_state - minimap.queue_redraw() -func _grotto_text() -> String: +func _sync_top() -> void: + for side: int in range(2): + var team: int = model.team() if side == 0 else model.enemy() + var block: Dictionary = _ancients[side] + var ancient: Dictionary = model.ancient(team) + block.data = ancient + var tint: Color = LuneUiTheme.team_color(team) + (block.emblem as LuneGlyph).glyph = "emblem_%d" % team + (block.emblem as LuneGlyph).color = tint + (block.caption as Label).text = ("DEIN ANCIENT · %s" if side == 0 else "GEGNER · %s") % LuneUiTheme.team_name(team).to_upper() + var bar: LuneHpBar = block.bar + bar.fraction = float(ancient.fraction) + bar.fill = LuneHpBar.health_color(bar.fraction, tint) + (block.value as Label).text = "%s / %s" % [LuneUiTheme.number(ancient.hp), LuneUiTheme.number(ancient.max_hp)] + var towers: Array = block.towers + for index: int in range(2): + var base: Dictionary = ancient.base[index] + (towers[index] as LuneTowerIndicator).team = team + (towers[index] as LuneTowerIndicator).set_state(bool(base.alive), float(base.fraction), bool(base.attackable)) + var level: int = 2 if bar.fraction < .3 else (1 if bar.fraction < .5 else 0) + var badge: LuneStatusBadge = block.badge + badge.visible = level > 0 or not ancient.alive + if not ancient.alive: + badge.set_state("ZERSTÖRT", LuneUiTheme.DANGER, "close", true) + elif level == 2: + badge.set_state("KRITISCH", LuneUiTheme.DANGER, "warning", true) + elif level == 1: + badge.set_state("BESCHÄDIGT", LuneUiTheme.WARN) + elif bool(ancient.attackable): + badge.visible = true + badge.set_state("ANGREIFBAR", LuneUiTheme.WARN) + _wave.text = "WELLE %d" % match_state.round_number + if model.over(): + _phase_badge.set_state("PARTIE BEENDET", LuneUiTheme.MUTED) + elif model.fighting(): + var elapsed: int = int(match_state.combat.elapsed) + _phase_badge.set_state("KAMPF · %02d:%02d" % [elapsed / 60, elapsed % 60], LuneUiTheme.DANGER.lightened(.1), "melee") + elif model.locked(): + _phase_badge.set_state("PLANUNG · BEREIT", LuneUiTheme.GOOD, "check") + else: + _phase_badge.set_state("PLANUNG", LuneUiTheme.BRONZE_LIGHT, "clock") + +func _sync_right(planning: bool, fighting: bool) -> void: + var boss: Dictionary = model.boss() + boss_card.apply(boss, model.team(), match_state.round_number, planning) + if boss.has("hp"): + boss_card.set_boss_hp(float(boss.hp) / maxf(1.0, float(boss.max_hp))) + var entry: Dictionary = _selected_entry() + if entry.is_empty() and not selected_uid.is_empty(): + selected_uid = "" + unit_detail.visible = not entry.is_empty() + boss_card.set_compact(unit_detail.visible) + if unit_detail.visible: + unit_detail.apply(entry, model, model.live_unit(selected_uid)) + next_wave.visible = not unit_detail.visible and not model.over() + if next_wave.visible: + next_wave.apply(model.next_wave(), model.team(), fighting) + +func _selected_entry() -> Dictionary: + if selected_uid.is_empty(): + return {} + for entry: Dictionary in model.own_roster(): + if str(entry.uid) == selected_uid: + return entry + return {} + +func _sync_bottom(planning: bool, fighting: bool) -> void: + _gold.set_values(model.own_gold(), model.income(), not model.over()) + var roster: Array = model.own_roster() + var locked: bool = model.locked() + var can_act: bool = planning and not locked + _purchase.visible = planning + _army.visible = not planning or not champion_panel.visible + _undo.visible = planning + _undo.disabled = not can_act or (model.me().undo as Array).is_empty() + _starter.visible = planning and roster.is_empty() + _starter.disabled = not can_act + if planning: + var ids: Array[String] = match_state.base_ids(model.me().bund) + var gold: int = model.own_gold() + var full: bool = roster.size() >= model.supply_cap() + if not _shop_buttons.has(ids[0]): + _shop_buttons.clear() + for index: int in range(ids.size()): + var card: LuneUnitCard = _troop_row.get_child(index) + var id: String = ids[index] + var data: Dictionary = DataCatalog.get_unit(id) + card.setup(id, id, str(data.display_name), model.role_of(id), int(data.cost), str(index + 1)) + for connection: Dictionary in card.pressed.get_connections(): + card.pressed.disconnect(connection.callable) + card.pressed.connect(func() -> void: command_requested.emit({"type": "buy", "type_id": id})) + if not card.has_meta("tip"): + card.set_meta("tip", true) + LuneTooltip.attach(card, func() -> Dictionary: return LuneStats.troop_tip(card.type_id, model.role_of(card.type_id), card.reason)) + _shop_buttons[id] = card + for id: String in _shop_buttons: + var card: LuneUnitCard = _shop_buttons[id] + var why: String = "" + if locked: + why = "Bereit gemeldet – die Planung ist verbindlich." + elif full: + why = "Armee voll (%d/%d)." % [roster.size(), model.supply_cap()] + elif gold < card.cost: + why = "Es fehlen %d Gold." % (card.cost - gold) + card.set_available(why.is_empty(), why, locked, gold < card.cost) + if champion_panel.visible: + champion_panel.apply(model) + _army_title.text = "ARMEE %d/%d" % [roster.size(), model.supply_cap()] + var per_lane: Dictionary = {} + var alive_lane: Dictionary = {} + for entry: Dictionary in roster: + var lane: String = str(entry.lane) + per_lane[lane] = int(per_lane.get(lane, 0)) + 1 + if fighting: + var unit: LuneUnit = model.live_unit(str(entry.uid)) + if unit != null and unit.alive: + alive_lane[lane] = int(alive_lane.get(lane, 0)) + 1 + var parts: PackedStringArray = [] + for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]: + if per_lane.has(lane): + var short: String = "GROTTE" if lane == LuneMatch.GROTTO else LuneMap.LANE_NAMES[lane].to_upper() + parts.append(("%s %d/%d" % [short, int(alive_lane.get(lane, 0)), int(per_lane[lane])]) if fighting else ("%s %d" % [short, int(per_lane[lane])])) + _army_lanes.text = " · ".join(parts) + _sync_chips(roster, fighting) + var hero: Dictionary = model.own_hero() + hero_card.apply(hero, model, model.live_unit(str(hero.get("uid", ""))) if not hero.is_empty() else null) + +func _sync_chips(roster: Array, fighting: bool) -> void: + var ordered: Array = roster.duplicate() + var order: Array = LuneMap.LANES + [LuneMatch.GROTTO] + ordered.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var la: int = order.find(str(a.lane)) + var lb: int = order.find(str(b.lane)) + return int(a.seq) < int(b.seq) if la == lb else la < lb) + var edge: float = _chip_edge(ordered.size(), fighting) + var signature: String = "%s|%d" % [str(ordered.map(func(e: Dictionary) -> String: return "%s:%s:%s" % [e.uid, e.type_id, e.lane])), int(edge)] + if signature != _chip_signature: + _chip_signature = signature + for child: Node in _strip.get_children(): + child.free() + _chips.clear() + for entry: Dictionary in ordered: + _chips[str(entry.uid)] = _make_chip(entry, edge) + for uid: String in _chips: + var chip: LuneArmyChip = _chips[uid] + chip.set_pressed_no_signal(uid == selected_uid) + if fighting: + var unit: LuneUnit = model.live_unit(uid) + chip.fallen = unit == null or not unit.alive + else: + chip.fallen = false + +## Largest chip edge (46 … 30 px) at which the whole army fits into the strip. +func _chip_edge(count: int, fighting: bool) -> float: + var width: float = _army.size.x if _army.size.x > 60.0 else 300.0 + var height: float = (BAR_COMBAT if fighting else BAR_PLANNING) - 20.0 - 26.0 + for edge: float in [46.0, 42.0, 38.0, 34.0, 30.0]: + var per_row: int = maxi(1, floori((width + 4.0) / (edge + 4.0))) + var rows: int = ceili(float(count) / float(per_row)) + if float(rows) * (edge + LuneArmyChip.BAND + 4.0) <= height: + return edge + return 30.0 + +func _make_chip(entry: Dictionary, edge: float) -> LuneArmyChip: + var chip := LuneArmyChip.new() + _strip.add_child(chip) + chip.setup(entry, edge) + var uid: String = str(entry.uid) + chip.pressed.connect(func() -> void: + if selected_uid == uid: + clear_selection() + else: + select_unit(uid)) + var type_id: String = str(entry.type_id) + var lane: String = str(entry.lane) + LuneTooltip.attach(chip, func() -> Dictionary: + var tip: Dictionary = LuneStats.troop_tip(type_id, model.role_of(type_id)) + tip.note = "Einsatz: %s · Klick: Details, Aufwerten, Einsatz" % ("Grotte" if lane == LuneMatch.GROTTO else LuneMap.LANE_NAMES[lane]) + if chip.fallen: + tip.warn = "In diesem Kampf gefallen; kehrt zur nächsten Planung zurück." + return tip) + return chip + +func _sync_ready(planning: bool, fighting: bool) -> void: + _ready_box.visible = not model.over() + _ready_button.visible = planning + _ready_note.visible = planning + _combat_row.visible = fighting + _pause.visible = fighting + _speed.visible = fighting + if planning: + var locked: bool = model.locked() + _ready_button.disabled = locked or ready_blocked() + _ready_button.text = "BEREIT" + _ready_check.visible = locked + if locked: + var count: Vector2i = model.ready_count() + _ready_note.text = "Warte auf %d Mitspieler" % maxi(0, count.y - count.x) if count.y > count.x else "Kampf beginnt …" + else: + _ready_note.text = "Leertaste · startet Welle %d" % match_state.round_number + if fighting: + var elapsed: int = int(match_state.combat.elapsed) + _clock.text = "KAMPF %02d:%02d" % [elapsed / 60, elapsed % 60] + _auto.text = "Tempo %s" % _speed.text + var guest: bool = model.is_guest() + _pause.disabled = guest + _speed.disabled = guest + +func _sync_banners() -> void: + var lines: Array = [] + var boss: Dictionary = model.boss() + if str(boss.state) in ["deploy_next", "deploying"]: + var own: bool = bool(boss.own) + var lane: String = LuneMap.LANE_NAMES.get(str(boss.lane), "?").to_upper() + var text: String + if own: + text = "Deine SCHLUNDKRONE greift in Welle %d auf %s an · Kopfgeld für den Gegner %s Gold" % [int(boss.deploy_round), lane, LuneUiTheme.number(boss.bounty)] + else: + text = "SCHLUNDKRONE (%s) greift in Welle %d auf %s an · Kopfgeld für euch %s Gold" % [LuneUiTheme.team_name(int(boss.team)), int(boss.deploy_round), lane, LuneUiTheme.number(boss.bounty)] + lines.append([text, "crown", own, {"lane": str(boss.lane)}]) + var core: Dictionary = model.ancient(model.team()) + if bool(core.alive) and float(core.fraction) < .3: + lines.append(["Dein Ancient ist kritisch: %d %% Leben" % int(round(float(core.fraction) * 100.0)), "warning", false, {"camera": "base"}]) + var signature: String = str(lines) + if signature == _banner_signature: + return + _banner_signature = signature + for child: Node in _banners.get_children(): + child.free() + for line: Array in lines: + var panel := PanelContainer.new() + panel.theme_type_variation = "HudBannerOwn" if line[2] else "HudBannerDanger" + panel.mouse_filter = Control.MOUSE_FILTER_STOP + panel.mouse_force_pass_scroll_events = false + panel.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + _banners.add_child(panel) + var row: HBoxContainer = _box(false, 10, panel) + row.alignment = BoxContainer.ALIGNMENT_CENTER + LuneGlyph.make(line[1], LuneUiTheme.team_color(model.team()) if line[2] else LuneUiTheme.DANGER, 20.0, row) + var label := LuneUiTheme.label(line[0], "HudValue", row) + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var target: Dictionary = line[3] + panel.gui_input.connect(func(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + _focus_target(target)) + +func _status_visibility() -> void: + _status.visible = not _report.text.is_empty() or not _toast.text.is_empty() + _report.visible = not _report.text.is_empty() + _toast.visible = not _toast.text.is_empty() + +# ---------------------------------------------------------------- phase changes and events +func _phase_changed(previous: int, phase: int) -> void: + if phase == LuneMatch.Phase.COMBAT: + close_modal() + _report.text = "" + _status_visibility() + for entry: Dictionary in match_state.announcements(): + var own: bool = int(entry.team) == model.team() + var lane: String = LuneMap.LANE_NAMES[str(entry.lane)].to_upper() + if entry.kind == "boss": + feed.push("%s – Schlundkrone (%s) greift an" % [lane, LuneUiTheme.team_name(int(entry.team))], "good" if own else "danger", "crown", {"lane": str(entry.lane)}) + else: + feed.push("%s – Angriffschampion %s (%s) erschienen" % [lane, LuneContent.CHAMPIONS[entry.champion_id].name, LuneUiTheme.team_name(int(entry.team))], "info" if own else "warn", "champion", {"lane": str(entry.lane)}) + elif phase == LuneMatch.Phase.PLANNING and previous == LuneMatch.Phase.COMBAT: + _ready_block_until = Time.get_ticks_msec() + 1000 + _report_settlement() + elif phase == LuneMatch.Phase.PLANNING and previous < 0 and not match_state.last_settlement.is_empty(): + _report_settlement() + _boss_seen.clear() + _layout() + +func _report_settlement() -> void: + var s: Dictionary = match_state.last_settlement + if s.is_empty(): + return + var gained: int = int((s.gains as Array)[human_slot]) + int(s.income) + _report.text = "Welle %d abgeschlossen (%s, %ds) · dein Ertrag +%d Gold (Kills %d, Einkommen %d)" % [int(s.round), _reason(str(s.end_reason)), int(s.combat_time), gained, int((s.gains as Array)[human_slot]), int(s.income)] + _status_visibility() + feed.push("Welle %d abgeschlossen · +%d Gold" % [int(s.round), gained], "info", "coin") 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) + if str(s.get("boss", "")) == "won" and not claim.is_empty(): + var own: bool = int(claim.team) == model.team() + feed.push("Boss in Welle %d auf %s (%s)" % [int(claim.deploy_round), LuneMap.LANE_NAMES[claim.lane].to_upper(), LuneUiTheme.team_name(int(claim.team))], "good" if own else "danger", "crown", {"lane": str(claim.lane)}) + var level: int = _own_hero_level() + if level > _hero_level and _hero_level > 0: + var hero: Dictionary = model.own_hero() + feed.push("%s erreicht Stufe %d" % [LuneContent.HEROES[hero.hero_id].name, level], "good", "star", {"camera": "hero"}) + _hero_level = level + +func _own_hero_level() -> int: + var hero: Dictionary = model.own_hero() + return 0 if hero.is_empty() else LuneContent.level_for(int(hero.xp)) + +func _remember_buildings() -> void: + _known_alive.clear() + for definition: Dictionary in match_state.map.buildings: + _known_alive[definition.id] = model.building(definition.id).alive + +## Diffs public state for the event feed: fallen buildings, Ancient thresholds, the boss. +func _track_events() -> void: + if model.over() and _known_alive.is_empty(): + return + for definition: Dictionary in match_state.map.buildings: + var id: String = definition.id + var alive: bool = model.building(id).alive + if bool(_known_alive.get(id, alive)) and not alive: + var own: bool = int(definition.team) == model.team() + var place: String = "BASIS" if str(definition.lane) == "base" else LuneMap.LANE_NAMES[str(definition.lane)].to_upper() + var title: String = LuneHudModel.building_title(id) + feed.push("%s – %s (%s) zerstört" % [place, title, LuneUiTheme.team_name(int(definition.team))], "danger" if own else "good", "tower", {"point": definition.position}) + _known_alive[id] = alive + for side: int in range(2): + var team: int = model.team() if side == 0 else model.enemy() + var core: Dictionary = model.ancient(team) + var level: int = 2 if float(core.fraction) < .3 else (1 if float(core.fraction) < .5 else 0) + if level > _ancient_level[side] and bool(core.alive): + if level == 2: + feed.push("Dein Ancient kritisch (%d %%)" % int(round(float(core.fraction) * 100.0)) if side == 0 else "Gegnerischer Ancient kritisch (%d %%)" % int(round(float(core.fraction) * 100.0)), + "danger" if side == 0 else "good", "warning", {"camera": "base" if side == 0 else "enemy_base"}) + if not Preferences.reduced_motion: + _ancients[side].pulse = 1.5 + _ancient_level[side] = level + if model.fighting(): + for unit: LuneUnit in match_state.combat.units: + if unit.kind != "boss": + continue + var key: String = "%s-%d" % [unit.zone, unit.id] + var was: bool = bool(_boss_seen.get(key, unit.alive)) + if was and not unit.alive: + if unit.zone == "grotto": + var boss: Dictionary = model.boss() + if int(boss.team) >= 0: + feed.push("Bossgrotte erobert – %s" % LuneUiTheme.team_name(int(boss.team)), "good" if bool(boss.own) else "warn", "crown", {"camera": "grotto"}) + else: + feed.push("SCHLUNDKRONE besiegt (%s)" % LuneMap.LANE_NAMES.get(unit.lane, "").to_upper(), "good" if unit.team != model.team() else "warn", "crown", {"lane": unit.lane}) + _boss_seen[key] = unit.alive + +func _focus_target(target: Dictionary) -> void: + if target.has("lane") and LuneMap.LANES.has(str(target.lane)): + lane_focus_requested.emit(str(target.lane)) + elif target.has("point"): + var point: Vector2 = target.point + map_focus_requested.emit(Vector3(point.x, 0, point.y)) + elif target.has("camera"): + camera_requested.emit(str(target.camera)) func _reason(reason: String) -> String: return {"cleared": "alle Einheiten gefallen", "timeout": "Zeitfenster abgelaufen", "core": "Ancient zerstört"}.get(reason, reason) -func _building_name(id: String) -> String: - var parts: PackedStringArray = id.split("_") - var team: String = "Blau" if parts[0] == "blau" else "Rot" - if parts[1] == "ancient": - return "Ancient " + team - if parts[1] == "base": - return "Basisturm %s %s" % [team, parts[2]] - return "%s %s-%s" % [team, LuneMap.LANE_NAMES[parts[1]], "Außenturm" if parts[2] == "outer" else "Innenturm"] - -func _refresh_plan(player: Dictionary) -> void: - var draft: Dictionary = player.draft - var ids: Array[String] = match_state.base_ids(player.bund) - if _shop_buttons.is_empty() or not _shop_buttons.has(ids[0]): - for child: Node in _shop.get_children(): - child.free() - _shop_buttons.clear() - for index: int in range(ids.size()): - var id: String = ids[index] - var data: Dictionary = DataCatalog.get_unit(id) - var button := Button.new() - button.icon = AssetLibrary.icon(id) - button.expand_icon = true - button.custom_minimum_size = Vector2(76, 96) - button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER - button.vertical_icon_alignment = VERTICAL_ALIGNMENT_TOP - button.text = "%d G" % int(data.cost) - button.focus_mode = Control.FOCUS_ALL - button.tooltip_text = "%s [%d]\n%s · %d HP · %d Schaden/s · %.1f m\n%s" % [data.display_name, index + 1, ROLE_NAMES[match_state.role_of(id)], data.max_hp, data.damage, data.attack_range, data.ability_description] - button.pressed.connect(func() -> void: command_requested.emit({"type": "buy", "type_id": id})) - _shop.add_child(button) - _shop_buttons[id] = button - for id: String in _shop_buttons: - var cost: int = int(DataCatalog.get_unit(id).cost) - (_shop_buttons[id] as Button).disabled = int(draft.gold) < cost or (draft.roster as Array).size() >= match_state.balance.supply_cap - _undo.disabled = (player.undo as Array).is_empty() - _starter.disabled = not (draft.roster as Array).is_empty() - _ready_button.disabled = bool(player.ready) - _ready_button.text = "BEREIT · KAMPF ›" if not player.ready else "WARTE AUF ANDERE …" - var signature: String = JSON.stringify(draft) - if signature == _roster_signature: - return - _roster_signature = signature - for child: Node in _roster.get_children(): - child.free() - _roster_title.text = "DEINE ARMEE · %d / %d" % [(draft.roster as Array).size(), match_state.balance.supply_cap] - if (draft.roster as Array).is_empty(): - _label("Noch keine Truppen. Kaufe oben oder nutze die Startaufstellung.", "MutedLabel", _roster, 15).autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - for entry: Dictionary in draft.roster: - var data: Dictionary = DataCatalog.get_unit(entry.type_id) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 6) - _roster.add_child(row) - var icon := TextureRect.new() - icon.texture = AssetLibrary.icon(entry.type_id) - icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - icon.custom_minimum_size = Vector2(40, 40) - row.add_child(icon) - var name_label := _label(str(data.display_name), "Label", row, 15) - name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - name_label.clip_text = true - for lane: String in LuneMap.LANES + [LuneMatch.GROTTO]: - var lane_button := Button.new() - var grotto: bool = lane == LuneMatch.GROTTO - lane_button.text = "G" if grotto else LuneMap.LANE_NAMES[lane].left(1) - lane_button.toggle_mode = true - lane_button.button_pressed = entry.lane == lane - lane_button.theme_type_variation = "ChoiceButton" - lane_button.custom_minimum_size = Vector2(34, 34) - lane_button.disabled = grotto and not match_state.grotto_available() - lane_button.tooltip_text = "In die Bossgrotte entsenden (fehlt der Lane die ganze Kampfphase)" if grotto else "Auf %s einteilen" % LuneMap.LANE_NAMES[lane] - var uid: String = str(entry.uid) - lane_button.pressed.connect(func() -> void: command_requested.emit({"type": "assign", "uid": uid, "lane": lane})) - row.add_child(lane_button) - var upgrade_id: String = str(data.get("upgrade_id", "")) - var uid_value: String = str(entry.uid) - var up := Button.new() - up.text = "↑ %d" % int(DataCatalog.get_unit(upgrade_id).cost) if not upgrade_id.is_empty() else "max" - up.disabled = upgrade_id.is_empty() or int(draft.gold) < int(DataCatalog.get_unit(upgrade_id).get("cost", 0)) - up.tooltip_text = ("Aufwerten zu %s" % DataCatalog.get_unit(upgrade_id).display_name) if not upgrade_id.is_empty() else "Vollständig aufgewertet" - up.pressed.connect(func() -> void: command_requested.emit({"type": "upgrade", "uid": uid_value})) - row.add_child(up) - var sell := Button.new() - var refund: int = int(entry.invested) if bool(entry.get("fresh", false)) else floori(float(entry.invested) * match_state.balance.sell_refund) - sell.text = "×" - sell.tooltip_text = "Zurückgeben für %d Gold" % refund - sell.pressed.connect(func() -> void: command_requested.emit({"type": "sell", "uid": uid_value})) - row.add_child(sell) +# ---------------------------------------------------------------- public API +func notify(message: String, error: bool = false) -> void: + _toast.text = message + _toast.add_theme_color_override("font_color", LuneUiTheme.DANGER if error else LuneUiTheme.GOOD) + _toast_left = 5.0 + _status_visibility() + _dirty = true func set_speed_label(value: int, paused: bool) -> void: _speed.text = "%d×" % value - _pause.text = "Weiter · P" if paused else "Pause · P" + var label: Label = _pause.get_meta("label") + label.text = "P" + var glyph: LuneGlyph = _pause.get_child(0).get_child(0) + glyph.glyph = "play" if paused else "pause" + +## G2 compatibility: 0 = troops, 1 = hero, 2 = attack champions. +func show_page(index: int) -> void: + match index: + 1: + open_hero() + 2: + close_modal() + open_champions() + _: + close_modal() + open_troops() + +func open_hero(tab: int = LuneHeroDetail.TAB_VALUES) -> void: + hero_detail.tab = tab + hero_detail.show_tab(tab) + hero_detail.apply(model) + _modal.open(hero_detail.title_text(model)) + +func open_items() -> void: + open_hero(LuneHeroDetail.TAB_ITEMS) + +func open_talents() -> void: + open_hero(LuneHeroDetail.TAB_TALENTS) + +func close_modal() -> void: + _modal.close() + +func modal_open() -> bool: + return _modal.visible + +func open_champions() -> void: + _purchase_tabs.select(1) + _show_purchase(1) + +func open_troops() -> void: + _purchase_tabs.select(0) + _show_purchase(0) + +func _show_purchase(index: int) -> void: + _troop_row.visible = index == 0 + champion_panel.visible = index == 1 + _army.visible = index == 0 + _purchase.size_flags_horizontal = Control.SIZE_EXPAND_FILL if index == 1 else Control.SIZE_FILL + _dirty = true + +func select_unit(uid: String) -> void: + selected_uid = uid + _dirty = true + +func clear_selection() -> void: + selected_uid = "" + _dirty = true + +func public_digest() -> Dictionary: + return model.public_digest() + +func _unhandled_key_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not selected_uid.is_empty() and not _modal.visible: + clear_selection() + get_viewport().set_input_as_handled() + +# ---------------------------------------------------------------- tooltips +func _wave_tip() -> Dictionary: + var wave: Dictionary = model.next_wave() + return {"title": "Welle %d" % match_state.round_number, "glyph": "clock", + "text": "Wellen sind unbegrenzt: Die Partie endet nur, wenn ein Ancient fällt. Monster werden jede Runde stärker, alle drei Runden kommt eines je Lane hinzu (bis 7).", + "note": "Kommende Welle %d: %d Monster je Lane." % [int(wave.round), int(wave.count)]} + +func _ancient_tip(block: Dictionary) -> Dictionary: + var ancient: Dictionary = block.data + if ancient.is_empty(): + return {} + var team: int = int(ancient.team) + var standing: int = int(ancient.base[0].alive) + int(ancient.base[1].alive) + return {"title": "Ancient %s · %s" % [LuneUiTheme.team_name(team), LuneUiTheme.TEAM_EMBLEMS[team]], "glyph": "emblem_%d" % team, "glyph_color": LuneUiTheme.team_color(team), + "stats": [["heart", "Leben", "%s / %s" % [LuneUiTheme.number(ancient.hp), LuneUiTheme.number(ancient.max_hp)]], ["tower", "Basistürme", "%d von 2 stehen" % standing]], + "text": "Fällt ein Ancient, ist die Partie vorbei. Er ist erst angreifbar, wenn beide Basistürme (Kernwacht) gefallen sind; die fallen erst, wenn eine Lane ganz offen ist.", + "note": "B: Kamera zum eigenen Ancient" if bool(block.own) else ""} + +func _base_tip(block: Dictionary, index: int) -> Dictionary: + var ancient: Dictionary = block.data + if ancient.is_empty(): + return {} + var base: Dictionary = ancient.base[index] + return {"title": "Kernwacht %d · %s" % [index + 1, LuneUiTheme.team_name(int(base.team))], "glyph": "tower", "glyph_color": LuneUiTheme.team_color(int(base.team)), + "stats": [["heart", "Leben", LuneLaneIndicator._tower_value(base)]], + "text": "Basisturm vor dem Ancient. Angreifbar, sobald auf einer Lane Grenzwacht und Bastion gefallen sind." if base.alive else "Zerstört."} + +func _gold_tip() -> Dictionary: + return {"title": "Dein Gold", "glyph": "coin", "glyph_color": LuneUiTheme.GOLD, + "stats": [["coin", "Jetzt", LuneUiTheme.number(model.own_gold())], ["clock", "Einkommen", "+%s nach dieser Welle" % LuneUiTheme.number(model.income())]], + "text": "Dazu kommt Gold für besiegte Monster, Champions, Gebäude und den Boss auf der eigenen Seite (Teile gehen ans Team).", + "note": "Das Gold der anderen Spieler bleibt privat."} + +func _starter_tip() -> Dictionary: + var ids: Array[String] = match_state.base_ids(model.me().bund) + var cost: int = 0 + var names: PackedStringArray = [] + for index: int in [1, 0, 2]: + cost += int(DataCatalog.get_unit(ids[index]).cost) + names.append(str(DataCatalog.get_unit(ids[index]).display_name)) + return {"title": "Startaufstellung · %d Gold" % cost, "text": "Kauft %s für deine Lane." % ", ".join(names)} + +func _ready_tip() -> Dictionary: + if model.locked(): + return {"title": "Bereit gemeldet", "text": "Deine Planung ist verbindlich. Der Kampf beginnt, sobald alle bereit sind."} + return {"title": "Bereit", "text": "Übernimmt deine Planung verbindlich. Der Kampf läuft automatisch über alle drei Lanes.", "note": "Leertaste"} diff --git a/scripts/luneraria/ui/lune_hud_model.gd b/scripts/luneraria/ui/lune_hud_model.gd new file mode 100644 index 0000000..88f9d6b --- /dev/null +++ b/scripts/luneraria/ui/lune_hud_model.gd @@ -0,0 +1,333 @@ +class_name LuneHudModel +extends RefCounted +## What one seat may see, prepared for display. Every HUD element reads through this model, +## so the privacy rules live in one place: +## - gold, draft, undo and uncommitted bookings exist only for the viewer's own seat; +## - of other players the model exposes names, lanes, readiness, human/bot/offline and the +## committed hero (public since the last combat), never their gold or current plans; +## - lanes, buildings, the boss and the waves come from public state only. +## The model never changes the match; commands still go through LuneGame. + +const TOWER_NAMES: Dictionary = {"outer": "Grenzwacht", "inner": "Bastion", "base": "Kernwacht", "core": "Ancient"} +const TIER_TEXT: Dictionary = {"outer": "Außenturm", "inner": "Innenturm", "base": "Basisturm", "core": "Ancient"} + +var state: LuneMatch +var slot: int = 0 +## Optional LuneSession; only read for the network display (role, connection). +var session: Node +var _was_human: Dictionary = {} + +func _init(match_state: LuneMatch, viewer: int, network: Node = null) -> void: + state = match_state + slot = maxi(0, viewer) + session = network + +# ---------------------------------------------------------------- phase and own seat +func planning() -> bool: + return state.phase == LuneMatch.Phase.PLANNING + +func fighting() -> bool: + return state.phase == LuneMatch.Phase.COMBAT and state.combat != null + +func over() -> bool: + return state.phase == LuneMatch.Phase.OVER + +func me() -> Dictionary: + return state.players[slot] + +func team() -> int: + return int(me().team) + +func enemy() -> int: + return 1 - team() + +func own_gold() -> int: + return state.gold_of(slot) + +## Base income paid at the end of the current round's combat (rules: LuneBalance.round_income). +func income() -> int: + return state.balance.round_income(state.round_number) + +## After Ready the plan is binding (the host refuses later commands). +func locked() -> bool: + return planning() and bool(me().ready) + +func own_roster() -> Array: + return me().draft.roster if planning() else me().roster + +func own_hero() -> Dictionary: + return me().draft.hero if planning() else me().hero + +func own_bookings() -> Array: + var result: Array = [] + if planning(): + for booking: Dictionary in me().draft.new_bookings: + var entry: Dictionary = booking.duplicate() + entry.deploy_round = state.round_number + 1 + entry.fresh = true + result.append(entry) + for booking: Dictionary in me().bookings: + if int(booking.deploy_round) >= state.round_number: + result.append(booking) + return result + +func supply_cap() -> int: + return state.balance.supply_cap + +func networked() -> bool: + return session != null and is_instance_valid(session) and session.is_networked() + +func is_guest() -> bool: + return networked() and not session.is_authority() + +# ---------------------------------------------------------------- units and heroes +func role_of(type_id: String) -> String: + return state.role_of(type_id) + +static func stage_of(type_id: String) -> int: + var number: int = int(type_id.substr(1)) + return 2 if number % 2 == 0 else 1 + +## Catalogue definition of a troop with its role and derived figures. +func troop(type_id: String) -> Dictionary: + var data: Dictionary = DataCatalog.get_unit(type_id) + data.role = role_of(type_id) + data.stage = stage_of(type_id) + return data + +## Live combat unit for a persistent uid of the viewer (or null). +func live_unit(uid: String) -> LuneUnit: + if not fighting(): + return null + for unit: LuneUnit in state.combat.units: + if unit.persistent_uid == uid: + return unit + return null + +func hero_definition(hero: Dictionary) -> Dictionary: + var definition: Dictionary = LuneContent.hero_definition(hero) + definition.role = LuneMatch.HERO_ROLES.get(hero.hero_id, "front") + return definition + +# ---------------------------------------------------------------- buildings +func building(id: String) -> Dictionary: + var definition: Dictionary = state.map.building_def(id) + var max_hp: float = float(definition.get("max_hp", 1.0)) + var hp: float = float(state.building_hp.get(id, 0.0)) + var alive: bool = hp > 0.0 + if fighting() and state.combat.buildings.has(id): + var unit: LuneUnit = state.combat.buildings[id] + alive = unit.alive + hp = unit.hp if alive else 0.0 + return {"id": id, "team": int(definition.get("team", 0)), "tier": str(definition.get("tier", "outer")), "lane": str(definition.get("lane", "")), + "alive": alive, "hp": hp, "max_hp": max_hp, "fraction": clampf(hp / maxf(1.0, max_hp), 0.0, 1.0), + "attackable": alive and LuneMap.is_attackable(definition, _alive_map())} + +func _alive_map() -> Dictionary: + if fighting(): + return state.combat.alive_map() + var result: Dictionary = {} + for id: String in state.building_hp: + result[id] = float(state.building_hp[id]) > 0.0 + return result + +static func building_title(id: String) -> String: + var parts: PackedStringArray = id.split("_") + if parts[1] == "ancient": + return "Ancient" + if parts[1] == "base": + return "Kernwacht" + return TOWER_NAMES.get(parts[2], id) + +static func building_place(id: String) -> String: + var parts: PackedStringArray = id.split("_") + var team: String = "Blau" if parts[0] == "blau" else "Rot" + if parts[1] == "ancient": + return "Ancient " + team + if parts[1] == "base": + return "Basis %s · Kernwacht %s" % [team, parts[2]] + return "%s · %s %s" % [LuneMap.LANE_NAMES[parts[1]].to_upper(), building_title(id), team] + +func ancient(team_index: int) -> Dictionary: + var prefix: String = "blau" if team_index == 0 else "rot" + var core: Dictionary = building(prefix + "_ancient") + core.base = [building(prefix + "_base_1"), building(prefix + "_base_2")] + return core + +# ---------------------------------------------------------------- players +func player_name(seat: int) -> String: + return "Du" if seat == slot else str(state.players[seat].name) + +## Seat status for display: self, human, bot, or offline (was human, a bot plays the seat). +func status_of(seat: int) -> String: + var player: Dictionary = state.players[seat] + if bool(player.is_human): + _was_human[seat] = true + if seat == slot: + return "self" + if bool(player.is_human): + return "human" + return "offline" if _was_human.has(seat) else "bot" + +func members(team_index: int) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for seat: int in range(team_index * 3, team_index * 3 + 3): + var player: Dictionary = state.players[seat] + # Own seat: current draft. Everyone else: the committed hero, public since the last combat. + var hero: Dictionary = own_hero() if seat == slot else player.hero + result.append({"slot": seat, "name": player_name(seat), "lane": str(player.lane), "is_self": seat == slot, + "ready": bool(player.ready), "status": status_of(seat), "bund": str(player.bund), + "hero_id": str(hero.get("hero_id", "")), "hero_level": LuneContent.level_for(int(hero.get("xp", 0))) if not hero.is_empty() else 0}) + return result + +func ready_count() -> Vector2i: + var ready: int = 0 + var humans: int = 0 + for player: Dictionary in state.players: + if bool(player.is_human): + humans += 1 + ready += int(bool(player.ready)) + return Vector2i(ready, humans) + +# ---------------------------------------------------------------- lanes +func lane(lane_name: String) -> Dictionary: + var own_seat: int = state.slot_of(team(), lane_name) + var enemy_seat: int = state.slot_of(enemy(), lane_name) + var own_prefix: String = "blau" if team() == 0 else "rot" + var enemy_prefix: String = "rot" if team() == 0 else "blau" + var dangers: Array[Dictionary] = [] + for entry: Dictionary in state.announcements(): + if str(entry.lane) == lane_name: + dangers.append(entry) + var result: Dictionary = {"lane": lane_name, "title": LuneMap.LANE_NAMES[lane_name].to_upper(), "own_player": player_name(own_seat), + "enemy_player": player_name(enemy_seat), "own_is_me": own_seat == slot, + "towers": [building("%s_%s_inner" % [own_prefix, lane_name]), building("%s_%s_outer" % [own_prefix, lane_name]), + building("%s_%s_outer" % [enemy_prefix, lane_name]), building("%s_%s_inner" % [enemy_prefix, lane_name])], + "threat": LuneLaneThreat.evaluate(state, team(), lane_name), "dangers": dangers, + "own_hero": hero_of(own_seat), "enemy_hero": hero_of(enemy_seat)} + if fighting(): + var battle: Dictionary = LuneLaneThreat.battlefield(state, team(), lane_name) + result.own_count = int(battle.own_count) + result.enemy_count = int(battle.enemy_count) + return result + +## A seat's hero for display: the own draft for the viewer, the committed (public) hero for +## everyone else; {} without a hero. +func hero_of(seat: int) -> Dictionary: + var hero: Dictionary = own_hero() if seat == slot else state.players[seat].hero + if hero.is_empty(): + return {} + var spec: Dictionary = LuneContent.HEROES[hero.hero_id] + return {"model": str(spec.model), "name": str(spec.name), "level": LuneContent.level_for(int(hero.xp))} + +# ---------------------------------------------------------------- boss grotto +func boss_bounty(round_number: int) -> int: + return LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * round_number + +## Public boss state: available, contested, conquered, deploy_next, deploying, defeated, +## cooldown. On a guest the grotto fight is read from the mirrored units. +func boss() -> Dictionary: + var claim: Dictionary = state.boss.claim + var round_number: int = state.round_number + var result: Dictionary = {"state": "cooldown", "team": -1, "lane": "", "deploy_round": 0, "won_round": 0, "bounty": 0, + "rounds_left": maxi(0, int(state.boss.next_available_round) - round_number), "own": false, "chooser": false, + "next_round": int(state.boss.next_available_round)} + if fighting(): + for unit: LuneUnit in state.combat.units: + if unit.kind != "boss": + continue + if unit.zone == "grotto": + if unit.alive: + result.state = "contested" + return result + var winner: int = -1 + for other: LuneUnit in state.combat.units: + if other.alive and other.zone == "grotto" and other.team <= 1: + winner = other.team + result.state = "conquered" if winner >= 0 else "failed" + result.team = winner + result.own = winner == team() + result.deploy_round = round_number + 1 + return result + if unit.zone == "lane": + result.state = "deploying" if unit.alive else "defeated" + result.team = unit.team + result.own = unit.team == team() + result.lane = unit.lane + result.deploy_round = round_number + result.bounty = boss_bounty(round_number) + result.hp = unit.hp + result.max_hp = unit.max_hp + return result + if not claim.is_empty() and int(claim.deploy_round) >= round_number: + result.state = "deploy_next" if planning() else "deploying" + result.team = int(claim.team) + result.own = int(claim.team) == team() + result.won_round = int(claim.won_round) + result.deploy_round = int(claim.deploy_round) + result.bounty = boss_bounty(int(claim.deploy_round)) + result.chooser = int(claim.chooser_slot) == slot + # The chooser's lane change is private until combat; others see the public claim lane. + var chosen: String = str(me().draft.boss_lane) if result.chooser and planning() else "" + result.lane = chosen if not chosen.is_empty() else str(claim.lane) + return result + if state.grotto_available(): + result.state = "available" + return result + +# ---------------------------------------------------------------- waves +## The wave of the coming combat (planning) or the one after (combat). Monsters are public and +## identical for both sides; champions and the boss only once they are announced. +func next_wave() -> Dictionary: + var round_number: int = state.round_number if not fighting() else state.round_number + 1 + var wave: Array[Dictionary] = LuneWaves.build(state.balance, round_number) + var before: PackedStringArray = LuneWaves.unlocked_types(state.balance, round_number - 1) if round_number > 1 else PackedStringArray() + var groups: Dictionary = {} + var order: Array[String] = [] + for monster: Dictionary in wave: + var id: String = str(monster.id) + if not groups.has(id): + groups[id] = {"id": id, "name": str(monster.display_name), "count": 0, "hp": float(monster.max_hp), "damage": float(monster.damage), + "range": float(monster.attack_range), "new": round_number > 1 and not before.has(id), "armor": int(monster.armor_type), "attack": int(monster.attack_type)} + order.append(id) + groups[id].count = int(groups[id].count) + 1 + var monsters: Array[Dictionary] = [] + for id: String in order: + monsters.append(groups[id]) + var champions: Array[Dictionary] = [] + var boss_entry: Dictionary = {} + if not fighting(): + for entry: Dictionary in state.announcements(): + if entry.kind == "boss": + boss_entry = {"team": int(entry.team), "lane": str(entry.lane), "bounty": int(entry.bounty), "own": int(entry.team) == team()} + else: + champions.append({"team": int(entry.team), "lane": str(entry.lane), "champion_id": str(entry.champion_id), + "name": LuneContent.CHAMPIONS[entry.champion_id].name, "own": int(entry.team) == team()}) + return {"round": round_number, "monsters": monsters, "count": wave.size(), "champions": champions, "boss": boss_entry} + +# ---------------------------------------------------------------- network test support +## Everything the HUD shows that must be identical for every seat (host, guest, bot views). +func public_digest() -> Dictionary: + var buildings: Dictionary = {} + for definition: Dictionary in state.map.buildings: + var entry: Dictionary = building(definition.id) + buildings[definition.id] = [entry.alive, int(round(float(entry.hp)))] + var lanes: Dictionary = {} + for name: String in LuneMap.LANES: + for side: int in range(2): + var threat: Dictionary = LuneLaneThreat.evaluate(state, side, name) + lanes["%s/%d" % [name, side]] = [threat.level, Array(threat.reasons)] + var seats: Array = [] + for player: Dictionary in state.players: + var hero: Dictionary = player.hero + seats.append([int(player.slot), str(player.lane), bool(player.ready), bool(player.is_human), str(hero.get("hero_id", "")), int(hero.get("xp", 0))]) + var boss_state: Dictionary = boss() + boss_state.erase("own") + boss_state.erase("chooser") + var wave: Dictionary = next_wave() + for entry: Dictionary in wave.champions: + entry.erase("own") + if not (wave.boss as Dictionary).is_empty(): + wave.boss.erase("own") + return {"round": state.round_number, "phase": state.phase, "buildings": buildings, "lanes": lanes, "seats": seats, + "boss": boss_state, "wave": wave} diff --git a/scripts/luneraria/ui/lune_hud_model.gd.uid b/scripts/luneraria/ui/lune_hud_model.gd.uid new file mode 100644 index 0000000..c1c15a3 --- /dev/null +++ b/scripts/luneraria/ui/lune_hud_model.gd.uid @@ -0,0 +1 @@ +uid://cw7iyma87jk84 diff --git a/scripts/luneraria/ui/lune_lane_threat.gd b/scripts/luneraria/ui/lune_lane_threat.gd new file mode 100644 index 0000000..27431ba --- /dev/null +++ b/scripts/luneraria/ui/lune_lane_threat.gd @@ -0,0 +1,109 @@ +class_name LuneLaneThreat +extends RefCounted +## Deterministic lane threat for one team, read only from public state: building HP, the +## units on the battlefield (positions and HP, which every client also sees) and the public +## announcements of this round. It never reads drafts, gold, unannounced bookings or bot +## notes, so host and guest compute the same level. Positions are projected onto the lane, +## because a network guest has positions but no lane progress. +## +## RUHIG nothing below applies +## UMKÄMPFT a lane tower is below 85 %, an enemy attack champion is announced here, or +## enemy units stand in the own half of the lane +## UNTER DRUCK the Grenzwacht fell or is below 50 %, enemies attack the Grenzwacht, the enemy +## has more than 1.5 times the own unit HP in the own half, or the enemy boss +## is deployed on this lane +## KRITISCH both lane towers fell (base open), the Bastion is below 50 % after the +## Grenzwacht fell, enemies attack the Bastion, or the enemy boss comes while the +## Grenzwacht is already down + +enum Level { CALM, CONTESTED, PRESSURE, CRITICAL } +const NAMES: Array[String] = ["RUHIG", "UMKÄMPFT", "UNTER DRUCK", "KRITISCH"] +const GLYPHS: Array[String] = ["check", "melee", "warning", "warning"] + +static func _building(state: LuneMatch, id: String) -> Array: + var max_hp: float = float(state.map.building_def(id).get("max_hp", 1.0)) + if state.phase == LuneMatch.Phase.COMBAT and state.combat != null and state.combat.buildings.has(id): + var unit: LuneUnit = state.combat.buildings[id] + return [unit.alive, clampf(unit.hp / maxf(1.0, max_hp), 0.0, 1.0) if unit.alive else 0.0] + var hp: float = float(state.building_hp.get(id, 0.0)) + return [hp > 0.0, clampf(hp / maxf(1.0, max_hp), 0.0, 1.0)] + +static func _percent(value: float) -> String: + return "%d %%" % int(round(value * 100.0)) + +static func evaluate(state: LuneMatch, team: int, lane: String) -> Dictionary: + var reasons: PackedStringArray = [] + var level: int = Level.CALM + var prefix: String = "blau" if team == 0 else "rot" + var enemy: int = 1 - team + var outer: Array = _building(state, "%s_%s_outer" % [prefix, lane]) + var inner: Array = _building(state, "%s_%s_inner" % [prefix, lane]) + if not outer[0] and not inner[0]: + level = Level.CRITICAL + reasons.append("Grenzwacht und Bastion gefallen – der Weg zur Basis ist offen") + elif not outer[0]: + level = Level.PRESSURE + reasons.append("Grenzwacht gefallen") + if float(inner[1]) < .5: + level = Level.CRITICAL + reasons.append("Bastion bei " + _percent(inner[1])) + elif float(inner[1]) < .85: + reasons.append("Bastion bei " + _percent(inner[1])) + elif float(outer[1]) < .5: + level = Level.PRESSURE + reasons.append("Grenzwacht bei " + _percent(outer[1])) + elif float(outer[1]) < .85: + level = Level.CONTESTED + reasons.append("Grenzwacht bei " + _percent(outer[1])) + for entry: Dictionary in state.announcements(): + if int(entry.team) != enemy or str(entry.lane) != lane: + continue + if entry.kind == "boss": + level = maxi(level, Level.CRITICAL if not outer[0] else Level.PRESSURE) + reasons.append("Gegnerische Schlundkrone greift hier an") + else: + level = maxi(level, Level.CONTESTED) + reasons.append("Angriffschampion %s angekündigt" % LuneContent.CHAMPIONS[entry.champion_id].name) + if state.phase == LuneMatch.Phase.COMBAT and state.combat != null: + var battle: Dictionary = battlefield(state, team, lane) + var length: float = float(battle.length) + if int(battle.enemy_count) > 0: + var deepest: float = float(battle.enemy_depth) + var inner_at: float = length * state.balance.inner_tower_fraction + var outer_at: float = length * state.balance.outer_tower_fraction + var reach: float = state.balance.tower_range * .8 + if deepest <= inner_at + reach: + level = Level.CRITICAL + reasons.append("Gegner greifen die Bastion oder die Basis an") + elif deepest <= outer_at + reach and outer[0]: + level = maxi(level, Level.PRESSURE) + reasons.append("Gegner greifen die Grenzwacht an") + elif deepest <= length * .5: + level = maxi(level, Level.CONTESTED) + reasons.append("Gegner in der eigenen Lanehälfte") + if float(battle.enemy_hp) > float(battle.own_hp) * 1.5 and deepest <= length * .5: + level = maxi(level, Level.PRESSURE) + reasons.append("Übermacht: %d gegen %d Einheiten" % [int(battle.enemy_count), int(battle.own_count)]) + return {"level": level, "name": NAMES[level], "reasons": reasons} + +## Units of both sides on one lane: counts, HP sums and the deepest enemy position measured +## from the own base along the lane (from positions only). +static func battlefield(state: LuneMatch, team: int, lane: String) -> Dictionary: + var path: LuneMap.LanePath = state.map.lanes[lane] + var result: Dictionary = {"length": path.length, "own_count": 0, "enemy_count": 0, "own_hp": 0.0, "enemy_hp": 0.0, "enemy_depth": INF, "own_front": 0.0} + if state.combat == null: + return result + for unit: LuneUnit in state.combat.units: + if not unit.alive or not unit.is_mobile() or unit.zone != "lane" or unit.lane != lane or unit.team > 1: + continue + var s: float = path.project(unit.position) + var from_own_base: float = s if team == 0 else path.length - s + if unit.team == team: + result.own_count = int(result.own_count) + 1 + result.own_hp = float(result.own_hp) + unit.hp + result.own_front = maxf(float(result.own_front), from_own_base) + else: + result.enemy_count = int(result.enemy_count) + 1 + result.enemy_hp = float(result.enemy_hp) + unit.hp + result.enemy_depth = minf(float(result.enemy_depth), from_own_base) + return result diff --git a/scripts/luneraria/ui/lune_lane_threat.gd.uid b/scripts/luneraria/ui/lune_lane_threat.gd.uid new file mode 100644 index 0000000..584092a --- /dev/null +++ b/scripts/luneraria/ui/lune_lane_threat.gd.uid @@ -0,0 +1 @@ +uid://bqbni0wogws00 diff --git a/scripts/luneraria/ui/lune_minimap.gd b/scripts/luneraria/ui/lune_minimap.gd index bccfdba..da33597 100644 --- a/scripts/luneraria/ui/lune_minimap.gd +++ b/scripts/luneraria/ui/lune_minimap.gd @@ -1,19 +1,54 @@ extends Control -## Minimap drawn from the authoritative match state. Click or drag to move the camera. +## Minimap from the public match state. Lanes, river, grotto and both bases as the map; every +## building in its team's shape language (round = Blau, angular = Rot; Ancients as crescent +## and sun disc with an HP ring); heroes, attack champions and the boss as their own markers; +## troops and monsters merged per map cell into one marker whose size grows with the count, +## so there is no cloud of pixels. Click or drag moves the camera; the minimap never buys, +## orders or changes anything in the world. It redraws only when the HUD refreshes it (about +## ten times a second in combat) and the camera frame lives on a separate overlay. signal focus_requested(point: Vector3) -const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55"), Color("b37cff")] + +const CELL: float = 6.0 +const Overlay = preload("res://scripts/luneraria/ui/lune_minimap_overlay.gd") + var match_state: LuneMatch +var viewer_team: int = 0 +var human_slot: int = 0 var camera_focus := Vector3.ZERO var camera_span: float = 34.0 ## Ground outline of the game camera's view (perspective trapezoid); empty = rectangle. -var camera_footprint := PackedVector2Array() +var camera_footprint := PackedVector2Array(): + set(value): + if value.size() != camera_footprint.size() or _moved(value): + camera_footprint = value + if _overlay != null: + _overlay.queue_redraw() +## Counts for tests and the performance log. +var markers: Dictionary = {} +var redraws: int = 0 +var _clusters: Array = [] +var _specials: Array = [] +var _overlay: Control +var _dangers: Array = [] func _ready() -> void: - custom_minimum_size = Vector2(236, 236) + custom_minimum_size = Vector2(224, 224) mouse_filter = Control.MOUSE_FILTER_STOP focus_mode = Control.FOCUS_NONE - tooltip_text = "Minimap · Klicken oder ziehen, um die Kamera zu bewegen" + mouse_default_cursor_shape = Control.CURSOR_CROSS + clip_contents = true + _overlay = Overlay.new() + _overlay.map = self + _overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(_overlay) + resized.connect(queue_redraw) + +func _moved(value: PackedVector2Array) -> bool: + for index: int in range(value.size()): + if value[index].distance_squared_to(camera_footprint[index]) > .04: + return true + return false func to_map(v: Vector2) -> Vector2: var extent: float = LuneMap.HALF_EXTENT + 4.0 @@ -25,56 +60,157 @@ func to_world(p: Vector2) -> Vector3: return Vector3(v.x, 0, v.y) func _gui_input(event: InputEvent) -> void: - if (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT) or (event is InputEventMouseMotion and event.button_mask & MOUSE_BUTTON_MASK_LEFT): + if event is InputEventMouseButton: + if event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + focus_requested.emit(to_world(event.position)) + accept_event() + elif event is InputEventMouseMotion and event.button_mask & MOUSE_BUTTON_MASK_LEFT: focus_requested.emit(to_world(event.position)) accept_event() +## Recomputes markers from the match (called by the HUD at its update rate). +func refresh(announcements: Array = []) -> void: + _clusters.clear() + _specials.clear() + _dangers = announcements + var counts: Dictionary = {"clusters": 0, "heroes": 0, "champions": 0, "boss": 0, "units": 0} + if match_state != null and match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null: + var cells: Dictionary = {} + for unit: LuneUnit in match_state.combat.units: + if not unit.alive or not unit.is_mobile(): + continue + counts.units += 1 + if unit.kind in ["hero", "champion", "boss"]: + _specials.append([unit.kind, unit.team, unit.position, unit.owner_slot == human_slot, unit.hp / maxf(1.0, unit.max_hp)]) + counts[{"hero": "heroes", "champion": "champions", "boss": "boss"}[unit.kind]] += 1 + continue + var key := Vector3i(unit.team, floori(unit.position.x / CELL), floori(unit.position.y / CELL)) + if not cells.has(key): + cells[key] = [Vector2.ZERO, 0] + cells[key][0] += unit.position + cells[key][1] += 1 + for key: Vector3i in cells: + var entry: Array = cells[key] + _clusters.append([key.x, entry[0] / float(entry[1]), int(entry[1])]) + counts.clusters = _clusters.size() + markers = counts + redraws += 1 + queue_redraw() + func _draw() -> void: - draw_rect(Rect2(Vector2.ZERO, size), Color(.04, .06, .06, .92)) - draw_rect(Rect2(Vector2.ZERO, size), Color("5c4a33"), false, 1.5) + var rect := Rect2(Vector2.ZERO, size) + draw_rect(rect, Color(.035, .05, .06, .95)) if match_state == null: return - # The Kluft: from the grotto spring along the symmetry axis into the south-eastern delta. + var scale_factor: float = size.x / 224.0 + # Ground: the Kluft from the grotto spring into the south-eastern delta, lanes, plazas. var spring: Vector2 = LuneMap.GROTTO + LuneMap.AXIS * LuneTerrain.ARENA_RADIUS - draw_line(to_map(spring), to_map(Vector2(40, 40)), Color(.12, .38, .42, .8), 7.0) - draw_circle(to_map(Vector2(33, 33)), 11.0, Color(.12, .38, .42, .8)) + draw_line(to_map(spring), to_map(Vector2(40, 40)), Color(.1, .3, .34, .9), 6.0 * scale_factor) + draw_circle(to_map(Vector2(33, 33)), 10.0 * scale_factor, Color(.1, .3, .34, .9)) + for team: int in range(2): + draw_circle(to_map(match_state.map.core_position(team)), 17.0 * scale_factor, Color(LuneUiTheme.TEAM_DARK[team], .55)) for lane: String in LuneMap.LANES: var path: LuneMap.LanePath = match_state.map.lanes[lane] var points := PackedVector2Array() for p: Vector2 in path.points: points.append(to_map(p)) - draw_polyline(points, Color(.62, .56, .42), 4.0, true) - var grotto: Vector2 = to_map(LuneMap.GROTTO) - var claim: Dictionary = match_state.boss.claim - var grotto_color: Color = TEAM_COLORS[int(claim.team)] if not claim.is_empty() else (Color(.62, .42, .95) if match_state.grotto_available() else Color(.35, .32, .4)) - draw_circle(grotto, 6.0, grotto_color) - draw_arc(grotto, 6.0, 0, TAU, 16, Color(1, 1, 1, .6), 1.0) - var hp: Dictionary = {} - if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null: - for unit: LuneUnit in match_state.combat.units: - if unit.is_mobile() and unit.alive: - 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)) + draw_polyline(points, Color(.2, .19, .16), 7.0 * scale_factor, true) + draw_polyline(points, Color(.55, .5, .4), 3.2 * scale_factor, true) + _draw_grotto(scale_factor) + # Buildings. for definition: Dictionary in match_state.map.buildings: - var alive: bool = match_state.building_alive(definition.id) - if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null and match_state.combat.buildings.has(definition.id): - alive = (match_state.combat.buildings[definition.id] as LuneUnit).alive - var at: Vector2 = to_map(definition.position) - var radius: float = 6.5 if definition.tier == "core" else 4.0 - var color: Color = TEAM_COLORS[int(definition.team)] - if alive: - draw_circle(at, radius, color) - draw_arc(at, radius, 0, TAU, 16, Color(1, 1, 1, .6), 1.0) - else: - draw_arc(at, radius, 0, TAU, 16, color.darkened(.3), 1.5) - draw_line(at - Vector2(radius, radius) * .6, at + Vector2(radius, radius) * .6, color.darkened(.3), 1.5) - if camera_footprint.size() == 4: - var outline := PackedVector2Array() - for corner: Vector2 in camera_footprint: - outline.append(to_map(corner).clamp(Vector2.ZERO, size)) - outline.append(outline[0]) - draw_polyline(outline, Color(1, 1, 1, .6), 1.2, true) + _draw_building(definition, scale_factor) + # Announced dangers: a warning mark at the own end of the lane. + for entry: Dictionary in _dangers: + if int(entry.team) == viewer_team: + continue + var at: Vector2 = to_map(match_state.map.lane_point(viewer_team, str(entry.lane), match_state.map.lanes[str(entry.lane)].length * .5)) + var edge: float = 13.0 * scale_factor + draw_circle(at, edge * .62, Color(0, 0, 0, .6)) + LuneGlyphs.draw(self, "crown" if entry.kind == "boss" else "warning", Rect2(at - Vector2(edge, edge) * .5, Vector2(edge, edge)), LuneUiTheme.DANGER) + # Armies merged per cell. + for cluster: Array in _clusters: + var team: int = cluster[0] + var at: Vector2 = to_map(cluster[1]) + var radius: float = (2.0 + 1.15 * sqrt(float(cluster[2]))) * scale_factor + var tint: Color = LuneUiTheme.team_color(team) if team <= 1 else LuneUiTheme.TEAM[2] + _shape(team, at, radius + 1.2 * scale_factor, Color(0, 0, 0, .7)) + _shape(team, at, radius, Color(tint, .9)) + # Heroes, champions and the boss on top. + for special: Array in _specials: + var kind: String = special[0] + var team: int = special[1] + var at: Vector2 = to_map(special[2]) + var tint: Color = LuneUiTheme.team_color(team) + match kind: + "hero": + var radius: float = 4.6 * scale_factor + draw_circle(at, radius + 2.4 * scale_factor, Color(0, 0, 0, .75)) + draw_arc(at, radius + 1.2 * scale_factor, 0, TAU, 20, LuneUiTheme.HERO, 1.8 * scale_factor, true) + _shape(team, at, radius, tint) + if special[3]: + draw_arc(at, radius + 3.6 * scale_factor, 0, TAU, 24, Color.WHITE, 1.2 * scale_factor, true) + "champion": + var edge: float = 12.0 * scale_factor + draw_circle(at, edge * .55, Color(0, 0, 0, .75)) + LuneGlyphs.draw(self, "champion", Rect2(at - Vector2(edge, edge) * .5, Vector2(edge, edge)), tint.lerp(LuneUiTheme.CHAMPION, .35)) + "boss": + var edge: float = 17.0 * scale_factor + draw_circle(at, edge * .6, Color(0, 0, 0, .8)) + draw_arc(at, edge * .6, 0, TAU, 24, tint, 1.6 * scale_factor, true) + LuneGlyphs.draw(self, "crown", Rect2(at - Vector2(edge, edge) * .45, Vector2(edge, edge) * .9), tint.lightened(.2)) + draw_rect(rect, Color("3a4656"), false, 1.0) + +func _shape(team: int, at: Vector2, radius: float, color: Color) -> void: + if team == 1: + draw_colored_polygon(PackedVector2Array([at + Vector2(0, -radius * 1.2), at + Vector2(radius * 1.2, 0), at + Vector2(0, radius * 1.2), at + Vector2(-radius * 1.2, 0)]), color) else: - var center: Vector2 = to_map(Vector2(camera_focus.x, camera_focus.z)) - var span: float = camera_span / ((LuneMap.HALF_EXTENT + 4.0) * 2.0) * size.x - draw_rect(Rect2(center - Vector2(span * .9, span * .55), Vector2(span * 1.8, span * 1.1)), Color(1, 1, 1, .55), false, 1.2) + draw_circle(at, radius, color) + +func _draw_building(definition: Dictionary, scale_factor: float) -> void: + var id: String = definition.id + var team: int = int(definition.team) + var alive: bool = match_state.building_alive(id) + var hp: float = float(match_state.building_hp.get(id, 0.0)) + if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null and match_state.combat.buildings.has(id): + var unit: LuneUnit = match_state.combat.buildings[id] + alive = unit.alive + hp = unit.hp + var fraction: float = clampf(hp / maxf(1.0, float(definition.max_hp)), 0.0, 1.0) if alive else 0.0 + var at: Vector2 = to_map(definition.position) + var tint: Color = LuneUiTheme.team_color(team) + if definition.tier == "core": + var edge: float = 18.0 * scale_factor + var radius: float = 10.5 * scale_factor + draw_circle(at, radius, Color(0, 0, 0, .7)) + draw_arc(at, radius, 0, TAU, 32, Color(tint, .25), 2.4 * scale_factor, true) + if fraction > 0.0: + draw_arc(at, radius, -PI * .5, -PI * .5 + TAU * fraction, 32, LuneHpBar.health_color(fraction, tint), 2.4 * scale_factor, true) + LuneGlyphs.draw(self, "emblem_%d" % team, Rect2(at - Vector2(edge, edge) * .38, Vector2(edge, edge) * .76), tint if alive else Color(.4, .4, .4)) + return + var radius: float = (4.4 if definition.tier == "base" else 3.8) * scale_factor + if alive: + _shape(team, at, radius + 1.3 * scale_factor, Color(0, 0, 0, .75)) + _shape(team, at, radius, tint) + if fraction < .99: + var bar := Rect2(at + Vector2(-radius - 1.0, radius + 2.5 * scale_factor), Vector2(radius * 2.0 + 2.0, 2.0 * scale_factor)) + draw_rect(bar, Color(0, 0, 0, .8)) + draw_rect(Rect2(bar.position, Vector2(bar.size.x * fraction, bar.size.y)), LuneHpBar.health_color(fraction, tint)) + else: + _shape(team, at, radius, Color(0, 0, 0, .55)) + var cross: float = radius * .8 + draw_line(at - Vector2(cross, cross), at + Vector2(cross, cross), Color(.55, .55, .58), 1.4 * scale_factor, true) + draw_line(at + Vector2(-cross, cross), at + Vector2(cross, -cross), Color(.55, .55, .58), 1.4 * scale_factor, true) + +func _draw_grotto(scale_factor: float) -> void: + var at: Vector2 = to_map(LuneMap.GROTTO) + var claim: Dictionary = match_state.boss.claim + var tint: Color = LuneUiTheme.TEAM[2] + if not claim.is_empty(): + tint = LuneUiTheme.team_color(int(claim.team)) + elif not match_state.grotto_available(): + tint = Color(.42, .4, .46) + var edge: float = 16.0 * scale_factor + draw_circle(at, edge * .72, Color(0, 0, 0, .65)) + draw_arc(at, edge * .72, 0, TAU, 24, Color(tint, .9), 1.4 * scale_factor, true) + LuneGlyphs.draw(self, "grotto", Rect2(at - Vector2(edge, edge) * .42, Vector2(edge, edge) * .84), tint) diff --git a/scripts/luneraria/ui/lune_minimap_overlay.gd b/scripts/luneraria/ui/lune_minimap_overlay.gd new file mode 100644 index 0000000..57b7297 --- /dev/null +++ b/scripts/luneraria/ui/lune_minimap_overlay.gd @@ -0,0 +1,24 @@ +extends Control +## Camera frame on the minimap. A separate layer, so moving the camera only redraws this +## outline and not the whole map. + +var map: Control + +func _init() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + +func _draw() -> void: + if map == null: + return + var footprint: PackedVector2Array = map.camera_footprint + if footprint.size() == 4: + var outline := PackedVector2Array() + for corner: Vector2 in footprint: + outline.append(map.to_map(corner).clamp(Vector2.ZERO, size)) + draw_colored_polygon(outline, Color(1, 1, 1, .05)) + outline.append(outline[0]) + draw_polyline(outline, Color(1, 1, 1, .75), 1.3, true) + else: + var center: Vector2 = map.to_map(Vector2(map.camera_focus.x, map.camera_focus.z)) + var span: float = map.camera_span / ((LuneMap.HALF_EXTENT + 4.0) * 2.0) * size.x + draw_rect(Rect2(center - Vector2(span * .9, span * .55), Vector2(span * 1.8, span * 1.1)), Color(1, 1, 1, .6), false, 1.2) diff --git a/scripts/luneraria/ui/lune_minimap_overlay.gd.uid b/scripts/luneraria/ui/lune_minimap_overlay.gd.uid new file mode 100644 index 0000000..7384832 --- /dev/null +++ b/scripts/luneraria/ui/lune_minimap_overlay.gd.uid @@ -0,0 +1 @@ +uid://di4aeg6oademq diff --git a/scripts/luneraria/ui/lune_reinforce_panel.gd b/scripts/luneraria/ui/lune_reinforce_panel.gd deleted file mode 100644 index 0fd57b2..0000000 --- a/scripts/luneraria/ui/lune_reinforce_panel.gd +++ /dev/null @@ -1,113 +0,0 @@ -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) diff --git a/scripts/luneraria/ui/lune_reinforce_panel.gd.uid b/scripts/luneraria/ui/lune_reinforce_panel.gd.uid deleted file mode 100644 index 42bdfb8..0000000 --- a/scripts/luneraria/ui/lune_reinforce_panel.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://2f0vric1dx0m diff --git a/scripts/luneraria/ui/lune_settings.gd b/scripts/luneraria/ui/lune_settings.gd index 7f252e9..526c56c 100644 --- a/scripts/luneraria/ui/lune_settings.gd +++ b/scripts/luneraria/ui/lune_settings.gd @@ -1,14 +1,26 @@ class_name LuneSettings extends ColorRect -## Options and controls for Champions of Luneraria; the same Preferences as before. +## Options and controls for Champions of Luneraria; the same Preferences as before plus the +## HUD size. The key list is the one place where every shortcut is shown. signal closed +const SCALES: Array[float] = [.9, 1.0, 1.15, 1.3] +const KEYS: Array = [ + ["Planung", [["1–5", "Truppe kaufen"], ["Leertaste", "Bereit"], ["Klick auf Einheit", "Details, Aufwerten, Einsatz"], ["Esc", "Auswahl oder Fenster schließen"]]], + ["Kampf", [["Leertaste / P", "Pause (offline)"], ["Tempo-Knopf", "1× · 2× · 4×"]]], + ["Kamera", [["F2 · F3 · F4", "Top · Mid · Bottom"], ["G", "Bossgrotte"], ["B", "eigener Ancient"], ["H", "eigener Held"], + ["Tab", "nächste Lane"], ["O", "Übersicht"], ["R", "zurücksetzen"], ["Q / E", "drehen"], ["WASD · Pfeile · mittlere Maustaste", "verschieben"], + ["Mausrad", "Zoom"], ["Minimap", "klicken oder ziehen"]]], + ["Allgemein", [["F1", "Optionen"], ["M", "Ton an/aus"], ["F11", "Vollbild"]]], +] + var _volume: HSlider var _checks: Dictionary = {} +var _scale: ChoiceGroup func _ready() -> void: - theme = SaumwachtTheme.shared() + theme = LuneUiTheme.shared() color = Color(.02, .03, .04, .9) set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_STOP @@ -17,38 +29,74 @@ func _ready() -> void: add_child(center) var panel := PanelContainer.new() panel.theme_type_variation = "MenuPanel" - panel.custom_minimum_size.x = 760 + panel.custom_minimum_size.x = 900 center.add_child(panel) var column := VBoxContainer.new() - column.add_theme_constant_override("separation", 12) + column.add_theme_constant_override("separation", 10) panel.add_child(column) var heading := _label("Optionen & Steuerung", "HeadingLabel", column) heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _label("Ein laufender Kampf pausiert, solange dieses Fenster geöffnet ist.", "MutedLabel", column).horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _label("LAUTSTÄRKE", "SectionLabel", column) + _label("Ein laufender Kampf pausiert offline, solange dieses Fenster geöffnet ist.", "MutedLabel", column).horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + var top := HBoxContainer.new() + top.add_theme_constant_override("separation", 30) + column.add_child(top) + var left := VBoxContainer.new() + left.add_theme_constant_override("separation", 8) + left.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top.add_child(left) + _label("LAUTSTÄRKE", "SectionLabel", left) _volume = HSlider.new() _volume.max_value = 100 _volume.custom_minimum_size.y = 28 - column.add_child(_volume) + left.add_child(_volume) _volume.value_changed.connect(func(value: float) -> void: Preferences.volume = value / 100.0 Preferences.commit()) - var grid := GridContainer.new() - grid.columns = 2 - grid.add_theme_constant_override("h_separation", 28) - column.add_child(grid) for entry: Array in [["muted", "Ton stumm [M]"], ["fullscreen", "Vollbild [F11]"], ["reduced_motion", "Bewegung reduzieren"]]: var check := CheckButton.new() check.text = entry[1] - grid.add_child(check) + left.add_child(check) var key: String = entry[0] check.toggled.connect(func(value: bool) -> void: Preferences.set(key, value) Preferences.commit()) _checks[key] = check + var right := VBoxContainer.new() + right.add_theme_constant_override("separation", 8) + right.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top.add_child(right) + _label("UI-GRÖSSE IM SPIEL", "SectionLabel", right) + _scale = ChoiceGroup.new(true) + for value: float in SCALES: + _scale.add_option("%d %%" % int(round(value * 100.0))) + right.add_child(_scale) + _scale.item_selected.connect(func(index: int) -> void: + Preferences.ui_scale = SCALES[index] + Preferences.commit()) + var note := _label("Größer für hochauflösende oder weit entfernte Bildschirme. Das HUD begrenzt die Größe selbst, sodass nichts aus dem Fenster ragt.", "MutedLabel", right) + note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART column.add_child(HSeparator.new()) _label("STEUERUNG", "SectionLabel", column) - _label("Planung: 1–5 Truppe kaufen · Lane-Knöpfe T/M/B teilen ein · Leertaste Bereit\nKampf: Leertaste oder P Pause · Tempo 1×/2×/4× · Kämpfe laufen automatisch\nKamera: WASD / Pfeile / mittlere Maustaste · Mausrad Zoom · Q/E drehen · R zurücksetzen\nSprünge: F2 Top · F3 Mid · F4 Bottom · Tab nächste Lane · B eigene Basis · G Bossgrotte · H Held · O Übersicht\nMinimap anklicken springt dorthin · F1 Optionen · M Ton · F11 Vollbild", "Label", column) + var keys := HBoxContainer.new() + keys.add_theme_constant_override("separation", 26) + column.add_child(keys) + for index: int in range(2): + var list := GridContainer.new() + list.columns = 2 + list.add_theme_constant_override("h_separation", 14) + list.add_theme_constant_override("v_separation", 2) + list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + keys.add_child(list) + for group: Array in (KEYS.slice(0, 2) + [KEYS[3]] if index == 0 else [KEYS[2]]): + var caption := _label(str(group[0]).to_upper(), "EyebrowLabel", list) + caption.add_theme_font_size_override("font_size", 14) + list.add_child(Control.new()) + for pair: Array in group[1]: + var key_label := _label(str(pair[0]), "CardFactLabel", list) + key_label.add_theme_font_size_override("font_size", 15) + key_label.add_theme_color_override("font_color", LuneUiTheme.BRONZE_LIGHT) + var action := _label(str(pair[1]), "MutedLabel", list) + action.add_theme_font_size_override("font_size", 15) var back := Button.new() back.text = "ZURÜCK [Esc]" back.theme_type_variation = "PrimaryButton" @@ -66,3 +114,8 @@ func sync() -> void: _volume.set_value_no_signal(Preferences.volume * 100.0) for key: String in _checks: (_checks[key] as CheckButton).set_pressed_no_signal(bool(Preferences.get(key))) + var best: int = 1 + for index: int in range(SCALES.size()): + if absf(SCALES[index] - Preferences.ui_scale) < absf(SCALES[best] - Preferences.ui_scale): + best = index + _scale.select(best) diff --git a/scripts/luneraria/ui/lune_stats.gd b/scripts/luneraria/ui/lune_stats.gd new file mode 100644 index 0000000..3cd0920 --- /dev/null +++ b/scripts/luneraria/ui/lune_stats.gd @@ -0,0 +1,82 @@ +class_name LuneStats +extends RefCounted +## Display figures taken from the real unit definitions and combat rules: damage per hit, +## one attack per second times the attack tempo bonus, range, armour and damage type with +## the damage matrix (LuneUnit.DAMAGE_MATRIX). Nothing is invented; values are only formatted. + +const ROLE_NAMES: Dictionary = {"front": "Tank", "melee": "Nahkampf", "ranged": "Fernkampf", "splash": "Flächenschaden", "support": "Support"} +const ROLE_GLYPHS: Dictionary = {"front": "tank", "melee": "melee", "ranged": "ranged", "splash": "splash", "support": "support"} + +static func role_name(role: String) -> String: + return ROLE_NAMES.get(role, role) + +static func role_glyph(role: String) -> String: + return ROLE_GLYPHS.get(role, "melee") + +## Armour class the damage type hits for 130 % / 75 % (DAMAGE_MATRIX). +static func strong_against(attack_type: int) -> String: + var row: Array = LuneUnit.DAMAGE_MATRIX[clampi(attack_type, 0, 2)] + return DataCatalog.ARMOR_NAMES[row.find(1.30)] + +static func weak_against(attack_type: int) -> String: + var row: Array = LuneUnit.DAMAGE_MATRIX[clampi(attack_type, 0, 2)] + return DataCatalog.ARMOR_NAMES[row.find(0.75)] + +static func hit_damage(definition: Dictionary) -> float: + return float(definition.get("damage", 0.0)) * (1.0 + float(definition.get("damage_bonus", 0.0))) + +static func attacks_per_second(definition: Dictionary) -> float: + return 1.0 + clampf(float(definition.get("attack_speed_bonus", 0.0)), -.25, .5) + +## Stat rows [glyph, name, value] for tooltips and detail panels. +static func rows(definition: Dictionary, with_move: bool = false) -> Array: + var attack_type: int = int(definition.get("attack_type", 0)) + var armor_type: int = int(definition.get("armor_type", 0)) + var reduction: float = float(definition.get("reduction_bonus", 0.0)) + var result: Array = [ + ["heart", "Leben", LuneUiTheme.number(float(definition.get("max_hp", 0.0)))], + ["sword", "Schaden/Treffer", LuneUiTheme.decimal(hit_damage(definition))], + ["tempo", "Angriffstempo", "%s /s" % LuneUiTheme.decimal(attacks_per_second(definition), 2)], + ["range", "Reichweite", "%s m" % LuneUiTheme.decimal(float(definition.get("attack_range", 0.0)))], + ["armor", "Rüstung", DataCatalog.ARMOR_NAMES[clampi(armor_type, 0, 2)] + (" · −%d %% Schaden" % int(round(reduction * 100.0)) if reduction > 0.0 else "")], + ["splash", "Angriffsart", "%s · stark gegen %s" % [DataCatalog.DAMAGE_NAMES[clampi(attack_type, 0, 2)], strong_against(attack_type)]], + ] + if with_move: + result.append(["move", "Lauftempo", "%s m/s" % LuneUiTheme.decimal(float(definition.get("speed", 0.0)))]) + var regen: float = float(definition.get("regen", 0.0)) + if regen > 0.0: + result.append(["heart", "Regeneration", "%s %% Leben/s" % LuneUiTheme.decimal(regen * 100.0)]) + var splash: float = float(definition.get("item_splash", 0.0)) + if splash > 0.0: + result.append(["splash", "Splitter", "%d %% an bis zu 2 Gegner" % int(round(splash * 100.0))]) + return result + +## Compact pairs [glyph, "Name Wert"] for narrow panels (two per row). +static func short_rows(definition: Dictionary) -> Array: + var attack_type: int = clampi(int(definition.get("attack_type", 0)), 0, 2) + return [ + ["heart", "Leben %s" % LuneUiTheme.number(float(definition.get("max_hp", 0.0)))], + ["sword", "Schaden %s" % LuneUiTheme.decimal(hit_damage(definition))], + ["tempo", "Tempo %s/s" % LuneUiTheme.decimal(attacks_per_second(definition), 2)], + ["range", "Reichweite %s m" % LuneUiTheme.decimal(float(definition.get("attack_range", 0.0)))], + ["armor", "Rüstung %s" % DataCatalog.ARMOR_NAMES[clampi(int(definition.get("armor_type", 0)), 0, 2)]], + ["splash", "%s › %s" % [DataCatalog.DAMAGE_NAMES[attack_type], strong_against(attack_type)]], + ] + +## "Schaden pro Sekunde" from hit damage and tempo, without abilities or type modifiers. +static func dps(definition: Dictionary) -> float: + return hit_damage(definition) * attacks_per_second(definition) + +## Tooltip content for a troop type (shop cards, army chips). +static func troop_tip(type_id: String, role: String, extra: String = "") -> Dictionary: + var data: Dictionary = DataCatalog.get_unit(type_id) + var upgrade: String = str(data.get("upgrade_id", "")) + var note: String = "" + if not upgrade.is_empty(): + var next: Dictionary = DataCatalog.get_unit(upgrade) + note = "Aufwertung: %s für %d Gold (Leben %s → %s, Schaden %s → %s)" % [next.display_name, int(next.cost), + LuneUiTheme.number(data.max_hp), LuneUiTheme.number(next.max_hp), LuneUiTheme.decimal(data.damage), LuneUiTheme.decimal(next.damage)] + else: + note = "Höchste Stufe." + return {"title": str(data.display_name), "glyph": role_glyph(role), "subtitle": "%s · Stufe %s · %d Gold" % [role_name(role), "II" if int(type_id.substr(1)) % 2 == 0 else "I", int(data.cost)], + "stats": rows(data), "text": str(data.get("ability_description", "")), "note": note, "warn": extra} diff --git a/scripts/luneraria/ui/lune_stats.gd.uid b/scripts/luneraria/ui/lune_stats.gd.uid new file mode 100644 index 0000000..3dee3a7 --- /dev/null +++ b/scripts/luneraria/ui/lune_stats.gd.uid @@ -0,0 +1 @@ +uid://bf4eigg6ryxi4 diff --git a/scripts/luneraria/ui/theme/lune_glyphs.gd b/scripts/luneraria/ui/theme/lune_glyphs.gd new file mode 100644 index 0000000..c6885ec --- /dev/null +++ b/scripts/luneraria/ui/theme/lune_glyphs.gd @@ -0,0 +1,282 @@ +class_name LuneGlyphs +extends RefCounted +## Vector symbols for the HUD, drawn from a unit square so they stay sharp at every UI scale +## and resolution. Team emblems follow the G2 world: Blau = Mondsichel (round shapes), +## Rot = Sonnenscheibe (rayed, angular shapes), the grotto = hexagon. Symbols are drawn +## with draw_* calls into any CanvasItem; nothing here allocates nodes. + +const NAMES: Array[String] = ["coin", "emblem_0", "emblem_1", "emblem_2", "shape_0", "shape_1", "shape_2", + "tank", "melee", "ranged", "splash", "support", "siege", "crown", "champion", "hourglass", "tower", + "ancient", "warning", "check", "close", "bot", "offline", "clock", "heart", "tempo", "range", "armor", + "move", "star", "lock", "up", "lane_top", "lane_mid", "lane_bot", "grotto", "pause", "play", "menu", + "gear", "human", "sword", "basaltschild", "glutklinge", "rankenmantel", "funkenfokus", "quellamulett", + "kristallsplitter", "hero", "skull"] + +static var _cache: Dictionary = {} + +## Fits the unit square into rect (centred, aspect kept). +static func _frame(rect: Rect2) -> Transform2D: + var s: float = minf(rect.size.x, rect.size.y) + var origin: Vector2 = rect.position + (rect.size - Vector2(s, s)) * .5 + return Transform2D(0.0, Vector2(s, s), 0.0, origin) + +static func _pts(frame: Transform2D, points: Array) -> PackedVector2Array: + var result := PackedVector2Array() + for p: Vector2 in points: + result.append(frame * p) + return result + +static func _rot(points: Array, angle: float, pivot: Vector2 = Vector2(.5, .5)) -> Array: + var result: Array = [] + for p: Vector2 in points: + result.append(pivot + (p - pivot).rotated(angle)) + return result + +static func _fill(ci: CanvasItem, points: PackedVector2Array, color: Color) -> void: + if points.size() < 3: + return + ci.draw_colored_polygon(points, color) + var outline := points.duplicate() + outline.append(points[0]) + ci.draw_polyline(outline, color, 1.0, true) + +static func _line(ci: CanvasItem, frame: Transform2D, points: Array, color: Color, width: float) -> void: + ci.draw_polyline(_pts(frame, points), color, maxf(1.0, width * frame.x.x), true) + +static func _circle(points: int, center: Vector2, radius: float, start: float = 0.0) -> Array: + var result: Array = [] + for index: int in range(points): + var angle: float = start + TAU * float(index) / float(points) + result.append(center + Vector2(cos(angle), sin(angle)) * radius) + return result + +static func _star(points: int, outer: float, inner: float, start: float = -PI * .5) -> Array: + var result: Array = [] + for index: int in range(points * 2): + var angle: float = start + PI * float(index) / float(points) + result.append(Vector2(.5, .5) + Vector2(cos(angle), sin(angle)) * (outer if index % 2 == 0 else inner)) + return result + +## Crescent (Blau): outer disc minus an offset disc, computed once. +static func _crescent() -> Array: + if not _cache.has("crescent"): + var outer := PackedVector2Array(_circle(48, Vector2(.5, .5), .42)) + var inner := PackedVector2Array(_circle(48, Vector2(.66, .4), .33)) + var clipped: Array = Geometry2D.clip_polygons(outer, inner) + var result: Array = [] + if not clipped.is_empty(): + for p: Vector2 in clipped[0]: + result.append(p) + _cache["crescent"] = result + return _cache["crescent"] + +static func _heart() -> Array: + if not _cache.has("heart"): + var result: Array = [] + for index: int in range(40): + var t: float = TAU * float(index) / 40.0 + var x: float = 16.0 * pow(sin(t), 3) + var y: float = 13.0 * cos(t) - 5.0 * cos(2.0 * t) - 2.0 * cos(3.0 * t) - cos(4.0 * t) + result.append(Vector2(.5 + x / 38.0, .47 - y / 38.0)) + _cache["heart"] = result + return _cache["heart"] + +static func _drop() -> Array: + var result: Array = [Vector2(.5, .16)] + for index: int in range(17): + var angle: float = -PI * .15 + PI * 1.3 * float(index) / 16.0 + result.append(Vector2(.5, .64) + Vector2(cos(angle), sin(angle)) * .22) + return result + +## Draws one symbol. `accent` is an optional second colour (inner details); by default a +## darkened version of `color`. +static func draw(ci: CanvasItem, glyph: String, rect: Rect2, color: Color, accent: Color = Color(0, 0, 0, 0)) -> void: + var f: Transform2D = _frame(rect) + var dark: Color = accent if accent.a > 0.0 else Color(color.darkened(.55), color.a) + match glyph: + "coin": + var c: Vector2 = f * Vector2(.5, .5) + var r: float = f.x.x * .42 + ci.draw_circle(c, r, color) + ci.draw_arc(c, r, 0, TAU, 32, color.darkened(.35), maxf(1.0, r * .12), true) + ci.draw_arc(c, r * .62, 0, TAU, 24, color.darkened(.28), maxf(1.0, r * .1), true) + ci.draw_arc(c, r * .8, PI * 1.1, PI * 1.45, 8, Color(1, 1, 1, .55 * color.a), maxf(1.0, r * .1), true) + "emblem_0": + _fill(ci, _pts(f, _crescent()), color) + "emblem_1": + ci.draw_circle(f * Vector2(.5, .5), f.x.x * .2, color) + for index: int in range(8): + var angle: float = TAU * float(index) / 8.0 - PI * .5 + var tip := Vector2(.5, .5) + Vector2(cos(angle), sin(angle)) * .47 + var left := Vector2(.5, .5) + Vector2(cos(angle - .22), sin(angle - .22)) * .27 + var right := Vector2(.5, .5) + Vector2(cos(angle + .22), sin(angle + .22)) * .27 + _fill(ci, _pts(f, [left, tip, right]), color) + "emblem_2": + var hexagon: Array = _circle(6, Vector2(.5, .5), .42, -PI * .5) + hexagon.append(hexagon[0]) + _line(ci, f, hexagon, color, .08) + _fill(ci, _pts(f, [Vector2(.5, .3), Vector2(.64, .5), Vector2(.5, .7), Vector2(.36, .5)]), color) + "shape_0": + ci.draw_circle(f * Vector2(.5, .5), f.x.x * .4, color) + "shape_1": + _fill(ci, _pts(f, [Vector2(.5, .06), Vector2(.94, .5), Vector2(.5, .94), Vector2(.06, .5)]), color) + "shape_2": + _fill(ci, _pts(f, _circle(6, Vector2(.5, .5), .44, -PI * .5)), color) + "tank", "armor": + var shield: Array = [Vector2(.2, .16), Vector2(.8, .16), Vector2(.8, .48), Vector2(.72, .66), Vector2(.5, .87), Vector2(.28, .66), Vector2(.2, .48)] + if glyph == "tank": + _fill(ci, _pts(f, shield), color) + _line(ci, f, [Vector2(.5, .24), Vector2(.5, .74)], dark, .07) + else: + shield.append(shield[0]) + _line(ci, f, shield, color, .09) + "melee", "sword": + var blade: Array = [Vector2(.5, .06), Vector2(.56, .14), Vector2(.56, .62), Vector2(.44, .62), Vector2(.44, .14)] + _fill(ci, _pts(f, _rot(blade, PI * .25)), color) + _fill(ci, _pts(f, _rot([Vector2(.3, .62), Vector2(.7, .62), Vector2(.7, .69), Vector2(.3, .69)], PI * .25)), color) + _fill(ci, _pts(f, _rot([Vector2(.46, .69), Vector2(.54, .69), Vector2(.54, .88), Vector2(.46, .88)], PI * .25)), color) + "ranged": + var bow: Array = [] + for index: int in range(13): + var angle: float = PI * .62 + PI * .76 * float(index) / 12.0 + bow.append(Vector2(.78, .5) + Vector2(cos(angle), sin(angle)) * .5) + _line(ci, f, _rot(bow, -PI * .25), color, .08) + _line(ci, f, _rot([bow[0], bow[12]], -PI * .25), color, .03) + _line(ci, f, _rot([Vector2(.14, .5), Vector2(.82, .5)], -PI * .25), color, .06) + _fill(ci, _pts(f, _rot([Vector2(.9, .5), Vector2(.76, .41), Vector2(.76, .59)], -PI * .25)), color) + "splash": + _fill(ci, _pts(f, _star(8, .46, .2)), color) + ci.draw_circle(f * Vector2(.5, .5), f.x.x * .1, dark) + "support": + _fill(ci, _pts(f, [Vector2(.4, .14), Vector2(.6, .14), Vector2(.6, .4), Vector2(.86, .4), Vector2(.86, .6), Vector2(.6, .6), + Vector2(.6, .86), Vector2(.4, .86), Vector2(.4, .6), Vector2(.14, .6), Vector2(.14, .4), Vector2(.4, .4)]), color) + "siege": + _fill(ci, _pts(f, _rot([Vector2(.18, .18), Vector2(.74, .18), Vector2(.74, .42), Vector2(.18, .42)], -PI * .2)), color) + _fill(ci, _pts(f, _rot([Vector2(.42, .4), Vector2(.52, .4), Vector2(.52, .92), Vector2(.42, .92)], -PI * .2)), color) + "crown", "hero": + _fill(ci, _pts(f, [Vector2(.12, .74), Vector2(.12, .3), Vector2(.32, .5), Vector2(.5, .2), Vector2(.68, .5), Vector2(.88, .3), Vector2(.88, .74)]), color) + _fill(ci, _pts(f, [Vector2(.12, .78), Vector2(.88, .78), Vector2(.88, .88), Vector2(.12, .88)]), color) + if glyph == "crown": + ci.draw_circle(f * Vector2(.5, .6), f.x.x * .06, dark) + "skull": + _fill(ci, _pts(f, _circle(20, Vector2(.5, .44), .34)), color) + _fill(ci, _pts(f, [Vector2(.32, .6), Vector2(.68, .6), Vector2(.64, .86), Vector2(.36, .86)]), color) + ci.draw_circle(f * Vector2(.37, .45), f.x.x * .09, dark) + ci.draw_circle(f * Vector2(.63, .45), f.x.x * .09, dark) + "champion": + _fill(ci, _pts(f, [Vector2(.5, .06), Vector2(.82, .46), Vector2(.61, .46), Vector2(.61, .9), Vector2(.39, .9), Vector2(.39, .46), Vector2(.18, .46)]), color) + "hourglass": + _fill(ci, _pts(f, [Vector2(.24, .12), Vector2(.76, .12), Vector2(.56, .5), Vector2(.44, .5)]), color) + _fill(ci, _pts(f, [Vector2(.44, .5), Vector2(.56, .5), Vector2(.76, .88), Vector2(.24, .88)]), color) + _line(ci, f, [Vector2(.18, .1), Vector2(.82, .1)], color, .06) + _line(ci, f, [Vector2(.18, .9), Vector2(.82, .9)], color, .06) + "tower": + _fill(ci, _pts(f, [Vector2(.28, .92), Vector2(.28, .36), Vector2(.22, .36), Vector2(.22, .14), Vector2(.33, .14), Vector2(.33, .23), + Vector2(.44, .23), Vector2(.44, .14), Vector2(.56, .14), Vector2(.56, .23), Vector2(.67, .23), Vector2(.67, .14), + Vector2(.78, .14), Vector2(.78, .36), Vector2(.72, .36), Vector2(.72, .92)]), color) + _fill(ci, _pts(f, [Vector2(.44, .92), Vector2(.44, .72), Vector2(.5, .66), Vector2(.56, .72), Vector2(.56, .92)]), dark) + "ancient": + _fill(ci, _pts(f, [Vector2(.5, .05), Vector2(.76, .4), Vector2(.6, .95), Vector2(.4, .95), Vector2(.24, .4)]), color) + _line(ci, f, [Vector2(.5, .05), Vector2(.5, .95)], dark, .04) + _line(ci, f, [Vector2(.24, .4), Vector2(.5, .52), Vector2(.76, .4)], dark, .04) + "warning": + _fill(ci, _pts(f, [Vector2(.5, .08), Vector2(.94, .88), Vector2(.06, .88)]), color) + _fill(ci, _pts(f, [Vector2(.46, .34), Vector2(.54, .34), Vector2(.53, .64), Vector2(.47, .64)]), dark) + ci.draw_circle(f * Vector2(.5, .75), f.x.x * .05, dark) + "check": + _line(ci, f, [Vector2(.16, .52), Vector2(.4, .76), Vector2(.86, .24)], color, .13) + "close": + _line(ci, f, [Vector2(.2, .2), Vector2(.8, .8)], color, .12) + _line(ci, f, [Vector2(.8, .2), Vector2(.2, .8)], color, .12) + "bot", "gear": + var c: Vector2 = f * Vector2(.5, .5) + for index: int in range(8): + var angle: float = TAU * float(index) / 8.0 + _fill(ci, _pts(f, _rot([Vector2(.43, .06), Vector2(.57, .06), Vector2(.57, .24), Vector2(.43, .24)], angle)), color) + ci.draw_circle(c, f.x.x * .3, color) + ci.draw_circle(c, f.x.x * .12, dark) + "human": + ci.draw_circle(f * Vector2(.5, .3), f.x.x * .17, color) + _fill(ci, _pts(f, [Vector2(.2, .9), Vector2(.24, .62), Vector2(.38, .52), Vector2(.62, .52), Vector2(.76, .62), Vector2(.8, .9)]), color) + "offline": + ci.draw_arc(f * Vector2(.5, .5), f.x.x * .38, 0, TAU, 32, color, maxf(1.0, f.x.x * .1), true) + _line(ci, f, [Vector2(.22, .78), Vector2(.78, .22)], color, .1) + "clock": + ci.draw_arc(f * Vector2(.5, .5), f.x.x * .4, 0, TAU, 32, color, maxf(1.0, f.x.x * .09), true) + _line(ci, f, [Vector2(.5, .24), Vector2(.5, .5), Vector2(.68, .62)], color, .08) + "heart": + _fill(ci, _pts(f, _heart()), color) + "tempo": + _fill(ci, _pts(f, [Vector2(.6, .04), Vector2(.22, .56), Vector2(.46, .56), Vector2(.38, .96), Vector2(.78, .42), Vector2(.54, .42), Vector2(.66, .04)]), color) + "range": + var c: Vector2 = f * Vector2(.5, .5) + ci.draw_arc(c, f.x.x * .38, 0, TAU, 32, color, maxf(1.0, f.x.x * .08), true) + ci.draw_arc(c, f.x.x * .18, 0, TAU, 24, color, maxf(1.0, f.x.x * .08), true) + ci.draw_circle(c, f.x.x * .06, color) + for tick: Array in [[Vector2(.5, .02), Vector2(.5, .16)], [Vector2(.5, .84), Vector2(.5, .98)], [Vector2(.02, .5), Vector2(.16, .5)], [Vector2(.84, .5), Vector2(.98, .5)]]: + _line(ci, f, tick, color, .07) + "move": + _line(ci, f, [Vector2(.18, .2), Vector2(.46, .5), Vector2(.18, .8)], color, .11) + _line(ci, f, [Vector2(.52, .2), Vector2(.8, .5), Vector2(.52, .8)], color, .11) + "star": + _fill(ci, _pts(f, _star(5, .47, .2)), color) + "lock": + _fill(ci, _pts(f, [Vector2(.22, .46), Vector2(.78, .46), Vector2(.78, .9), Vector2(.22, .9)]), color) + ci.draw_arc(f * Vector2(.5, .46), f.x.x * .19, PI, TAU, 16, color, maxf(1.0, f.x.x * .09), true) + ci.draw_circle(f * Vector2(.5, .64), f.x.x * .06, dark) + "up": + _fill(ci, _pts(f, [Vector2(.5, .08), Vector2(.86, .48), Vector2(.62, .48), Vector2(.62, .9), Vector2(.38, .9), Vector2(.38, .48), Vector2(.14, .48)]), color) + "lane_top", "lane_mid", "lane_bot": + var box: Array = [Vector2(.12, .12), Vector2(.88, .12), Vector2(.88, .88), Vector2(.12, .88), Vector2(.12, .12)] + _line(ci, f, box, Color(color, color.a * .3), .05) + var path: Array = [Vector2(.2, .8), Vector2(.2, .2), Vector2(.8, .2)] if glyph == "lane_top" else ([Vector2(.2, .8), Vector2(.8, .2)] if glyph == "lane_mid" else [Vector2(.2, .8), Vector2(.8, .8), Vector2(.8, .2)]) + _line(ci, f, path, color, .12) + "grotto": + var arch: Array = [Vector2(.08, .9)] + for index: int in range(13): + var angle: float = PI + PI * float(index) / 12.0 + arch.append(Vector2(.5, .62) + Vector2(cos(angle) * .42, sin(angle) * .5)) + arch.append(Vector2(.92, .9)) + _fill(ci, _pts(f, arch), color) + var mouth: Array = [Vector2(.3, .9)] + for index: int in range(9): + var angle: float = PI + PI * float(index) / 8.0 + mouth.append(Vector2(.5, .72) + Vector2(cos(angle) * .2, sin(angle) * .26)) + mouth.append(Vector2(.7, .9)) + _fill(ci, _pts(f, mouth), dark) + "pause": + _fill(ci, _pts(f, [Vector2(.24, .16), Vector2(.42, .16), Vector2(.42, .84), Vector2(.24, .84)]), color) + _fill(ci, _pts(f, [Vector2(.58, .16), Vector2(.76, .16), Vector2(.76, .84), Vector2(.58, .84)]), color) + "play": + _fill(ci, _pts(f, [Vector2(.26, .14), Vector2(.84, .5), Vector2(.26, .86)]), color) + "menu": + for y: float in [.24, .5, .76]: + _line(ci, f, [Vector2(.16, y), Vector2(.84, y)], color, .1) + "basaltschild": + _fill(ci, _pts(f, _circle(6, Vector2(.5, .5), .44, -PI * .5)), color) + var inner: Array = _circle(6, Vector2(.5, .5), .27, -PI * .5) + inner.append(inner[0]) + _line(ci, f, inner, dark, .07) + "glutklinge": + var blade: Array = [Vector2(.5, .06), Vector2(.57, .15), Vector2(.57, .66), Vector2(.43, .66), Vector2(.43, .15)] + _fill(ci, _pts(f, _rot(blade, PI * .25)), color) + _fill(ci, _pts(f, _rot([Vector2(.3, .66), Vector2(.7, .66), Vector2(.7, .73), Vector2(.3, .73)], PI * .25)), color) + _fill(ci, _pts(f, [Vector2(.2, .5), Vector2(.3, .3), Vector2(.36, .42), Vector2(.42, .22), Vector2(.5, .44), Vector2(.4, .6)]), dark if accent.a > 0.0 else Color("ffb070")) + "rankenmantel": + _fill(ci, _pts(f, [Vector2(.5, .1), Vector2(.66, .18), Vector2(.8, .9), Vector2(.5, .8), Vector2(.2, .9), Vector2(.34, .18)]), color) + _line(ci, f, [Vector2(.5, .22), Vector2(.42, .4), Vector2(.56, .56), Vector2(.46, .74)], dark, .05) + "funkenfokus": + ci.draw_arc(f * Vector2(.5, .5), f.x.x * .42, 0, TAU, 32, color, maxf(1.0, f.x.x * .08), true) + _fill(ci, _pts(f, [Vector2(.56, .16), Vector2(.3, .54), Vector2(.47, .54), Vector2(.42, .84), Vector2(.7, .44), Vector2(.53, .44), Vector2(.62, .16)]), color) + "quellamulett": + ci.draw_arc(f * Vector2(.5, .2), f.x.x * .14, PI * .9, PI * 2.1, 12, color, maxf(1.0, f.x.x * .06), true) + _fill(ci, _pts(f, _drop()), color) + ci.draw_circle(f * Vector2(.44, .66), f.x.x * .06, Color(1, 1, 1, .45 * color.a)) + "kristallsplitter": + for spec: Array in [[Vector2(.5, .5), 0.0, .44], [Vector2(.3, .62), -.55, .3], [Vector2(.7, .62), .55, .3]]: + var center: Vector2 = spec[0] + var length: float = spec[2] + var shard: Array = [center + Vector2(0, -length), center + Vector2(.08, 0), center + Vector2(0, length * .45), center + Vector2(-.08, 0)] + _fill(ci, _pts(f, _rot(shard, spec[1], center)), color) + _: + ci.draw_circle(f * Vector2(.5, .5), f.x.x * .3, color) diff --git a/scripts/luneraria/ui/theme/lune_glyphs.gd.uid b/scripts/luneraria/ui/theme/lune_glyphs.gd.uid new file mode 100644 index 0000000..5826101 --- /dev/null +++ b/scripts/luneraria/ui/theme/lune_glyphs.gd.uid @@ -0,0 +1 @@ +uid://xgj2g8l0h1en diff --git a/scripts/luneraria/ui/theme/lune_ui_theme.gd b/scripts/luneraria/ui/theme/lune_ui_theme.gd new file mode 100644 index 0000000..6d9d9e9 --- /dev/null +++ b/scripts/luneraria/ui/theme/lune_ui_theme.gd @@ -0,0 +1,282 @@ +class_name LuneUiTheme +extends RefCounted +## Champions of Luneraria UI theme. It extends the Saumwacht menu theme (fonts, buttons, +## focus rings, ForgedStyle plates) with the in-match look: dark blue-anthracite plates, +## bronze and gold only on what the player can act on, quiet team accents. HUD components +## pick their look through theme_type_variation; shared styles are built once and cached, +## never copied per control. + +const SLATE_TOP := Color(.075, .1, .135, .9) +const SLATE_BOTTOM := Color(.05, .068, .094, .93) +const EDGE := Color("2b3848") +const EDGE_LIGHT := Color("455870") +const TEXT := Color("ebe5d8") +const BRIGHT := Color("f6eedc") +const MUTED := Color("9aa4b0") +const FAINT := Color("6a7480") +const GOLD := Color("f2c96b") +const BRONZE := SaumwachtTheme.BRONZE +const BRONZE_LIGHT := SaumwachtTheme.BRONZE_LIGHT +const BRONZE_DEEP := SaumwachtTheme.BRONZE_DEEP +## Team accents: Blau (cool, crescent moon), Rot (warm, sun disc), neutral grotto (violet). +const TEAM: Array[Color] = [Color("6cb4ff"), Color("ff7b5e"), Color("b78cff")] +const TEAM_DARK: Array[Color] = [Color("1c3f6a"), Color("722a1f"), Color("45305f")] +const TEAM_NAMES: Array[String] = ["Blau", "Rot", "Grotte"] +const TEAM_EMBLEMS: Array[String] = ["Mondsichel", "Sonnenscheibe", "Grotte"] +## Threat levels RUHIG, UMKÄMPFT, UNTER DRUCK, KRITISCH. +const THREAT: Array[Color] = [Color("7cc9a6"), Color("e5c35e"), Color("ff9448"), Color("ff584a")] +const CHAMPION := Color("e2a15e") +const HERO := Color("f2c96b") +const GOOD := Color("8fdcb4") +const WARN := Color("f0c060") +const DANGER := Color("ff6a55") +const INFO := Color("a9c6e8") +## Lower bound for any HUD text: 14 logical px stay above 10 px at 1280×720. +const MIN_FONT: int = 14 + +static var _theme: Theme +static var _styles: Dictionary = {} + +static func shared() -> Theme: + if _theme == null: + _theme = build() + return _theme + +static func font_ui(weight: float = 560.0, spacing: int = 0) -> FontVariation: + return SaumwachtTheme.font(SaumwachtTheme.CANTARELL, weight, spacing) + +static func font_display(weight: float = 700.0, spacing: int = 1) -> FontVariation: + return SaumwachtTheme.font(SaumwachtTheme.CINZEL, weight, spacing) + +static func plate(top: Color, bottom: Color, edge: Color, edge_width: float = 1.0, cut: float = 6.0, horizontal: float = 12.0, vertical: float = 10.0) -> StyleBox: + var style: StyleBox = SaumwachtTheme.plate(top, bottom, edge, edge_width, cut) + (style as ForgedStyle).inner = Color(1, 1, 1, .035) + (style as ForgedStyle).inner_inset = 3.0 + return SaumwachtTheme.margins(style, horizontal, vertical) + +static func build() -> Theme: + var theme: Theme = SaumwachtTheme.build() + var ui := font_ui() + var ui_bold := font_ui(720.0) + var caps := font_ui(700.0, 2) + var display := font_display(700.0, 1) + _label(theme, "HudText", ui, 15, TEXT) + _label(theme, "HudMuted", ui, 14, MUTED) + _label(theme, "HudCaps", caps, 14, BRONZE_LIGHT) + _label(theme, "HudBadge", font_ui(720.0, 1), 14, TEXT) + _label(theme, "HudLane", font_display(700.0, 1), 16, Color("f4e6c4")) + _label(theme, "HudValue", ui_bold, 17, BRIGHT) + _label(theme, "HudGold", ui_bold, 24, GOLD) + _label(theme, "HudName", display, 18, Color("f4e6c4")) + _label(theme, "HudTitle", display, 24, Color("f4e6c4")) + _label(theme, "HudWave", font_display(700.0, 3), 30, Color("f4e6c4")) + for type: String in ["HudWave", "HudTitle", "HudGold"]: + theme.set_color("font_outline_color", type, Color(0, 0, 0, .55)) + theme.set_constant("outline_size", type, 4) + + # Panels: one quiet plate for all regions, a heavier one for modals and the tooltip. + theme.set_stylebox("panel", "HudPanel", plate(SLATE_TOP, SLATE_BOTTOM, EDGE, 1.0, 6.0, 12, 10)) + theme.set_type_variation("HudPanel", "PanelContainer") + theme.set_stylebox("panel", "HudPanelTight", plate(SLATE_TOP, SLATE_BOTTOM, EDGE, 1.0, 5.0, 8, 6)) + theme.set_type_variation("HudPanelTight", "PanelContainer") + var inset := StyleBoxFlat.new() + inset.bg_color = Color(0, 0, 0, .28) + inset.set_corner_radius_all(4) + SaumwachtTheme.margins(inset, 8, 6) + theme.set_stylebox("panel", "HudInset", inset) + theme.set_type_variation("HudInset", "PanelContainer") + var modal := plate(Color(.07, .09, .125, .98), Color(.045, .06, .085, .98), Color("5c4a33"), 1.5, 10.0, 22, 18) + (modal as ForgedStyle).shadow = Color(0, 0, 0, .55) + (modal as ForgedStyle).shadow_size = 16.0 + theme.set_stylebox("panel", "HudModal", modal) + theme.set_type_variation("HudModal", "PanelContainer") + var banner := plate(Color(.16, .075, .05, .94), Color(.11, .05, .035, .95), Color("a0523a"), 1.5, 6.0, 16, 8) + theme.set_stylebox("panel", "HudBannerDanger", banner) + theme.set_type_variation("HudBannerDanger", "PanelContainer") + var banner_own := plate(Color(.07, .1, .16, .94), Color(.05, .07, .11, .95), Color("5f86b8"), 1.5, 6.0, 16, 8) + theme.set_stylebox("panel", "HudBannerOwn", banner_own) + theme.set_type_variation("HudBannerOwn", "PanelContainer") + var tooltip := plate(Color(.06, .08, .11, .98), Color(.045, .06, .085, .98), Color("6b5a44"), 1.0, 6.0, 12, 9) + (tooltip as ForgedStyle).shadow = Color(0, 0, 0, .5) + (tooltip as ForgedStyle).shadow_size = 8.0 + theme.set_stylebox("panel", "HudTooltip", tooltip) + theme.set_type_variation("HudTooltip", "PanelContainer") + + # Buttons. Secondary HUD buttons are compact; the gold PrimaryButton stays for Bereit. + var focus: StyleBox = SaumwachtTheme.focus_ring(6.0) + _button(theme, "HudButton", + plate(Color("1b232d"), Color("131920"), Color("4a5a6c"), 1.0, 5.0, 12, 7), + plate(Color("26303c"), Color("1a212a"), BRONZE_LIGHT, 1.0, 5.0, 12, 7), + plate(Color("10151b"), Color("171d25"), BRONZE, 1.5, 5.0, 12, 7), + plate(Color("141a20"), Color("10141a"), Color("2c333c"), 1.0, 5.0, 12, 7), focus) + theme.set_font_size("font_size", "HudButton", 15) + theme.set_color("font_disabled_color", "HudButton", FAINT) + _button(theme, "HudIconButton", + plate(Color("1b232d"), Color("131920"), Color("3c4a5a"), 1.0, 5.0, 9, 6), + plate(Color("26303c"), Color("1a212a"), BRONZE_LIGHT, 1.0, 5.0, 9, 6), + plate(Color("10151b"), Color("171d25"), BRONZE, 1.5, 5.0, 9, 6), + plate(Color("141a20"), Color("10141a"), Color("2c333c"), 1.0, 5.0, 9, 6), focus) + theme.set_font_size("font_size", "HudIconButton", 15) + var primary_small: StyleBox = SaumwachtTheme.margins(SaumwachtTheme.plate(Color("e8c47e"), Color("b98a4a"), Color("fff0cc"), 1.5, 7.0), 16, 8) + var primary_small_hover: StyleBox = SaumwachtTheme.margins(SaumwachtTheme.plate(Color("f6d898"), Color("cc9c56"), Color.WHITE, 1.5, 7.0), 16, 8) + var primary_small_pressed: StyleBox = SaumwachtTheme.margins(SaumwachtTheme.plate(Color("b98a4a"), Color("d5aa66"), Color("fff0cc"), 2.0, 7.0), 16, 8) + var primary_small_disabled: StyleBox = SaumwachtTheme.margins(SaumwachtTheme.plate(Color("4a4234"), Color("3a3328"), Color("6a5c46"), 1.0, 7.0), 16, 8) + _button(theme, "HudPrimary", primary_small, primary_small_hover, primary_small_pressed, primary_small_disabled, focus) + theme.set_font("font", "HudPrimary", font_ui(760.0, 1)) + theme.set_font_size("font_size", "HudPrimary", 16) + for state: String in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]: + theme.set_color(state, "HudPrimary", SaumwachtTheme.INK) + theme.set_color("font_disabled_color", "HudPrimary", Color("9c8f78")) + + # Cards (units, heroes, items) and the champion variant with its own copper language. + var card_focus: StyleBox = SaumwachtTheme.focus_ring(7.0) + _button(theme, "HudCard", + plate(Color("19222c"), Color("10161d"), Color("34424f"), 1.0, 7.0, 6, 6), + plate(Color("212c38"), Color("141b23"), BRONZE, 1.5, 7.0, 6, 6), + plate(Color("2a2820"), Color("17171a"), BRONZE_LIGHT, 2.0, 7.0, 6, 6), + plate(Color("12171d"), Color("0d1116"), Color("252c34"), 1.0, 7.0, 6, 6), card_focus) + theme.set_stylebox("hover_pressed", "HudCard", theme.get_stylebox("pressed", "HudCard")) + _button(theme, "HudChampionCard", + plate(Color("261c14"), Color("15100c"), Color("6e4a2c"), 1.0, 7.0, 6, 6), + plate(Color("302216"), Color("1b140e"), CHAMPION, 1.5, 7.0, 6, 6), + plate(Color("3a2818"), Color("1e150e"), Color("ffc88c"), 2.0, 7.0, 6, 6), + plate(Color("16110d"), Color("0f0c0a"), Color("3a2c20"), 1.0, 7.0, 6, 6), card_focus) + _button(theme, "HudChip", + plate(Color("1a232d"), Color("11171e"), Color("33404d"), 1.0, 4.0, 2, 2), + plate(Color("222d39"), Color("151c24"), BRONZE, 1.0, 4.0, 2, 2), + plate(Color("2c2a22"), Color("17171a"), BRONZE_LIGHT, 2.0, 4.0, 2, 2), + plate(Color("12171d"), Color("0d1116"), Color("252c34"), 1.0, 4.0, 2, 2), SaumwachtTheme.focus_ring(4.0)) + theme.set_stylebox("hover_pressed", "HudChip", theme.get_stylebox("pressed", "HudChip")) + _button(theme, "HudRow", + plate(Color(.09, .12, .16, .55), Color(.06, .08, .11, .55), Color("2a3645"), 1.0, 5.0, 10, 7), + plate(Color(.13, .17, .22, .75), Color(.08, .11, .15, .75), BRONZE, 1.0, 5.0, 10, 7), + plate(Color(.16, .15, .12, .8), Color(.09, .09, .1, .8), BRONZE_LIGHT, 1.5, 5.0, 10, 7), + plate(Color(.09, .12, .16, .55), Color(.06, .08, .11, .55), Color("2a3645"), 1.0, 5.0, 10, 7), focus) + + # Toggles for lane assignment and target lanes: selected = bronze fill, never colour alone. + var toggle_on := plate(Color("3a3222"), Color("262117"), BRONZE_LIGHT, 1.5, 5.0, 10, 6) + (toggle_on as ForgedStyle).inner = Color(BRONZE_LIGHT, .35) + _button(theme, "HudToggle", + plate(Color("171e27"), Color("11161d"), Color("3a4756"), 1.0, 5.0, 10, 6), + plate(Color("212a35"), Color("171d25"), BRONZE, 1.0, 5.0, 10, 6), + toggle_on, + plate(Color("11151a"), Color("0e1115"), Color("242a31"), 1.0, 5.0, 10, 6), focus) + theme.set_stylebox("hover_pressed", "HudToggle", toggle_on) + theme.set_font("font", "HudToggle", font_ui(700.0, 1)) + theme.set_font_size("font_size", "HudToggle", 14) + theme.set_color("font_color", "HudToggle", Color("cfd5dc")) + theme.set_color("font_pressed_color", "HudToggle", Color("fff4dc")) + theme.set_color("font_hover_pressed_color", "HudToggle", Color("fff4dc")) + theme.set_color("font_disabled_color", "HudToggle", Color("59616b")) + + # Tabs: flat with a bronze underline for the open tab. + var tab := StyleBoxFlat.new() + tab.bg_color = Color(0, 0, 0, 0) + tab.border_color = Color(0, 0, 0, 0) + tab.border_width_bottom = 2 + SaumwachtTheme.margins(tab, 12, 6) + var tab_hover := tab.duplicate() as StyleBoxFlat + tab_hover.bg_color = Color(1, 1, 1, .04) + tab_hover.border_color = Color(BRONZE, .5) + var tab_on := tab.duplicate() as StyleBoxFlat + tab_on.bg_color = Color(BRONZE_LIGHT, .08) + tab_on.border_color = BRONZE_LIGHT + _button(theme, "HudTab", tab, tab_hover, tab_on, tab, SaumwachtTheme.focus_ring(3.0)) + theme.set_stylebox("hover_pressed", "HudTab", tab_on) + theme.set_font("font", "HudTab", caps) + theme.set_font_size("font_size", "HudTab", 14) + theme.set_color("font_color", "HudTab", MUTED) + theme.set_color("font_hover_color", "HudTab", TEXT) + theme.set_color("font_pressed_color", "HudTab", BRONZE_LIGHT) + theme.set_color("font_hover_pressed_color", "HudTab", BRONZE_LIGHT) + theme.set_color("font_disabled_color", "HudTab", FAINT) + return theme + +static func _label(theme: Theme, type: String, face: Font, size: int, color: Color) -> void: + theme.set_type_variation(type, "Label") + theme.set_font("font", type, face) + theme.set_font_size("font_size", type, maxi(MIN_FONT, size)) + theme.set_color("font_color", type, color) + +static func _button(theme: Theme, type: String, normal: StyleBox, hover: StyleBox, pressed: StyleBox, disabled: StyleBox, focus: StyleBox) -> void: + theme.set_type_variation(type, "Button") + theme.set_stylebox("normal", type, normal) + theme.set_stylebox("hover", type, hover) + theme.set_stylebox("pressed", type, pressed) + theme.set_stylebox("disabled", type, disabled) + theme.set_stylebox("focus", type, focus) + theme.set_color("font_color", type, TEXT) + theme.set_color("font_hover_color", type, Color.WHITE) + theme.set_color("font_focus_color", type, Color.WHITE) + +## Rounded pill for StatusBadge; one cached style per colour and fill mode. +static func badge_style(color: Color, filled: bool) -> StyleBox: + var key := "badge:%s:%s" % [color.to_html(), filled] + if not _styles.has(key): + var style := StyleBoxFlat.new() + style.bg_color = Color(color, .92) if filled else Color(color.darkened(.75), .85) + style.border_color = color if not filled else color.lightened(.2) + style.set_border_width_all(1) + style.set_corner_radius_all(4) + style.content_margin_left = 7 + style.content_margin_right = 7 + style.content_margin_top = 1 + style.content_margin_bottom = 1 + style.anti_aliasing = true + _styles[key] = style + return _styles[key] + +## Small recessed square for item slots in cards (shared, 4 px padding). +static func slot_style(active: bool = false) -> StyleBox: + var key := "slot:%s" % active + if not _styles.has(key): + var style := StyleBoxFlat.new() + style.bg_color = Color(0, 0, 0, .35) + style.border_color = BRONZE if active else Color("33404d") + style.set_border_width_all(1) + style.set_corner_radius_all(3) + style.set_content_margin_all(4) + _styles[key] = style + return _styles[key] + +## Label factory used by every component so sizes and variations stay consistent. +static func label(text_value: String, variation: String = "HudText", parent: Node = null, size: int = 0) -> Label: + var result := Label.new() + result.text = text_value + result.theme_type_variation = variation + result.mouse_filter = Control.MOUSE_FILTER_IGNORE + if size > 0: + result.add_theme_font_size_override("font_size", maxi(MIN_FONT, size)) + if parent != null: + parent.add_child(result) + return result + +static func button(text_value: String, variation: String = "HudButton", parent: Node = null) -> Button: + var result := Button.new() + result.text = text_value + result.theme_type_variation = variation + result.focus_mode = Control.FOCUS_ALL + result.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + if parent != null: + parent.add_child(result) + return result + +static func team_color(team: int) -> Color: + return TEAM[clampi(team, 0, 2)] + +static func team_name(team: int) -> String: + return TEAM_NAMES[clampi(team, 0, 2)] + +## Whole numbers with German thousands separators (12.000). +static func number(value: float) -> String: + var whole: int = int(round(value)) + var negative: bool = whole < 0 + var digits: String = str(absi(whole)) + var result: String = "" + while digits.length() > 3: + result = "." + digits.right(3) + result + digits = digits.left(digits.length() - 3) + return ("-" if negative else "") + digits + result + +static func decimal(value: float, places: int = 1) -> String: + return (("%." + str(places) + "f") % value).replace(".", ",") diff --git a/scripts/luneraria/ui/theme/lune_ui_theme.gd.uid b/scripts/luneraria/ui/theme/lune_ui_theme.gd.uid new file mode 100644 index 0000000..dd1967e --- /dev/null +++ b/scripts/luneraria/ui/theme/lune_ui_theme.gd.uid @@ -0,0 +1 @@ +uid://bo56ywqbf68rv diff --git a/scripts/luneraria/view/lune_unit_view.gd b/scripts/luneraria/view/lune_unit_view.gd index 66bd53d..3438fa2 100644 --- a/scripts/luneraria/view/lune_unit_view.gd +++ b/scripts/luneraria/view/lune_unit_view.gd @@ -19,6 +19,8 @@ var skeletal: bool = false var _fill: MeshInstance3D var _bars: Node3D var _ring: MeshInstance3D +## G3: wide second ring in the owner's shape under a deployed boss (ownership marker). +var _owner_ring: MeshInstance3D var _last_attack: int = 0 var _last_ability: int = 0 var _attack_flash: float = 0.0 @@ -69,15 +71,33 @@ func bind(source: LuneUnit) -> void: var torus := TorusMesh.new() torus.inner_radius = .42 * scale_factor torus.outer_radius = .52 * scale_factor - torus.rings = 20 + # G3: team shape as well as colour – round ring for Blau, diamond for Rot, hexagon for the + # neutral grotto boss (the HUD and minimap use the same shapes). + torus.rings = [20, 4, 6][clampi(unit.team, 0, 2)] torus.ring_segments = 4 _ring.mesh = torus + if unit.team == 1: + _ring.rotation.y = PI * .25 var ring_color: Color = HERO_GOLD if unit.kind == "hero" else TEAM_COLORS[unit.team] _ring.material_override = _flat(ring_color, 2.2 if unit.kind in ["hero", "boss", "champion"] else (1.4 if unit.kind != "monster" else .6)) _ring.scale = Vector3(1, .25, 1) _ring.position.y = .05 _ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(_ring) + if unit.kind == "boss" and unit.team <= 1: + _owner_ring = MeshInstance3D.new() + var outer := TorusMesh.new() + outer.inner_radius = 1.0 * scale_factor + outer.outer_radius = 1.12 * scale_factor + outer.rings = 20 if unit.team == 0 else 4 + outer.ring_segments = 4 + _owner_ring.mesh = outer + _owner_ring.material_override = _flat(TEAM_COLORS[unit.team], 3.0) + _owner_ring.scale = Vector3(1, .2, 1) + _owner_ring.position.y = .07 + _owner_ring.rotation.y = PI * .25 if unit.team == 1 else 0.0 + _owner_ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(_owner_ring) _bars = Node3D.new() _bars.position.y = _height + .25 add_child(_bars) @@ -121,6 +141,9 @@ func bind(source: LuneUnit) -> void: var title: String = str(unit.def.get("display_name", "")) if unit.kind == "hero": title = "%s · Stufe %d" % [title, int(unit.def.get("level", 1))] + elif unit.kind == "boss" and unit.team <= 1: + # G3: a deployed boss says whom it fights for; its crystals alone are close to Blau's armies. + title = "%s · für %s" % [title.to_upper(), "Blau" if unit.team == 0 else "Rot"] tag.text = title add_child(tag) _tag = tag @@ -158,6 +181,8 @@ func sync(alpha: float, delta: float) -> void: _fill.scale.x = maxf(.001, fraction) _bars.visible = unit.alive and fraction < .999 _ring.visible = unit.alive + if _owner_ring != null: + _owner_ring.visible = unit.alive if _tag != null: # Fallen skeletal figures stay in their death pose, but without their name. _tag.visible = unit.alive diff --git a/tools/luneraria/g3_playtest.gd b/tools/luneraria/g3_playtest.gd new file mode 100644 index 0000000..a7c213d --- /dev/null +++ b/tools/luneraria/g3_playtest.gd @@ -0,0 +1,140 @@ +extends Node +## Interactive playtest driver for the real application (menu, match, HUD). It never touches the +## match state: every action is a real input event (mouse click, wheel, key) at window pixel +## coordinates, exactly as a player would give it. A tester writes command batches to files and +## gets a screenshot back after each batch: +## /tmp/lune_play/cmd-.txt lines: click X Y · rclick X Y · move X Y · wheel X Y up|down · +## key NAME · wait SECONDS · shot NAME +## /tmp/lune_play/out-.png the screen after the batch; /tmp/lune_play/ok- marks it done +## Hover (tooltips) follows the pointer of the batch: the control under it gets mouse_entered, +## because synthetic pointer moves do not move the OS cursor. +## A log of all commands with round and phase goes to docs/luneraria/logs/g3/playtest-log.txt. +## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/g3_playtest.tscn +const DIR := "/tmp/lune_play/" +const LOG := "res://docs/luneraria/logs/g3/playtest-log.txt" + +var main: Node +var next: int = 1 +var busy: bool = false +var hovered: Control +var started: int = 0 +var log_lines: PackedStringArray = [] + +func _ready() -> void: + DirAccess.make_dir_recursive_absolute(DIR) + for file: String in DirAccess.get_files_at(DIR): + DirAccess.remove_absolute(DIR + file) + main = load("res://scenes/luneraria.tscn").instantiate() + add_child(main) + started = Time.get_ticks_msec() + print("PLAYTEST_READY") + +func _process(_delta: float) -> void: + if busy: + return + var path: String = DIR + "cmd-%d.txt" % next + if not FileAccess.file_exists(path): + return + busy = true + _run_batch(path, next) + +func _run_batch(path: String, index: int) -> void: + await get_tree().process_frame + var text: String = FileAccess.get_file_as_string(path) + for raw: String in text.split("\n"): + var line: String = raw.strip_edges() + if line.is_empty() or line.begins_with("#"): + continue + _log(index, line) + await _command(line.split(" ", false)) + await get_tree().create_timer(.35).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png(DIR + "out-%d.png" % index) + var marker := FileAccess.open(DIR + "ok-%d" % index, FileAccess.WRITE) + marker.store_string(_status()) + marker.close() + next = index + 1 + busy = false + +func _status() -> String: + var game: Node = main.get("game") if main != null else null + if game == null or not is_instance_valid(game) or game.get("match_state") == null: + return "menu" + var state: LuneMatch = game.match_state + return "round=%d phase=%s" % [state.round_number, ["PLANUNG", "KAMPF", "ENDE"][state.phase]] + +func _log(index: int, line: String) -> void: + var entry: String = "%7.1f s #%d [%s] %s" % [(Time.get_ticks_msec() - started) / 1000.0, index, _status(), line] + log_lines.append(entry) + var file := FileAccess.open(LOG, FileAccess.WRITE) + if file != null: + file.store_string("\n".join(log_lines) + "\n") + file.close() + +func _command(parts: PackedStringArray) -> void: + match parts[0]: + "click", "rclick": + var at := Vector2(float(parts[1]), float(parts[2])) + await _move(at) + for pressed: bool in [true, false]: + _button(at, MOUSE_BUTTON_LEFT if parts[0] == "click" else MOUSE_BUTTON_RIGHT, pressed) + await get_tree().process_frame + "move": + await _move(Vector2(float(parts[1]), float(parts[2]))) + "wheel": + var at := Vector2(float(parts[1]), float(parts[2])) + await _move(at) + for pressed: bool in [true, false]: + _button(at, MOUSE_BUTTON_WHEEL_UP if parts.size() < 4 or parts[3] == "up" else MOUSE_BUTTON_WHEEL_DOWN, pressed) + await get_tree().process_frame + "key": + var code: Key = OS.find_keycode_from_string(parts[1]) + for pressed: bool in [true, false]: + var event := InputEventKey.new() + event.keycode = code + event.physical_keycode = code + event.pressed = pressed + Input.parse_input_event(event) + await get_tree().process_frame + "wait": + await get_tree().create_timer(float(parts[1])).timeout + "shot": + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png(DIR + parts[1] + ".png") + "quit": + get_tree().quit() + +## Window pixels → the same path as a real mouse (Input), so GUI and game see one event. +func _button(at: Vector2, button: int, pressed: bool) -> void: + var event := InputEventMouseButton.new() + event.button_index = button + event.position = at + event.global_position = at + event.pressed = pressed + Input.parse_input_event(event) + +func _move(at: Vector2) -> void: + var motion := InputEventMouseMotion.new() + motion.position = at + motion.global_position = at + Input.parse_input_event(motion) + await get_tree().process_frame + _hover(at) + +## The topmost visible control under the pointer that takes mouse input gets mouse_entered. +func _hover(at: Vector2) -> void: + var canvas: Vector2 = get_viewport().get_screen_transform().affine_inverse() * at + var found: Control = null + for node: Node in get_tree().root.find_children("*", "Control", true, false): + var control := node as Control + if control.mouse_filter == Control.MOUSE_FILTER_IGNORE or not control.is_visible_in_tree(): + continue + if control.get_global_rect().has_point(canvas): + found = control + if found == hovered: + return + if hovered != null and is_instance_valid(hovered): + hovered.mouse_exited.emit() + hovered = found + if found != null: + found.mouse_entered.emit() diff --git a/tools/luneraria/g3_playtest.gd.uid b/tools/luneraria/g3_playtest.gd.uid new file mode 100644 index 0000000..29c438b --- /dev/null +++ b/tools/luneraria/g3_playtest.gd.uid @@ -0,0 +1 @@ +uid://beb5uiwuvaq0p diff --git a/tools/luneraria/g3_playtest.tscn b/tools/luneraria/g3_playtest.tscn new file mode 100644 index 0000000..8fb8e85 --- /dev/null +++ b/tools/luneraria/g3_playtest.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/luneraria/g3_playtest.gd" id="1"] + +[node name="G3Playtest" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/g3_views.gd b/tools/luneraria/g3_views.gd new file mode 100644 index 0000000..56fde24 --- /dev/null +++ b/tools/luneraria/g3_views.gd @@ -0,0 +1,223 @@ +extends Node +## G3 HUD captures from the real game at the running window size (1920x1080, 2560x1440, +## 1280x720, ultrawide). Every view is a real LuneGame with the real HUD; fixtures only set +## public match state before or between phases (gold, round, building HP, boss claim), exactly +## like the G1/G2 views. HUD interactions go through the HUD's own public methods when the +## build has them, so the same script photographs the G2 HUD ("vorher") and the G3 HUD. +## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/g3_views.tscn -- --out=res://docs/luneraria/screenshots/g3/nachher-1920x1080 [--only=01,05] +const Game = preload("res://scripts/luneraria/lune_game.gd") + +var out: String = "res://docs/luneraria/screenshots/g3/nachher/" +var only: Array[String] = [] +var shots: Array[String] = [] +var game: Node3D + +func _ready() -> void: + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--out="): + out = arg.substr(6) + if not out.ends_with("/"): + out += "/" + elif arg.begins_with("--only="): + only.assign(arg.substr(7).split(",")) + call_deferred("run") + +func wanted(label: String) -> bool: + return only.is_empty() or only.any(func(prefix: String) -> bool: return label.begins_with(prefix)) + +func shot(label: String, frames: int = 12) -> void: + for i: int in frames: + await get_tree().process_frame + # G3: wait until the portrait cache has rendered every requested model. + var cache: Node = get_tree().root.get_node_or_null("LunePortraits") + var guard: int = 0 + while cache != null and cache.has_method("pending") and cache.pending() > 0 and guard < 600: + await get_tree().process_frame + guard += 1 + for i: int in 3: + await get_tree().process_frame + await RenderingServer.frame_post_draw + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out)) + get_viewport().get_texture().get_image().save_png(out + label + ".png") + shots.append(label) + print("G3_VIEW ", label, " ", get_viewport().get_visible_rect().size) + +func wait(seconds: float) -> void: + await get_tree().create_timer(seconds).timeout + +func fresh(seed: int = 4, slot: int = 1) -> LuneMatch: + if game != null and is_instance_valid(game): + game.queue_free() + await get_tree().process_frame + await get_tree().process_frame + game = Game.new() + add_child(game) + game.start({"seed": seed, "human_slot": slot, "bunds": ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"]}) + await get_tree().process_frame + return game.match_state + +## Calls a HUD method if this build has it (G3); returns false otherwise (G2). +func hud_call(method: String, args: Array = []) -> bool: + if game.hud.has_method(method): + game.hud.callv(method, args) + return true + return false + +func command(command_value: Dictionary) -> Dictionary: + return game.match_state.submit(game.human_slot, command_value) + +## Runs the combat clock quickly (more fixed steps per frame, rules unchanged), then holds it. +func advance_combat(seconds: float, hold: bool = true) -> void: + var state: LuneMatch = game.match_state + game.paused = false + while state.phase == LuneMatch.Phase.COMBAT and state.combat.elapsed < seconds: + for i: int in range(20): + if state.phase != LuneMatch.Phase.COMBAT or state.combat.elapsed >= seconds: + break + state.step_combat() + await get_tree().process_frame + game.paused = hold + +func armies(state: LuneMatch, slots: Array, round_number: int) -> void: + state.round_number = round_number + for slot: int in range(6): + state.players[slot].draft.roster.clear() + state.players[slot].draft.gold = 100000 if slots.has(slot) else 0 + if slots.has(slot): + LuneBot.plan(state, slot, "ausgewogen") + for entry: Dictionary in state.players[slot].draft.roster: + if entry.lane == LuneMatch.GROTTO: + entry.lane = state.players[slot].lane + var hero: Dictionary = state.players[slot].draft.hero + if not hero.is_empty() and hero.lane == LuneMatch.GROTTO: + hero.lane = state.players[slot].lane + # The fixture gold only buys the armies; the human keeps an ordinary amount afterwards. + state.players[game.human_slot].draft.gold = 185 + +func run() -> void: + Soundscape.enabled = false + await planning_views() + await boss_views() + await combat_views() + await ancient_views() + if game != null and is_instance_valid(game): + game.queue_free() + await get_tree().process_frame + print("G3_VIEWS_DONE ", shots.size()) + get_tree().quit() + +# ---------------------------------------------------------------- planning +func planning_views() -> void: + if not (wanted("01") or wanted("02") or wanted("03") or wanted("04") or wanted("05")): + return + var state: LuneMatch = await fresh() + # A normal planning round: round 3, starter army bought, some gold left. + state.round_number = 3 + state.players[game.human_slot].draft.gold = 420 + game._starter() + await wait(.4) + if wanted("01"): + await shot("01-planung") + # Hero hired and shown. + state.players[game.human_slot].draft.gold = 900 + command({"type": "hero_buy", "hero_id": "held_ringborke"}) + game._rebuild_preview() + if not hud_call("open_hero"): + hud_call("show_page", [1]) + await wait(.4) + if wanted("02"): + await shot("02-held-ausgewaehlt") + # Item choice with one item equipped. + var hero_uid: String = str(state.players[game.human_slot].draft.hero.get("uid", "")) + command({"type": "item_buy", "item_id": "glutklinge", "uid": hero_uid}) + hud_call("open_items") + await wait(.3) + if wanted("03"): + await shot("03-itemauswahl") + hud_call("close_modal") + # A normal unit selected. + var roster: Array = state.players[game.human_slot].draft.roster + if not roster.is_empty(): + if not hud_call("select_unit", [str(roster[0].uid)]): + hud_call("show_page", [0]) + await wait(.3) + if wanted("04"): + await shot("04-einheit-ausgewaehlt") + hud_call("clear_selection") + # Attack champions: one booked for the next round. + command({"type": "champion_book", "champion_id": "ch_belagerung", "lane": "mid"}) + if not hud_call("open_champions"): + hud_call("show_page", [2]) + await wait(.3) + if wanted("05"): + await shot("05-angriffschampion") + +# ---------------------------------------------------------------- boss +func boss_views() -> void: + if not (wanted("06") or wanted("07")): + return + var state: LuneMatch = await fresh() + state.round_number = 10 + state.boss.next_available_round = 10 + game.focus_grotto(true) + await wait(1.0) + if wanted("06"): + await shot("06-boss-verfuegbar") + # Own team conquered the boss in round 11: deployment in round 12 on Mid, planning of 12. + state.round_number = 12 + state.boss.claim = {"team": 0, "won_round": 11, "deploy_round": 12, "chooser_slot": 1, "lane": "mid"} + state.boss.next_available_round = 15 + game.focus_lane("mid", true) + await wait(1.0) + if wanted("07"): + await shot("07-boss-angekuendigt") + +# ---------------------------------------------------------------- combat +func combat_views() -> void: + if not (wanted("08") or wanted("09") or wanted("10") or wanted("13") or wanted("14")): + return + var state: LuneMatch = await fresh() + armies(state, [0, 1, 2, 3, 4, 5], 14) + state.building_hp["rot_top_outer"] = 0.0 + state.building_hp["blau_bot_outer"] = 0.0 + game._on_ready() + await get_tree().process_frame + await advance_combat(22.0) + for spec: Array in [["08-kampf-top", "top"], ["09-kampf-mid", "mid"], ["10-kampf-bottom", "bot"]]: + if wanted(str(spec[0]).left(2)): + game.focus_lane(spec[1], true) + await wait(.5) + await shot(spec[0], 4) + if wanted("13"): + var ruin: Vector2 = state.map.building_def("blau_bot_outer").position + game.camera.look_at_point(Vector3(ruin.x, 0, ruin.y), 22.0, true) + await wait(.5) + await shot("13-turm-zerstoert", 4) + if wanted("14"): + game.camera.look_at_point(Vector3(0, 0, 0), 46.0, true) + await wait(.5) + await shot("14-grosse-kampfansicht", 4) + +# ---------------------------------------------------------------- Ancients +func ancient_views() -> void: + if not (wanted("11") or wanted("12")): + return + var state: LuneMatch = await fresh() + state.round_number = 21 + for id: String in ["blau_mid_outer", "blau_mid_inner", "blau_base_1", "blau_base_2"]: + state.building_hp[id] = 0.0 + state.building_hp["blau_ancient"] = float(state.map.building_def("blau_ancient").max_hp) * .22 + game.focus_base(true) + await wait(1.2) + if wanted("11"): + await shot("11-eigener-ancient-kritisch") + state = await fresh() + state.round_number = 137 + for id: String in ["rot_top_outer", "rot_top_inner", "rot_base_1", "rot_base_2"]: + state.building_hp[id] = 0.0 + state.building_hp["rot_ancient"] = float(state.map.building_def("rot_ancient").max_hp) * .18 + var red: Vector2 = state.map.core_position(1) + game.camera.look_at_point(Vector3(red.x, 0, red.y), 26.0, true) + await wait(1.2) + if wanted("12"): + await shot("12-gegnerischer-ancient-kritisch") diff --git a/tools/luneraria/g3_views.gd.uid b/tools/luneraria/g3_views.gd.uid new file mode 100644 index 0000000..0df989e --- /dev/null +++ b/tools/luneraria/g3_views.gd.uid @@ -0,0 +1 @@ +uid://cufrp1tjr4ohw diff --git a/tools/luneraria/g3_views.tscn b/tools/luneraria/g3_views.tscn new file mode 100644 index 0000000..a22ce84 --- /dev/null +++ b/tools/luneraria/g3_views.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/luneraria/g3_views.gd" id="1"] + +[node name="G3Views" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/verify_g3.gd b/tools/luneraria/verify_g3.gd new file mode 100644 index 0000000..7762ac1 --- /dev/null +++ b/tools/luneraria/verify_g3.gd @@ -0,0 +1,778 @@ +extends Node +## G3 acceptance: the in-match HUD in the real game (LuneGame with the real HUD, real input). +## Part A layout: every region inside the window and free of overlaps at 1280×720, 1920×1080, +## 2560×1440, ultrawide 2560×1080 and the minimum window, at several UI scales +## Part B match status: Ancients, towers, lanes, unlimited waves (two and three digits), gold +## Part C hero, items, normal units (no item slots), lane orders with the grotto warning +## Part D attack champions (temporary), the boss grotto in every state, target lane, banner +## Part E Ready lock, bots, the network guest (privacy, same public information), disconnect +## Part F input: minimap click, camera hotkeys, lane clicks, no click passthrough, tooltips +## Part G events, critical alerts and "Bewegung reduzieren" +## Part H update rates and the portrait cache +## Run (window needed): godot --path . --resolution 1920x1080 res://tools/luneraria/verify_g3.tscn +const OUT := "res://docs/luneraria/logs/g3/verify-g3.json" +const Game = preload("res://scripts/luneraria/lune_game.gd") +const Hud = preload("res://scripts/luneraria/ui/lune_hud.gd") +const BUNDS: Array = ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"] + +var checks: int = 0 +var failures: Array[String] = [] +var facts: Dictionary = {} +var parts: Dictionary = {} +var _part: String = "" +var game: Node3D + +func check(ok: bool, label: String) -> void: + checks += 1 + parts[_part] = int(parts.get(_part, 0)) + 1 + if not ok: + failures.append("[%s] %s" % [_part, label]) + printerr("G3_FAIL: [", _part, "] ", label) + +func part(name: String) -> void: + _part = name + print("G3_PART ", name) + +func _ready() -> void: + call_deferred("run") + +func run() -> void: + Soundscape.enabled = false + var started: int = Time.get_ticks_msec() + var scale_before: float = Preferences.ui_scale + var motion_before: bool = Preferences.reduced_motion + Preferences.reduced_motion = false + part("A layout") + await _layout() + part("B status") + await _status() + part("C hero and units") + await _hero_and_units() + part("D champions and boss") + await _champions_and_boss() + part("E ready, bots, network") + await _ready_bots_network() + part("F input") + await _input_checks() + part("G events and motion") + await _events_and_motion() + part("H rates and portraits") + await _rates() + await _free_game() + Preferences.ui_scale = scale_before + Preferences.reduced_motion = motion_before + facts.seconds = (Time.get_ticks_msec() - started) / 1000.0 + facts.checks_per_part = parts + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT.get_base_dir())) + var file := FileAccess.open(OUT, FileAccess.WRITE) + file.store_string(JSON.stringify({"checks": checks, "failures": failures, "facts": facts}, "\t")) + file.close() + print("G3_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) + +# ---------------------------------------------------------------- helpers +func frames(count: int = 1) -> void: + for i: int in count: + await get_tree().process_frame + +func wait(seconds: float) -> void: + await get_tree().create_timer(seconds).timeout + +func _free_game() -> void: + if game != null and is_instance_valid(game): + game.queue_free() + await frames(2) + game = null + +func fresh(seed: int = 4, slot: int = 1) -> LuneMatch: + await _free_game() + game = Game.new() + add_child(game) + game.start({"seed": seed, "human_slot": slot, "bunds": BUNDS}) + await frames(3) + return game.match_state + +func hud() -> Control: + return game.hud + +func sync() -> void: + game.hud.mark_dirty() + await frames(2) + +func state() -> LuneMatch: + return game.match_state + +func command(value: Dictionary) -> Dictionary: + var result: Dictionary = state().submit(game.human_slot, value) + game._rebuild_preview() + return result + +func point_event(at: Vector2, button: int, pressed: bool) -> InputEventMouseButton: + var event := InputEventMouseButton.new() + event.button_index = button + event.position = at + event.global_position = at + event.pressed = pressed + return event + +func move_to(at: Vector2) -> void: + var motion := InputEventMouseMotion.new() + motion.position = at + motion.global_position = at + get_viewport().push_input(motion, true) + await get_tree().process_frame + +func wheel_at(at: Vector2) -> void: + await move_to(at) + for pressed: bool in [true, false]: + get_viewport().push_input(point_event(at, MOUSE_BUTTON_WHEEL_UP, pressed), true) + await frames(2) + +func click_at(at: Vector2, button: int = MOUSE_BUTTON_LEFT) -> void: + await move_to(at) + for pressed: bool in [true, false]: + get_viewport().push_input(point_event(at, button, pressed), true) + await get_tree().process_frame + +func click(control: Control) -> void: + if control == null or not control.is_visible_in_tree(): + check(false, "control to click exists and is visible (%s)" % (control.name if control != null else "null")) + return + await click_at(control.get_global_rect().get_center()) + +func key(code: int) -> void: + for pressed: bool in [true, false]: + var event := InputEventKey.new() + event.keycode = code + event.physical_keycode = code + event.pressed = pressed + get_viewport().push_input(event) + await get_tree().process_frame + +func texts(root: Node) -> String: + var result: PackedStringArray = [] + for node: Node in root.find_children("*", "", true, false): + if node is Label and (node as Label).is_visible_in_tree(): + result.append((node as Label).text) + elif node is Button and (node as Button).is_visible_in_tree(): + result.append((node as Button).text) + return "\n".join(result) + +func rect_inside(inner: Rect2, outer: Rect2) -> bool: + return inner.position.x >= outer.position.x - 1.0 and inner.position.y >= outer.position.y - 1.0 \ + and inner.end.x <= outer.end.x + 1.0 and inner.end.y <= outer.end.y + 1.0 + +func armies(round_number: int) -> void: + var match_state: LuneMatch = state() + match_state.round_number = round_number + for slot: int in range(6): + match_state.players[slot].draft.roster.clear() + match_state.players[slot].draft.gold = 100000 + LuneBot.plan(match_state, slot, "ausgewogen") + for entry: Dictionary in match_state.players[slot].draft.roster: + if entry.lane == LuneMatch.GROTTO: + entry.lane = match_state.players[slot].lane + var hero: Dictionary = match_state.players[slot].draft.hero + if not hero.is_empty() and hero.lane == LuneMatch.GROTTO: + hero.lane = match_state.players[slot].lane + +func advance(seconds: float) -> void: + var match_state: LuneMatch = state() + game.paused = false + while match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat.elapsed < seconds: + for i: int in range(20): + if match_state.phase != LuneMatch.Phase.COMBAT or match_state.combat.elapsed >= seconds: + break + match_state.step_combat() + await get_tree().process_frame + game.paused = true + +# ---------------------------------------------------------------- A layout +func _layout() -> void: + var match_state: LuneMatch = await fresh() + match_state.round_number = 3 + match_state.players[game.human_slot].draft.gold = 2000 + game._starter() + command({"type": "hero_buy", "hero_id": "held_quellgeflecht"}) + for index: int in range(6): + command({"type": "buy", "type_id": match_state.base_ids(match_state.players[game.human_slot].bund)[index % 5]}) + var roster: Array = match_state.players[game.human_slot].draft.roster + hud().select_unit(str(roster[0].uid)) + var sizes: Array[Vector2i] = [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440), Vector2i(2560, 1080), Vector2i(1152, 720)] + var measured: Array = [] + for window: Vector2i in sizes: + get_window().size = window + for wanted: float in [.9, 1.0, 1.3]: + Preferences.ui_scale = wanted + Preferences.changed.emit() + await frames(6) + var label: String = "%dx%d @%d%%" % [window.x, window.y, int(wanted * 100.0)] + var area: Rect2 = get_viewport().get_visible_rect() + measured.append([label, get_window().size, area.size, hud().ui_scale]) + check(hud().ui_scale <= wanted + .001, "%s: UI scale never exceeds the chosen size (%.2f)" % [label, hud().ui_scale]) + if wanted <= 1.0: + check(is_equal_approx(hud().ui_scale, wanted) or area.size.x / wanted < Hud.MIN_AREA.x or area.size.y / wanted < Hud.MIN_AREA.y, "%s: the chosen UI scale applies when it fits" % label) + _regions_ok(label + " planning, unit selected") + _labels_fit(label) + # Combat layout of the same window at 100 %. + get_window().size = Vector2i(1920, 1080) + Preferences.ui_scale = 1.0 + Preferences.changed.emit() + await frames(6) + hud().open_hero() + await frames(4) + var modal: Rect2 = hud()._modal.panel.get_global_rect() + check(rect_inside(modal, get_viewport().get_visible_rect()), "hero window inside the screen") + var covered: float = modal.get_area() / get_viewport().get_visible_rect().get_area() + check(covered < .75, "hero window leaves the field partly visible (%.0f %% covered)" % (covered * 100.0)) + hud().close_modal() + hud().open_champions() + await frames(4) + _regions_ok("1920x1080 champions tab") + hud().open_troops() + hud().clear_selection() + game._on_ready() + await frames(4) + for window: Vector2i in [Vector2i(1280, 720), Vector2i(2560, 1080), Vector2i(1920, 1080)]: + get_window().size = window + await frames(6) + _regions_ok("%dx%d combat" % [window.x, window.y]) + facts.layouts = measured + +func _regions_ok(label: String) -> void: + var area: Rect2 = get_viewport().get_visible_rect() + var regions: Dictionary = hud().regions() + for key: String in regions: + check(rect_inside(regions[key], area), "%s: %s inside the window (%s in %s)" % [label, key, regions[key], area]) + var keys: Array = regions.keys() + for i: int in range(keys.size()): + for j: int in range(i + 1, keys.size()): + var a: Rect2 = regions[keys[i]] + var b: Rect2 = regions[keys[j]] + check(not a.grow(-1.0).intersects(b.grow(-1.0)), "%s: %s and %s do not overlap" % [label, keys[i], keys[j]]) + # The right column never takes more than 30 % of the width. + if regions.has("right"): + check((regions.right as Rect2).size.x <= area.size.x * .3, "%s: context column at most 30 %% wide" % label) + +## Unclipped labels must fit their box (clipped ones end with an ellipsis by design). +func _labels_fit(label: String) -> void: + var bad: PackedStringArray = [] + for node: Node in hud().find_children("*", "Label", true, false): + var text_label := node as Label + if not text_label.is_visible_in_tree() or text_label.clip_text or text_label.autowrap_mode != TextServer.AUTOWRAP_OFF or text_label.text.is_empty(): + continue + if text_label.get_minimum_size().x > text_label.size.x + 1.5: + bad.append(text_label.text) + check(bad.is_empty(), "%s: no label wider than its box %s" % [label, str(bad)]) + +# ---------------------------------------------------------------- B status +func _status() -> void: + var match_state: LuneMatch = await fresh() + get_window().size = Vector2i(1920, 1080) + await frames(4) + check(hud().lanes.size() == 3, "three lane indicators") + for lane: String in LuneMap.LANES: + var indicator: Control = hud().lanes[lane] + check(indicator.is_visible_in_tree() and rect_inside(indicator.get_global_rect(), get_viewport().get_visible_rect()), "lane %s visible" % lane) + match_state.building_hp["blau_ancient"] = 5000.0 + match_state.building_hp["rot_ancient"] = 2600.0 + match_state.building_hp["rot_top_outer"] = 0.0 + match_state.building_hp["blau_mid_outer"] = 3000.0 + await sync() + var own: Dictionary = hud()._ancients[0] + var enemy: Dictionary = hud()._ancients[1] + check((own.value as Label).text == "5.000 / 12.000", "own Ancient HP shown (%s)" % (own.value as Label).text) + check(absf((own.bar as LuneHpBar).fraction - 5000.0 / 12000.0) < .002, "own Ancient bar follows HP") + check((enemy.value as Label).text == "2.600 / 12.000" and (enemy.badge as LuneStatusBadge).text == "KRITISCH", "enemy Ancient below 30 %% is KRITISCH (%s)" % (enemy.badge as LuneStatusBadge).text) + check((own.caption as Label).text.contains("BLAU") and (enemy.caption as Label).text.contains("ROT"), "Ancients are named by team, not only coloured") + var top: LuneLaneIndicator = hud().lanes["top"] + check(not top.towers[2].alive and top.towers[3].alive, "TOP: enemy Grenzwacht shown destroyed, Bastion alive") + var mid: LuneLaneIndicator = hud().lanes["mid"] + check(mid.towers[1].alive and absf(mid.towers[1].fraction - .5) < .011, "MID: own Grenzwacht at 50 %% (%.2f)" % mid.towers[1].fraction) + check(mid.level == LuneLaneThreat.Level.CONTESTED or mid.level == LuneLaneThreat.Level.PRESSURE, "MID threat rises with a damaged tower (%s)" % LuneLaneThreat.NAMES[mid.level]) + check(top.towers[0].team == 0 and top.towers[2].team == 1, "tower chain runs from the own base to the enemy base") + # Threat is deterministic and public: the same for every viewer of the same team. + var a: Dictionary = LuneLaneThreat.evaluate(match_state, 0, "mid") + var b: Dictionary = LuneLaneThreat.evaluate(match_state, 0, "mid") + check(a.level == b.level and a.reasons == b.reasons, "lane threat is deterministic") + match_state.building_hp["blau_bot_outer"] = 0.0 + match_state.building_hp["blau_bot_inner"] = 0.0 + await sync() + check((hud().lanes["bot"] as LuneLaneIndicator).level == LuneLaneThreat.Level.CRITICAL, "both lane towers down: BOTTOM is KRITISCH") + for round_number: int in [14, 21, 137]: + match_state.round_number = round_number + await sync() + var wave: Label = hud()._wave + check(wave.text == "WELLE %d" % round_number, "wave %d shown as \"%s\"" % [round_number, wave.text]) + check(not wave.text.contains("/"), "no wave limit in the display (%s)" % wave.text) + check(wave.get_minimum_size().x <= wave.size.x + 1.0 and rect_inside(hud()._top.get_global_rect(), get_viewport().get_visible_rect()), "wave %d fits the top bar" % round_number) + match_state.round_number = 3 + match_state.players[game.human_slot].draft.gold = 420 + await sync() + check(hud()._gold.amount.text == "420", "own gold shown (%s)" % hud()._gold.amount.text) + check(hud()._gold.detail.text.contains(str(match_state.balance.round_income(3))), "base income of the next settlement shown") + var ids: Array[String] = match_state.base_ids(match_state.players[game.human_slot].bund) + await click(hud()._shop_buttons[ids[0]]) + await sync() + var cost: int = int(DataCatalog.get_unit(ids[0]).cost) + check(match_state.players[game.human_slot].draft.gold == 420 - cost and hud()._gold.amount.text == str(420 - cost), "a card click buys and the gold display follows") + for slot: int in range(6): + if slot != game.human_slot: + match_state.players[slot].draft.gold = 7777 + slot + match_state.players[slot].gold = 6666 + slot + await sync() + var shown: String = texts(hud()) + var leaked: bool = false + for slot: int in range(6): + if slot != game.human_slot: + for value: int in [7777 + slot, 6666 + slot]: + leaked = leaked or shown.contains(str(value)) or shown.contains(LuneUiTheme.number(value)) + check(not leaked, "no other player's gold appears anywhere in the HUD") + +# ---------------------------------------------------------------- C hero and units +func _hero_and_units() -> void: + var match_state: LuneMatch = await fresh() + match_state.round_number = 5 + match_state.players[game.human_slot].draft.gold = 3000 + await sync() + await click(hud().hero_card) + await frames(3) + check(hud().modal_open() and hud().hero_detail.recruit_buttons.size() == 3, "hero card opens the recruiting window with three heroes") + await click(hud().hero_detail.recruit_buttons["held_ringborke"]) + await sync() + var hero: Dictionary = match_state.players[game.human_slot].draft.hero + check(not hero.is_empty() and hero.hero_id == "held_ringborke", "hero recruited through the window") + hero.xp = 500 + await sync() + check(hud().hero_card._level.text == "STUFE 4", "hero level from experience (%s)" % hud().hero_card._level.text) + check(absf(hud().hero_card._xp.fraction - (500.0 - 480.0) / (720.0 - 480.0)) < .01, "experience progress to the next level") + check(hud().hero_card._slots.size() == 3, "hero card shows three item slots") + var definition: Dictionary = LuneContent.hero_definition(hero) + hud().open_hero() + await frames(3) + var window_text: String = texts(hud()._modal) + check(window_text.contains(LuneUiTheme.number(definition.max_hp)) and window_text.contains(LuneUiTheme.decimal(definition.damage)), "hero window shows the real level-scaled HP and damage") + check(window_text.contains(str(DataCatalog.get_unit("w04").ability_description)), "hero window shows the real ability text") + hud().open_items() + await frames(3) + var cards: Dictionary = hud().hero_detail.item_cards + check(cards.size() == LuneContent.ITEMS.size(), "all items listed") + check((cards["kristallsplitter"] as LuneItemSlot).state == LuneItemSlot.State.AVAILABLE, "level-4 hero may buy Kristallsplitter") + await click(cards["glutklinge"]) + await sync() + check((hero.items as Array).has("glutklinge"), "item bought through its card") + cards = hud().hero_detail.item_cards + check((cards["glutklinge"] as LuneItemSlot).state == LuneItemSlot.State.UNIQUE, "a carried item shows GETRAGEN (unique)") + check(hud().hero_card._slots[0].glyph == "glutklinge", "the bottom hero card shows the equipped item") + command({"type": "item_buy", "item_id": "basaltschild", "uid": hero.uid}) + command({"type": "item_buy", "item_id": "rankenmantel", "uid": hero.uid}) + await sync() + check((hud().hero_detail.item_cards["funkenfokus"] as LuneItemSlot).state == LuneItemSlot.State.FULL, "fourth item: slots full") + match_state.players[game.human_slot].draft.gold = 10 + command({"type": "item_sell", "item_id": "rankenmantel"}) + match_state.players[game.human_slot].draft.gold = 10 + await sync() + check((hud().hero_detail.item_cards["funkenfokus"] as LuneItemSlot).state == LuneItemSlot.State.UNAFFORDABLE, "missing gold: item marked unaffordable") + var young: Dictionary = hero.duplicate(true) + hero.xp = 0 + (hero.items as Array).erase("basaltschild") + match_state.players[game.human_slot].draft.gold = 3000 + await sync() + check((hud().hero_detail.item_cards["kristallsplitter"] as LuneItemSlot).state == LuneItemSlot.State.INCOMPATIBLE, "level-1 hero: Kristallsplitter needs level 3") + hero.xp = young.xp + hud().close_modal() + # A normal unit: details without item slots, lane order with the grotto warning. + var ids: Array[String] = match_state.base_ids(match_state.players[game.human_slot].bund) + command({"type": "buy", "type_id": ids[1]}) + await sync() + var entry: Dictionary = match_state.players[game.human_slot].draft.roster[0] + var chip: Button = hud()._chips.get(str(entry.uid)) + await click(chip) + await sync() + var detail: LuneUnitDetail = hud().unit_detail + check(detail.visible and detail.uid == str(entry.uid), "army chip selects the unit") + var slots: int = 0 + for node: Node in detail.find_children("*", "", true, false): + slots += int(node is LuneItemSlot) + check(slots == 0 and not texts(detail).contains("ITEMS"), "normal units have no item slots") + var data: Dictionary = DataCatalog.get_unit(ids[1]) + var detail_text: String = texts(detail) + check(detail_text.contains(LuneUiTheme.number(data.max_hp)) and detail_text.contains(LuneUiTheme.decimal(data.damage)) and detail_text.contains(str(data.ability_description)), "unit details show real HP, damage and ability") + check(detail_text.contains(str(DataCatalog.get_unit(str(data.upgrade_id)).display_name)) and detail_text.contains(str(int(DataCatalog.get_unit(str(data.upgrade_id)).cost))), "upgrade target and cost shown") + await click(detail._lanes["top"]) + await sync() + check(str(match_state.players[game.human_slot].draft.roster[0].lane) == "top", "lane order TOP through the detail panel") + match_state.boss.next_available_round = 1 + await sync() + await click(detail._lanes[LuneMatch.GROTTO]) + await sync() + check(str(match_state.players[game.human_slot].draft.roster[0].lane) == LuneMatch.GROTTO and detail._warning.visible and detail._warning.text == LuneUnitDetail.GROTTO_WARNING, "grotto order shows the missing-lane warning") + match_state.boss.next_available_round = 9 + command({"type": "assign", "uid": entry.uid, "lane": "mid"}) + await sync() + check((detail._lanes[LuneMatch.GROTTO] as Button).disabled and not (detail._lanes["top"] as Button).disabled, "a closed grotto is not offered; lanes stay active") + var gold: int = match_state.players[game.human_slot].draft.gold + await click(detail._upgrade) + await sync() + check(str(match_state.players[game.human_slot].draft.roster[0].type_id) == str(data.upgrade_id) and match_state.players[game.human_slot].draft.gold == gold - int(DataCatalog.get_unit(str(data.upgrade_id)).cost), "upgrade through the detail panel") + # Esc clears the selection. + await key(KEY_ESCAPE) + await sync() + check(not hud().unit_detail.visible, "Esc clears the unit selection") + +# ---------------------------------------------------------------- D champions and boss +func _champions_and_boss() -> void: + var match_state: LuneMatch = await fresh() + match_state.round_number = 14 + match_state.players[game.human_slot].draft.gold = 1000 + await sync() + hud().open_champions() + await sync() + var panel: LuneChampionPanel = hud().champion_panel + check(panel.visible and panel.cards.size() == 3, "champion tab with three champions") + for id: String in panel.cards: + var card: LuneUnitCard = panel.cards[id] + check(card.champion and card.theme_type_variation == "HudChampionCard" and card._tag.visible, "%s card uses the temporary champion look (1 WELLE)" % id) + await click(panel._lane_buttons["bot"]) + await click(panel.cards["ch_belagerung"]) + await sync() + var bookings: Array = match_state.players[game.human_slot].draft.new_bookings + check(bookings.size() == 1 and str(bookings[0].lane) == "bot", "champion booked on the chosen target lane") + check(texts(panel).contains("Einsatz in Welle 15 – BOTTOM"), "booking says \"Einsatz in Welle 15 – BOTTOM\"") + check(match_state.players[game.human_slot].draft.roster.is_empty(), "a champion never joins the permanent army") + hud().open_troops() + # Boss states. + var states: Array = [] + match_state.boss = {"next_available_round": 1, "claim": {}} + await sync() + states.append(hud().boss_card._badge.text) + check(hud().boss_card._badge.text == "BOSS VERFÜGBAR", "boss available (%s)" % hud().boss_card._badge.text) + match_state.boss = {"next_available_round": 16, "claim": {}} + await sync() + states.append(hud().boss_card._badge.text) + check(hud().boss_card._badge.text == "COOLDOWN – 2 RUNDEN", "boss cooldown with rounds left (%s)" % hud().boss_card._badge.text) + match_state.boss = {"next_available_round": 17, "claim": {"team": 0, "won_round": 13, "deploy_round": 14, "chooser_slot": game.human_slot, "lane": "mid"}} + await sync() + states.append(hud().boss_card._badge.text) + check(hud().boss_card._badge.text == "EINSATZ NÄCHSTE WELLE", "boss deployment announced (%s)" % hud().boss_card._badge.text) + var banner: String = texts(hud()._banners) + check(banner.contains("Welle 14") and banner.contains("MID") and banner.contains(LuneUiTheme.number(LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * 14)), "banner names wave, lane and the bounty (%s)" % banner) + check(hud().boss_card._choice.visible, "the boss chooser gets the lane choice") + await click(hud().boss_card._lane_buttons["bot"]) + await sync() + check(str(match_state.players[game.human_slot].draft.boss_lane) == "bot" and texts(hud()._banners).contains("BOTTOM"), "chooser moves the boss to BOTTOM; the banner follows") + # Other seats see the public lane only. + var other := LuneHudModel.new(match_state, 0) + check(str(other.boss().lane) == "mid", "the chooser's uncommitted lane change stays private") + match_state.boss.claim.team = 1 + match_state.boss.claim.chooser_slot = 3 + await sync() + check(texts(hud()._banners).contains("Kopfgeld für euch"), "enemy boss: banner warns and names the defenders' bounty") + # Combat: the deployed boss attacks; then the grotto fight is contested. + game._on_ready() + await frames(3) + await sync() + states.append(hud().boss_card._badge.text) + check(hud().boss_card._badge.text.begins_with("BOSS GREIFT AN"), "deployed boss: BOSS GREIFT AN (%s)" % hud().boss_card._badge.text) + var boss_unit: LuneUnit = null + for unit: LuneUnit in match_state.combat.units: + if unit.kind == "boss": + boss_unit = unit + check(boss_unit != null and boss_unit.team == 1, "the boss fights for its owner") + var view: Node3D = game._views.get(boss_unit.id) if boss_unit != null else null + check(view != null and view._owner_ring != null and view._tag.text.contains("für Rot"), "the boss carries an ownership ring and label in the world") + match_state = await fresh() + match_state.round_number = 6 + match_state.boss = {"next_available_round": 1, "claim": {}} + for slot: int in [1, 4]: + match_state.players[slot].draft.gold = 2400 + LuneBot.plan(match_state, slot, "ausgewogen") + for entry: Dictionary in match_state.players[slot].draft.roster: + entry.lane = LuneMatch.GROTTO + game._on_ready() + await frames(3) + await sync() + states.append(hud().boss_card._badge.text) + check(hud().boss_card._badge.text == "BOSS UMKÄMPFT", "expeditions in the grotto: BOSS UMKÄMPFT (%s)" % hud().boss_card._badge.text) + facts.boss_badges = states + +# ---------------------------------------------------------------- E ready, bots, network +func _ready_bots_network() -> void: + var match_state: LuneMatch = await fresh() + match_state.round_number = 2 + match_state.players[game.human_slot].draft.gold = 600 + game._starter() + await sync() + var members: Array = hud().model.members(hud().model.team()) + var bots: int = 0 + for member: Dictionary in members: + bots += int(member.status == "bot") + check(bots == 2, "two bots in the own team") + var badges: String = texts(hud()._team) + check(badges.contains("BOT") and badges.contains("PLANT"), "team panel marks bots and the planning human") + # Ready lock: planning continues (another human would still plan) but the own plan is binding. + match_state.set_ready(game.human_slot) + await sync() + var roster_before: int = match_state.players[game.human_slot].draft.roster.size() + check(hud()._ready_button.disabled and hud()._ready_check.visible, "Bereit shows as committed") + var all_locked: bool = true + for id: String in hud()._shop_buttons: + all_locked = all_locked and (hud()._shop_buttons[id] as Button).disabled + check(all_locked, "after Ready every unit card is locked") + check(hud()._undo.disabled and hud()._starter.disabled, "undo and start army locked after Ready") + await key(KEY_1) + await sync() + check(match_state.players[game.human_slot].draft.roster.size() == roster_before, "key 1 buys nothing after Ready") + hud().select_unit(str(match_state.players[game.human_slot].draft.roster[0].uid)) + await sync() + check((hud().unit_detail._lanes["top"] as Button).disabled and hud().unit_detail._upgrade.disabled, "unit orders locked after Ready") + check(hud()._phase_badge.text == "PLANUNG · BEREIT", "phase badge says BEREIT (%s)" % hud()._phase_badge.text) + hud().clear_selection() + # Network: a host with a seated guest, and the guest's own view built from rpc_state. + var host: LuneSession = LuneSession.new() + host.name = "HostSession" + add_child(host) + host.host_offline(0, {"seed": 7, "human_slot": 0, "bunds": BUNDS}) + host.seat_peer(1, 80, "Dora") + var hm: LuneMatch = host.match_state + hm.players[0].draft.gold = 4321 + hm.players[0].gold = 4321 + hm.building_hp["rot_mid_outer"] = 2000.0 + hm.boss = {"next_available_round": 9, "claim": {}} + var ids: Array[String] = hm.base_ids("wurzelbund") + host.host_command(80, 1, {"type": "buy", "type_id": ids[0]}) + await _free_game() + var guest: LuneSession = LuneSession.new() + guest.name = "GuestSession" + add_child(guest) + guest.role = LuneSession.Role.CLIENT + guest.local_slot = 1 + guest.match_state = LuneMatch.new() + guest.match_state.setup({"seed": 7, "human_slot": 1, "bunds": BUNDS}) + guest.lobby = host.lobby.duplicate(true) + guest.rpc_state(host.state_for(1)) + game = Game.new() + game.session = guest + add_child(game) + game.start({"seed": 7, "human_slot": 1}) + await frames(3) + await sync() + var guest_text: String = texts(hud()) + check(hud()._gold.amount.text == str(guest.match_state.gold_of(1)), "guest sees its own gold (%s)" % hud()._gold.amount.text) + check(not guest_text.contains("4321") and not guest_text.contains("4.321"), "guest never sees the host's gold") + check(hud().model.networked() and hud().model.is_guest(), "guest HUD knows it is a network guest") + var host_model := LuneHudModel.new(hm, 0, host) + var guest_digest: Dictionary = hud().public_digest() + var host_digest: Dictionary = host_model.public_digest() + check(JSON.stringify(guest_digest) == JSON.stringify(host_digest), "host and guest show the same public information") + if JSON.stringify(guest_digest) != JSON.stringify(host_digest): + for key: String in host_digest: + if JSON.stringify(host_digest[key]) != JSON.stringify(guest_digest.get(key)): + printerr("G3_DIGEST_DIFF ", key, " host=", host_digest[key], " guest=", guest_digest.get(key)) + var host_row: Dictionary = {} + for member: Dictionary in hud().model.members(0): + if int(member.slot) == 0: + host_row = member + check(host_row.status == "human" and not host_row.has("gold"), "guest sees the host as a human seat, without gold") + # The host's HUD: guest ready, then the guest disconnects and a bot takes the seat. + var host_hud: Control = Hud.new() + host_hud.match_state = hm + host_hud.human_slot = 0 + host_hud.session = host + add_child(host_hud) + await frames(3) + host_hud.mark_dirty() + await frames(2) + var dora: Dictionary = host_hud.model.members(0)[1] + check(dora.name == "Dora" and dora.status == "human" and not dora.ready, "host sees Dora planning") + host.host_ready(1) + host_hud.mark_dirty() + await frames(2) + check(texts(host_hud._team).contains("BEREIT"), "host sees Dora ready") + host._on_peer_disconnected(80) + host_hud.mark_dirty() + await frames(2) + check(host_hud.model.members(0)[1].status == "offline" and texts(host_hud._team).contains("BOT ÜBERNIMMT"), "after a disconnect the seat shows BOT ÜBERNIMMT") + host_hud.queue_free() + await _free_game() + guest.queue_free() + host.close() + host.queue_free() + await frames(2) + +# ---------------------------------------------------------------- F input +func _input_checks() -> void: + var match_state: LuneMatch = await fresh() + get_window().size = Vector2i(1920, 1080) + match_state.round_number = 3 + match_state.players[game.human_slot].draft.gold = 400 + game._starter() + await sync() + await wait(.3) + var own_team: int = int(match_state.players[game.human_slot].team) + # Camera hotkeys (existing keys, now listed in the options). + var keys: Dictionary = {KEY_F2: "top", KEY_F3: "mid", KEY_F4: "bot"} + for code: int in keys: + await key(code) + var lane: String = keys[code] + var goal: Vector3 = game.camera._goal_focus + var expected: Vector2 = match_state.map.lane_point(own_team, lane, match_state.map.lanes[lane].length * .2) + check(Vector2(goal.x, goal.z).distance_to(expected) < .6, "hotkey for %s moves the camera there" % lane) + await key(KEY_G) + check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(match_state.map.grotto_center()) < .6, "G: camera to the boss grotto") + await key(KEY_B) + check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(match_state.map.core_position(own_team)) < .6, "B: camera to the own Ancient") + var settings_text: String = "" + for entry: Array in LuneSettings.KEYS: + settings_text += str(entry) + check(settings_text.contains("F2 · F3 · F4") and settings_text.contains("Bossgrotte") and settings_text.contains("eigener Ancient"), "camera hotkeys listed in the options") + # Lane click. + await click(hud().lanes["bot"]) + var bot: Vector2 = match_state.map.lane_point(own_team, "bot", match_state.map.lanes["bot"].length * .2) + check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(bot) < .6, "click on the BOTTOM lane row moves the camera") + # Minimap click and drag: camera only, never an order. + var gold: int = match_state.gold_of(game.human_slot) + var roster: String = str(match_state.players[game.human_slot].draft.roster) + var map: Control = hud().minimap + var local := Vector2(map.size.x * .72, map.size.y * .3) + var target: Vector3 = map.to_world(local) + await click_at(map.get_global_transform() * local) + check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(Vector2(target.x, target.z)) < 1.0, "minimap click moves the camera to that point") + check(match_state.gold_of(game.human_slot) == gold and str(match_state.players[game.human_slot].draft.roster) == roster, "minimap click buys and orders nothing") + # No click passthrough: the wheel over a panel does not zoom the world, over the field it does. + await frames(10) + var zoom: float = game.camera._goal_zoom + var bar_point: Vector2 = hud()._bottom.get_global_rect().get_center() + await wheel_at(bar_point) + check(is_equal_approx(game.camera._goal_zoom, zoom), "mouse wheel over the bottom bar does not reach the world") + var lane_point: Vector2 = (hud()._left.get_child(0) as Control).get_global_rect().get_center() + await wheel_at(lane_point) + check(is_equal_approx(game.camera._goal_zoom, zoom), "mouse wheel over the lane panel does not reach the world") + await wheel_at(hud().unit_detail.get_global_rect().get_center() if hud().unit_detail.visible else hud().boss_card.get_global_rect().get_center()) + check(is_equal_approx(game.camera._goal_zoom, zoom), "mouse wheel over the context column does not reach the world") + var field: Vector2 = get_viewport().get_visible_rect().size * Vector2(.5, .52) + await wheel_at(field) + check(game.camera._goal_zoom < zoom, "mouse wheel over the free field zooms") + var entry: Dictionary = match_state.players[game.human_slot].draft.roster[0] + hud().select_unit(str(entry.uid)) + await sync() + await click_at((hud()._team.get_parent() as Control).get_global_rect().get_center()) + await sync() + check(hud().selected_uid == str(entry.uid), "a click on a HUD panel does not reach the world (selection kept)") + # World click picks an own unit. + hud().clear_selection() + game.focus_lane(str(match_state.players[game.human_slot].lane), true) + await frames(6) + var picked: LuneUnit = null + for view: Node3D in game._views.values(): + if view.unit != null and view.unit.kind == "troop" and view.unit.owner_slot == game.human_slot: + picked = view.unit + await click_at(game.camera.unproject_position(view.aim_point())) + break + await sync() + check(picked != null and hud().selected_uid == picked.persistent_uid, "clicking an own figure in the field selects it") + # Tooltip: appears near the hovered card, stays on screen, disappears when leaving. + # Synthetic pointer: hover signals come from the OS cursor, so the test raises mouse_entered + # itself after moving the (synthetic) pointer onto the card. + var card: Control = hud()._shop_buttons.values()[0] + await move_to(card.get_global_rect().get_center()) + card.mouse_entered.emit() + await wait(.5) + var tip: LuneTooltip = hud().tooltip + check(tip.visible, "tooltip appears over a unit card") + check(rect_inside(tip.get_global_rect(), get_viewport().get_visible_rect()), "tooltip stays inside the window") + check(tip.mouse_filter == Control.MOUSE_FILTER_IGNORE, "tooltip never takes mouse input") + await move_to(field) + await frames(2) + check(not tip.visible, "tooltip hides as soon as the pointer leaves") + var tip_count: int = 0 + for node: Node in get_tree().root.find_children("*", "", true, false): + tip_count += int(node is LuneTooltip) + check(tip_count == 1, "one shared tooltip (%d)" % tip_count) + +# ---------------------------------------------------------------- G events and motion +func _events_and_motion() -> void: + var match_state: LuneMatch = await fresh() + armies(14) + # Only Blau fields an army: its Mid wave reaches Rot's Grenzwacht quickly. + for slot: int in range(3, 6): + match_state.players[slot].draft.roster.clear() + match_state.players[slot].draft.hero = {} + match_state.building_hp["rot_mid_outer"] = 40.0 + game._on_ready() + await frames(3) + var tower: LuneUnit = match_state.combat.buildings["rot_mid_outer"] + game.paused = false + while tower.alive and match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat.elapsed < 120.0: + for i: int in range(20): + if not tower.alive or match_state.phase != LuneMatch.Phase.COMBAT: + break + match_state.step_combat() + await frames(1) + game.paused = true + await wait(.4) + check(match_state.phase == LuneMatch.Phase.COMBAT and not tower.alive, "fixture: Rot's Mid Grenzwacht fell during the combat") + var feed: String = texts(hud().feed) + check(feed.contains("MID – Grenzwacht (Rot) zerstört"), "event: MID – Grenzwacht (Rot) zerstört (%s)" % feed.replace("\n", " | ")) + check(hud().feed.entries.size() <= LuneEventFeed.LIMIT, "at most %d events" % LuneEventFeed.LIMIT) + # Critical Ancient with and without reduced motion. + Preferences.reduced_motion = true + var core: LuneUnit = match_state.combat.buildings["blau_ancient"] + core.hp = core.max_hp * .2 + await wait(.4) + check(float(hud()._ancients[0].pulse) == 0.0, "reduced motion: no pulse on the critical Ancient") + check(texts(hud()._banners).contains("kritisch"), "critical own Ancient: banner shown") + check(texts(hud().feed).contains("Dein Ancient kritisch"), "critical own Ancient: event") + Preferences.reduced_motion = false + var enemy_core: LuneUnit = match_state.combat.buildings["rot_ancient"] + enemy_core.hp = enemy_core.max_hp * .2 + await wait(.35) + check(float(hud()._ancients[1].pulse) > 0.0, "without reduced motion the enemy Ancient pulses briefly") + await wait(1.8) + check(float(hud()._ancients[1].pulse) == 0.0, "the pulse ends by itself (no permanent pulsing)") + core.hp = core.max_hp + enemy_core.hp = enemy_core.max_hp + +# ---------------------------------------------------------------- H rates and portraits +func _rates() -> void: + var match_state: LuneMatch = await fresh() + match_state.round_number = 4 + game._starter() + await wait(.5) + var before: int = hud().syncs + await wait(1.0) + var planning_rate: int = hud().syncs - before + check(planning_rate <= 14, "planning: HUD updates about 10 times a second (%d)" % planning_rate) + armies(12) + game._on_ready() + await frames(3) + game.paused = false + game.speed_index = 0 + await wait(.4) + before = hud().syncs + var redraws: int = hud().minimap.redraws + await wait(1.0) + var combat_rate: int = hud().syncs - before + var map_rate: int = hud().minimap.redraws - redraws + check(combat_rate <= 7, "combat: HUD updates about 5 times a second (%d)" % combat_rate) + check(map_rate <= 12, "combat: minimap redraws about 10 times a second (%d)" % map_rate) + var markers: Dictionary = hud().minimap.markers + check(int(markers.get("clusters", 0)) < int(markers.get("units", 0)) and int(markers.get("units", 0)) > 20, "minimap merges armies (%s)" % str(markers)) + check(int(markers.get("heroes", 0)) >= 1, "heroes have their own minimap markers") + facts.rates = {"planning_syncs_per_s": planning_rate, "combat_syncs_per_s": combat_rate, "minimap_redraws_per_s": map_rate, "hud_sync_usec": hud().sync_usec, "minimap_markers": markers} + var cache: LunePortraits = LunePortraits.shared() + var guard: int = 0 + while cache.pending() > 0 and guard < 600: + await frames(1) + guard += 1 + var rendered: int = cache.rendered + var keys: int = LunePortraits.textures.size() + check(rendered > 0 and rendered <= keys, "portraits come from the real models and render once each (%d renders, %d cached)" % [rendered, keys]) + var viewports: int = hud().find_children("*", "SubViewport", true, false).size() + check(viewports == 0, "no live portrait viewports inside the HUD (%d)" % viewports) + await fresh() + game._starter() + await wait(.6) + check(cache.rendered == rendered or cache.rendered - rendered <= 2, "a new match reuses the cached portraits (%d → %d)" % [rendered, cache.rendered]) + facts.portraits = {"rendered": cache.rendered, "cached": LunePortraits.textures.size()} + game.paused = true diff --git a/tools/luneraria/verify_g3.gd.uid b/tools/luneraria/verify_g3.gd.uid new file mode 100644 index 0000000..957dc22 --- /dev/null +++ b/tools/luneraria/verify_g3.gd.uid @@ -0,0 +1 @@ +uid://dkhbngea71rm7 diff --git a/tools/luneraria/verify_g3.tscn b/tools/luneraria/verify_g3.tscn new file mode 100644 index 0000000..71eceab --- /dev/null +++ b/tools/luneraria/verify_g3.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://tools/luneraria/verify_g3.gd" id="1"] + +[node name="VerifyG3" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/verify_m1.gd b/tools/luneraria/verify_m1.gd index 9734392..f0e80ce 100644 --- a/tools/luneraria/verify_m1.gd +++ b/tools/luneraria/verify_m1.gd @@ -129,14 +129,10 @@ func run() -> void: await click(hud._undo) check(state.players[0].draft.roster.size() == 2, "undo removes the last purchase") await frames(4) - var lane_button: Button = null - for child: Node in hud._roster.get_children(): - for grand: Node in child.get_children(): - if grand is Button and grand.text == "M": - lane_button = grand - break - if lane_button != null: - break + # G3 HUD: select the troop in the army strip, then order it to Mid in the detail panel. + await click(hud._chips.values()[0] if not hud._chips.is_empty() else null) + await frames(3) + var lane_button: Button = hud.unit_detail._lanes.get("mid") if hud.unit_detail.visible else null await click(lane_button) check(state.players[0].draft.roster.any(func(e: Dictionary) -> bool: return e.lane == "mid"), "lane assignment button moves a troop to Mid") await click(hud._shop_buttons["g01"]) @@ -241,6 +237,9 @@ func run() -> void: func find_menu_button(hud: Control) -> Button: for node: Node in hud.find_children("*", "Button", true, false): - if (node as Button).text == "Menü" and (node as Button).is_visible_in_tree(): + var caption: String = (node as Button).text + if node.has_meta("label"): + caption = (node.get_meta("label") as Label).text + if caption == "Menü" and (node as Button).is_visible_in_tree(): return node return null diff --git a/tools/luneraria/verify_m2.gd b/tools/luneraria/verify_m2.gd index 170a92c..f79d30a 100644 --- a/tools/luneraria/verify_m2.gd +++ b/tools/luneraria/verify_m2.gd @@ -68,38 +68,38 @@ func run() -> void: 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") + check(hud.hero_card != null, "hero card 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)) + # G3 HUD: the hero card opens the hero window (recruiting, then values, items, talents). + await click(hud.hero_card) 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") + check(hud.modal_open() and hud.hero_detail.recruit_buttons.size() == 3, "hero card opens the hero window") + var recruit: Button = button_with(hud.hero_detail, "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")) + await click(button_with(hud.hero_detail, "TOP")) check(state.players[1].draft.hero.lane == "top", "hero lane changed by click") - await click(hud._tabs.get_button(0)) + hud.close_modal() 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(hud._chips.values()[0] if not hud._chips.is_empty() else null) + await frames(4) + var grotto_button: Button = hud.unit_detail._lanes.get(LuneMatch.GROTTO) if hud.unit_detail.visible else null + check(grotto_button != null and not grotto_button.disabled, "unit details offer 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 click(hud._purchase_tabs.tabs[1]) await frames(6) - check(button_with(hud.reinforce_panel, "150 G") != null, "reinforcement page lists the champions") + var champion_cards: Dictionary = hud.champion_panel.cards + check(hud.champion_panel.visible and champion_cards.size() == 3 and (champion_cards["ch_schutz"] as LuneUnitCard).cost == 150, "champion tab lists the champions") await shot("m2-hud-hero-round1") - await click(hud._tabs.get_button(0)) + await click(hud._purchase_tabs.tabs[0]) while not hud._undo.disabled: await click(hud._undo) await frames(2)