G3 (Zwischenstand): neues Ingame-HUD; Projekt wechselt nach Unity

Nicht abgenommen: Nach dem ersten Spieltest wurde entschieden, Champions of
Luneraria nach Unity 6 zu portieren. Dieser Stand ist die Referenz für das
HUD im Unity-Projekt (../luneraria-unity).

- HUD neu aus Komponenten mit eigenem Theme, Porträts aus den echten Modellen
  (einmal gerendert, zwischengespeichert), Lane-Übersicht mit deterministischer
  Bedrohung aus öffentlichem Zustand, Bossgrotte, nächste Welle, Ereignisse,
  Minimap mit zusammengefassten Armeen, Held/Items/Talente, Einheitendetails,
  Angriffschampions, Bereit-Sperre, UI-Größe, Team-Formen statt nur Farben
- Privatsphäre über LuneHudModel: fremdes Gold und fremde Entwürfe erscheinen nie
- Keine Regeländerung: Regeln 91/91, M2 73/73, Netz 43/43; verify_g3 715/715;
  verify_m1 38/38 und verify_m2 14/14 mit angepassten UI-Treibern
- Spieltest bis Welle 8 über die echte Oberfläche, Befunde in G3-HUD.md
- HELDEN-KONZEPT.md: vom Projektinhaber vorgegebene Helden (nicht umgesetzt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Martin Stickelbroeck 2026-09-29 19:59:55 +02:00
parent 9f62e30377
commit 1b540f2a55
152 changed files with 6568 additions and 792 deletions

View file

@ -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.

59
docs/luneraria/G3-HUD.md Normal file
View file

@ -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.

View file

@ -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?

View file

@ -1,6 +1,8 @@
# STATUS · Champions of Luneraria # 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). 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) | | 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)) | | 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)) | | 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 ## M1 · abgeschlossen

View file

@ -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

View file

@ -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": []
}

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,015 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View file

@ -8,6 +8,8 @@ var follow_camera: bool = true
var fullscreen: bool = false var fullscreen: bool = false
## Menu camera travel, idle drift, particles and fades are skipped when set. ## Menu camera travel, idle drift, particles and fades are skipped when set.
var reduced_motion: bool = false 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" var storage_path: String = "user://preferences.cfg"
func _ready() -> void: func _ready() -> void:
@ -37,6 +39,7 @@ func read_settings() -> void:
follow_camera = bool(config.get_value("camera", "follow", true)) follow_camera = bool(config.get_value("camera", "follow", true))
fullscreen = bool(config.get_value("display", "fullscreen", false)) fullscreen = bool(config.get_value("display", "fullscreen", false))
reduced_motion = bool(config.get_value("display", "reduced_motion", 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: func apply() -> void:
AudioServer.set_bus_volume_db(0, linear_to_db(maxf(.001, volume))) 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("camera", "follow", follow_camera)
config.set_value("display", "fullscreen", fullscreen) config.set_value("display", "fullscreen", fullscreen)
config.set_value("display", "reduced_motion", reduced_motion) config.set_value("display", "reduced_motion", reduced_motion)
config.set_value("display", "ui_scale", ui_scale)
return config.save(storage_path) return config.save(storage_path)
func commit() -> void: func commit() -> void:

View file

@ -55,6 +55,7 @@ func start(setup_options: Dictionary) -> void:
hud = Hud.new() hud = Hud.new()
hud.match_state = match_state hud.match_state = match_state
hud.human_slot = maxi(0, human_slot) hud.human_slot = maxi(0, human_slot)
hud.session = session if _net() else null
layer.add_child(hud) layer.add_child(hud)
hud.command_requested.connect(_on_command) hud.command_requested.connect(_on_command)
hud.undo_requested.connect(func() -> void: _feedback(_send({"type": "undo"}) if _net() else match_state.undo(human_slot))) 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.pause_requested.connect(_toggle_pause)
hud.lane_focus_requested.connect(focus_lane) hud.lane_focus_requested.connect(focus_lane)
hud.map_focus_requested.connect(func(point: Vector3) -> void: camera.look_at_point(point)) 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.menu_requested.connect(func() -> void: exit_requested.emit())
hud.rematch_requested.connect(func() -> void: rematch_requested.emit()) hud.rematch_requested.connect(func() -> void: rematch_requested.emit())
if _net(): if _net():
session.session_message.connect(func(text: String, is_error: bool) -> void: hud.notify(text, is_error)) 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(_rebuild_preview)
session.state_received.connect(hud.mark_dirty)
session.lobby_changed.connect(hud.mark_dirty)
if human_slot >= 0: if human_slot >= 0:
focus_lane(match_state.players[human_slot].lane, true) focus_lane(match_state.players[human_slot].lane, true)
else: else:
@ -104,6 +108,21 @@ func focus_grotto(instant: bool = false) -> void:
var center: Vector2 = match_state.map.grotto_center() var center: Vector2 = match_state.map.grotto_center()
camera.look_at_point(Vector3(center.x, 0, center.y), 22.0, instant) 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. ## The own hero: its planning preview or, in combat, the live unit.
func own_hero_unit() -> LuneUnit: func own_hero_unit() -> LuneUnit:
if human_slot < 0: if human_slot < 0:
@ -304,8 +323,39 @@ func _after_combat() -> void:
var input_blocked: Callable = func() -> bool: return false var input_blocked: Callable = func() -> bool: return false
func _unhandled_input(event: InputEvent) -> void: 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() 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. ## Game keys run before the GUI so Space/Tab keep their meaning even when a button has focus.
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
@ -315,6 +365,8 @@ func _input(event: InputEvent) -> void:
match event.keycode: match event.keycode:
KEY_SPACE: KEY_SPACE:
if match_state.phase == LuneMatch.Phase.PLANNING: if match_state.phase == LuneMatch.Phase.PLANNING:
# A Space meant as pause at the very end of a combat must not skip the planning.
if not hud.ready_blocked():
_on_ready() _on_ready()
else: else:
_toggle_pause() _toggle_pause()
@ -343,6 +395,10 @@ func _input(event: InputEvent) -> void:
camera.overview() camera.overview()
KEY_1, KEY_2, KEY_3, KEY_4, KEY_5: KEY_1, KEY_2, KEY_3, KEY_4, KEY_5:
if match_state.phase == LuneMatch.Phase.PLANNING and human_slot >= 0: if match_state.phase == LuneMatch.Phase.PLANNING and human_slot >= 0:
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) 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]}) _on_command({"type": "buy", "type_id": ids[event.keycode - KEY_1]})
_: _:

View file

@ -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)

View file

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

View file

@ -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"}

View file

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

View file

@ -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}

View file

@ -0,0 +1 @@
uid://8h25mipeosry

View file

@ -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()

View file

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

View file

@ -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"}

View file

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

View file

@ -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

View file

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

View file

@ -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

View file

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

View file

@ -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)

View file

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

View file

@ -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))]

View file

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

View file

@ -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)

View file

@ -0,0 +1 @@
uid://7ltitpqbfktb

View file

@ -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

View file

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

View file

@ -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()

Some files were not shown because too many files have changed in this diff Show more