From f0a8f18de88a7d5aa4644a7eb9bf0df504b070eb Mon Sep 17 00:00:00 2001 From: Team3 Date: Mon, 28 Sep 2026 19:44:30 +0200 Subject: [PATCH] update --- README.md | 16 +- anforderungen.md | 13 +- design.md | 92 ++++++ scripts/ai.gd | 4 +- scripts/campaign.gd | 77 +++-- scripts/cards.gd | 15 +- scripts/deckgen.gd | 4 +- scripts/game.gd | 81 +++-- scripts/net.gd | 13 +- scripts/netstate.gd | 2 +- scripts/profile.gd | 6 +- scripts/ui/battle_fx.gd | 112 ++++++- scripts/ui/battle_layout.gd | 83 +++-- scripts/ui/battle_screen.gd | 463 +++++++++++++++------------ scripts/ui/booster_screen.gd | 50 ++- scripts/ui/campaign_screen.gd | 131 +++++--- scripts/ui/card_grid.gd | 21 +- scripts/ui/card_view.gd | 199 +++++++++--- scripts/ui/collection_screen.gd | 37 ++- scripts/ui/creature_view.gd | 106 ++++-- scripts/ui/field_view.gd | 53 +++ scripts/ui/field_view.gd.uid | 1 + scripts/ui/lobby_screen.gd | 26 +- scripts/ui/main.gd | 15 +- scripts/ui/menu_screen.gd | 74 ++--- scripts/ui/opponent_select_screen.gd | 51 +-- scripts/ui/settings_screen.gd | 7 +- scripts/ui/sfx.gd | 2 + scripts/ui/style.gd | 205 +++++++++++- tests/frame_probe.gd | 6 +- tests/net_bot.gd | 2 +- tests/perf_probe.gd | 13 +- tests/simulate.gd | 38 ++- tests/test_runner.gd | 270 +++++++--------- tests/ui_test.gd | 148 ++++----- translations/ui.csv | 32 +- translations/ui.en.translation | Bin 6396 -> 6712 bytes 37 files changed, 1588 insertions(+), 880 deletions(-) create mode 100644 design.md create mode 100644 scripts/ui/field_view.gd create mode 100644 scripts/ui/field_view.gd.uid diff --git a/README.md b/README.md index e83bfef..542baa4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dinger -Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle mit denselben Regeln. +Kartenspiel in 2D (Godot 4.7): ein Duell gegen einen KI-Magier, beide mit denselben Regeln. ## Starten @@ -24,20 +24,20 @@ Der Spielstand liegt unter `user://spielstand.json` - Kein Deck und keine Starthand: jeder hat einen eigenen Laden mit fünf Angeboten. Kaufen kostet Mana, Ausspielen nichts, neu würfeln kostet 1 Mana - Mana wächst um 1 je Zug (bis 10) und bleibt liegen, wenn du es nicht ausgibst — sparen ist eine echte Entscheidung - Der Laden zeigt nur Karten, die höchstens dein Einkommen plus eins kosten: teure Wesen tauchen erst später auf -- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik: sie ändert weder Werte noch Text +- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik wie bei Holo-Karten: selten farbiger Rahmen, Schein und Edelstein, episch ein wandernder Lichtstreifen, legendär Funkeln, mythisch schillernde Farben; auch auf dem Feld für alle sichtbar, Werte und Text bleiben gleich - Booster (100 Münzen): fünf gewürfelte Stufen, höhere werten die Karte auf, der Rest wird zu Staub. Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück -- Resonanz: gleiche Elemente auf dem Feld verstärken sich (+1, +2, +3, +3) +- Resonanz: gleiche Elemente auf dem Feld verstärken sich (+1, +2, +3, +3); der Bonus steht als „+n“ am Angriff, verbundene Wesen sind unter der Reihe verlinkt - Feldkombos: ein neues Wesen neben einem Wesen mit anderem Element löst einen Effekt aus - Karteneffekte lösen beim Ausspielen aus (Schaden, Einfrieren, Heilen, Zurückwerfen, Verstärken, Schild, Mana); Kartenvorteil ist hier Manavorteil - Elementvorteil im Kreis (Wasser > Feuer > Luft > Erde > Wasser): 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, auch für Karteneffekte gegen Wesen -- Gemeinsames Feld: vier gespielte Karten eines Elements kippen es (Vulkan, Flut, Fels, Wirbel), Wesen dieses Elements bekommen +1 Angriff +- Gemeinsames Feld: vier gespielte Karten eines Elements kippen es (Vulkan, Flut, Fels, Wirbel), Wesen dieses Elements bekommen +1 Angriff; die Feldzeile zeigt je Element die Ladung als vier Punkte +- Vorschau: Hand- und Ladenkarten zeigen, welchen Bonus und welche Feldkombo das Ausspielen jetzt brächte; beim Zeigen ist der Kombo-Partner markiert, beim Angreifen steht der Elementvorteil (+1/−1) am Ziel - Verschmelzen: zwei eigene Wesen werden eins (Elemente und Schlüsselwörter beider, Werte Durchschnitt plus 1, Kosten der Durchschnitt der Karten); drei oder mehr Elemente ergeben eine Chimäre -- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner; die Hand fasst 8 Karten, das Feld 5 Wesen +- Duell mit ausgeloster Zugreihenfolge, wer nicht beginnt, bekommt einmalig 1 Mana; die Hand fasst 8 Karten, das Feld 5 Wesen - Schwierigkeit Leicht/Normal/Schwer: Gegner mit 25/30/35 Leben, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach - KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread - Kampagne: drei Kapitel mit je zehn Magiern, Bosse mit ein bis drei Merkmalen, eigenes Merkmal nach Kapitel 1 - Netzwerkspiel im lokalen Netz: Host führt das Spiel, Mitspieler sehen nur ihre Hand, Zuschauer keine. Fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung, die anderen verbinden neu (Platz bleibt 60 s frei) -- Unterbrochene Einzelspieler-Kämpfe (auch Kampagne) werden laufend gespeichert und lassen sich im Menü fortsetzen - Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`) - Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`) - Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen @@ -56,7 +56,7 @@ DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fe DINGER_ONLY=test_sammlung godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests DINGER_NETZ=1 godot --path . tests/ui_test.tscn # zusätzlich die langsamen Netzwerktests (Bot-Prozesse) godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Ladenangebot, Musik -godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern +godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf ``` ## Export @@ -79,4 +79,6 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe - `scripts/campaign.gd` – Kampagnenstufen - `scenes/main.tscn` – Startszene - `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden +- `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `field_view.gd` die Feldzeile, `battle_layout.gd` die Aufteilung des Kampfes - `translations/ui.csv` – englische Oberflächentexte +- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs diff --git a/anforderungen.md b/anforderungen.md index 8363584..faaa379 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -5,15 +5,14 @@ ## Grundidee - Kartenspiel wie zum Beispiel Magic - PC-Spiel -- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln -- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl +- Duell: Spieler gegen einen KI-Gegner (oder einen Mitspieler im Netz), beide mit gleichen Regeln +- Einzelspieler-Knopf führt zur Wahl der Schwierigkeit - Kampagne gegen benannte Magier als Fortschritt -- Im lokalen Netz zu mehreren spielbar, auch als Zuschauer -- Ein unterbrochener Kampf lässt sich später fortsetzen +- Im lokalen Netz zu zweit spielbar, weitere als Zuschauer ## Sammeln - Jede Karte gehört jedem von Anfang an: wer zum ersten Mal spielt, kann so gut sein wie wer tausend Partien hat -- Gesammelt wird die Seltenheitsstufe einer Karte, und die ist reine Optik (auch im Netzwerkspiel sichtbar) +- Gesammelt wird die Seltenheitsstufe einer Karte, und die ist reine Optik wie bei Holo-Karten: je höher die Stufe, desto mehr Glanz (Rahmen, Schein, Lichtstreifen, Funkeln, Schillern), auch im Netzwerkspiel sichtbar - Booster öffnen: höhere Stufen werten Karten auf, der Rest wird zu Staub - Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück - Kein Deckbau und kein Nachziehstapel: fünf Angebote im Laden, Kaufen kostet Mana, Ausspielen nichts @@ -23,8 +22,8 @@ ## Elemente - Jede Karte gehört zu genau einem Element - Kombos entstehen erst im Spiel: beim Verschmelzen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) und als Feldkombo -- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus +- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus; beides ist vor dem Ausspielen auf der Karte zu sehen - Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (1 Schaden mehr im Vorteil, 1 weniger im Nachteil; Mehrfachelemente nehmen den für sie besseren Wert) -- Das Feld gehört allen am Tisch: vier gespielte Karten eines Elements kippen es, Wesen dieses Elements bekommen +1 Angriff +- Das Feld gehört beiden: vier gespielte Karten eines Elements kippen es, Wesen dieses Elements bekommen +1 Angriff - Zwei eigene Wesen lassen sich für Energie verschmelzen: das neue erbt Elemente und Fähigkeiten beider, seine Werte sind der Durchschnitt plus 1 - Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus diff --git a/design.md b/design.md new file mode 100644 index 0000000..cc151e8 --- /dev/null +++ b/design.md @@ -0,0 +1,92 @@ +# Design von Dinger + +Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entwurf. + +## Woran wir uns orientieren + +- **Hearthstone** – Kosten und Werte springen aus dem Kartenrahmen (Kristall oben links, Angriff + und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt + kurz. Auf dem Brett: eigene Seite unten, Gegner oben, Verlauf links, Zug beenden rechts, Wesen + als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt. +- **Teamfight Tactics** – der Laden ist eine Reihe von fünf großen Karten unten, Neuwürfeln steht + mit seinem Preis direkt daneben, die Bank (bei uns die Hand) liegt als schmale Reihe darüber. + Der Laden zeigt an, was zum eigenen Brett passt (bei uns: „+n“ und die Feldkombo). +- **Pokémon TCG** – Seltenheit ist Optik: Folienglanz, Funkeln, Schillern. Werte und Symbole + statt Sätze. +- **Allgemeine Regeln für Kartenspiel-Oberflächen** (GDKeys, Medium): klar getrennte Zonen, + Lesefluss von links (Vergangenheit) über die Mitte (Spiel) nach rechts (Zukunft), die eigene + Seite größer als die der Gegner, Zahlen groß, Text klein, Symbole statt Wörter, und Rückmeldung + für jede Aktion (Bewegung, Ton, kurzes Zittern). + +## Karte + +- Kostenkristall oben links, Bild (Elementsymbol) oben, Namensband auf 36 % Höhe, darunter + Schlüsselwörter als Symbole (Mauer = Wächter, Schild, Doppelpfeil = Flink, gekreuzte Klingen = + Doppelschlag), dann der Effekttext ohne Einleitung, unten Angriff (gelb) und Leben (rot) als + Kreise, dazwischen der Edelstein der Stufe. +- Große Karten (Vorschau, Sammlung, Booster) schreiben das Wort neben das Symbol und die + Überschrift „Beim Ausspielen“ über den Effekt. Kleine Karten zeigen nur Symbole. +- Handkacheln (96×62) sind flach: Kosten, Name, Werte, Symbole oder das Kombo-Schild. Details + kommen aus der schwebenden Vorschau. +- Seltenheit: gewöhnlich schlicht; selten Rahmen, Schein, Edelstein, stehender Lichtstreifen; + episch wandernder Lichtstreifen; legendär Funkeln; mythisch schillernde Farben. +- Der Kartentext in Daten und Sammlungssuche bleibt vollständig (`Card.text()`), die Karte + zeichnet nur `effect_text()`. + +## Kampfbildschirm (1280×800) + +Nur, was gerade zählt, ist zu sehen. Von unten nach oben: + +- **Laden** als fünf große Karten, links daneben „Neu würfeln“ mit Preis, rechts „Zug beenden“. +- **Hand** als flache Kacheln (die Bank), für andere verdeckt. +- **Eigenes Brett** mit fünf sichtbaren Plätzen, färbt sich mit dem aktiven Feld; links daneben + die eigene Leiste (Leben, Mana, Einkommen, Zug), rechts „Verschmelzen“ und „Rückgängig“, nur + wenn sie gerade möglich sind. +- **Feldband** mit vier Ladepunkten je Element. +- **Gegner** oben in der Mitte mit seinem Brett in voller Größe. Das Spiel ist ein Duell wie + Hearthstone: mehr als zwei Bretter passen nicht lesbar auf einen Bildschirm, und ein Gegner + macht Ziele, Texte und Balance eindeutig. +- **Vorschau** schwebt neben der Karte, auf die man zeigt (über Ladenkarten), mit Resonanz, Feld + und Feldkombo darunter. +- **Verlauf** und **Aufgeben** als kleine Knöpfe oben links; der Verlauf öffnet als Überlagerung. +- Hand- und Ladenkarten tragen „+n“ (Bonus auf dem Brett) und das Kombo-Schild; beim Zeigen ist + der Partner auf dem Brett markiert; beim Angreifen steht der Elementkonter (+1/−1) am Ziel. +- Wesen mit gleichem Element sind unter der Reihe verbunden (Resonanz). + +## Bewegung und Ton + +- Zugbeginn: Band „Dein Zug“ quer über die Mitte, Laden wird ausgeteilt, Ton. +- Kaufen: die Karte fliegt aus dem Laden auf die Hand, Münzton. +- Ausspielen: die Kachel fliegt von der Hand auf ihren Platz und federt ein. +- Angriff: Ausfallschritt, roter Rahmen am Ziel, kurzes Zittern des Bretts, Schadenszahl. +- Feldwechsel: Band in Elementfarbe, das Brett färbt sich. +- Karten heben sich beim Zeigen an, Knöpfe federn beim Drücken, Bildschirme blenden ein. +- Booster: Karten drehen sich auf, seltene Stufen blitzen in ihrer Farbe auf. +- Alles hängt an der Einstellung „Animationen“. + +## Menüs + +- Hauptmenü: Titel und drei Spielarten links, darunter Booster, Sammlung, Einstellungen, + Beenden; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und + Staub oben rechts. +- Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter. +- Kampagne als Pfad mit zehn Stationen je Kapitel, die nächste pulsiert; darunter die gewählte + Stufe mit Gegner, Leben, Belohnung und Merkmalen. Die Bosse werden über Leben und Merkmale + stärker, nicht über Begleiter. +- Sammlung: sechs Spalten, aufgewertete Karten zuerst, rechts die große Vorschau. + +## Offen + +- Bilder statt Elementsymbolen, wenn es einmal Kartenkunst gibt. +- Hero-Porträts für Kampagnenbosse. +- Ein Tisch mit mehreren Spielern ließe sich als TFT-Rotation obendrauf setzen: jede Runde ein + anderer Gegner, das Brett bleibt. + +## Quellen + +- https://hearthstone.wiki.gg/wiki/Battlefield +- https://hearthstone.fandom.com/wiki/Card +- https://moirao.me/home/tft-ui +- https://gdkeys.com/the-card-games-ui-design-of-fairtravel-battle/ +- https://medium.com/@acbassettone/5-ux-ui-lessons-from-designing-a-card-game +- https://hackread.com/the-juice-factor-designing-game-feel/ diff --git a/scripts/ai.gd b/scripts/ai.gd index 6f421fb..9d9d2bf 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -39,8 +39,8 @@ static func hand_value(p: Game.Player) -> float: ## Stellung aus Sicht von p: eigenes Leben, Feld, Hand und übriges Mana zählen positiv, -## die der Gegner negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden -## bremsen). Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders. +## die des Gegners negativ. Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, +## tödliche besonders. static func evaluate(g: Game, p: Game.Player) -> float: if not p.alive(): return -10000.0 diff --git a/scripts/campaign.gd b/scripts/campaign.gd index 846d240..372f331 100644 --- a/scripts/campaign.gd +++ b/scripts/campaign.gd @@ -1,47 +1,47 @@ class_name Campaign -## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Gegnern am Tisch. +## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Leben und Merkmalen. const STAGES := [ - {"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"}, - {"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "reward": 100, "perk": "leben_35"}, - {"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "reward": 150, "perk": "waechter_leben"}, - {"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "reward": 150, "perk": "flink_angriff"}, - {"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "wesen_leben"}, - {"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_mana"}, - {"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "reward": 250, "perk": "resonanz_doppelt"}, - {"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "reward": 250, "perk": "schild_wesen"}, - {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 300, "perk": "alle_flink"}, - {"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 500, "perk": "start_energie"}, + {"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "life": 30, "reward": 100, "perk": "feuer_angriff"}, + {"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "life": 30, "reward": 100, "perk": "leben_35"}, + {"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "life": 31, "reward": 150, "perk": "waechter_leben"}, + {"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "life": 31, "reward": 150, "perk": "flink_angriff"}, + {"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "life": 32, "reward": 200, "perk": "wesen_leben"}, + {"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 200, "perk": "extra_mana"}, + {"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "life": 33, "reward": 250, "perk": "resonanz_doppelt"}, + {"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "life": 33, "reward": 250, "perk": "schild_wesen"}, + {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 300, "perk": "alle_flink"}, + {"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 500, "perk": "start_energie"}, ] -## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück. +## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Leben zurück. const STAGES2 := [ - {"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, - {"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "reward": 300, "perks": ["leben_35", "extra_mana"]}, - {"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]}, - {"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]}, - {"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]}, - {"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_mana", "flink_angriff"]}, - {"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "wesen_leben"]}, - {"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, - {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 500, "perks": ["alle_flink", "start_energie"]}, - {"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_mana"]}, + {"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "life": 30, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, + {"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "life": 30, "reward": 300, "perks": ["leben_35", "extra_mana"]}, + {"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "life": 31, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]}, + {"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "life": 31, "reward": 350, "perks": ["flink_angriff", "alle_flink"]}, + {"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "life": 32, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]}, + {"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 400, "perks": ["extra_mana", "flink_angriff"]}, + {"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "life": 33, "reward": 450, "perks": ["resonanz_doppelt", "wesen_leben"]}, + {"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "life": 33, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, + {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 500, "perks": ["alle_flink", "start_energie"]}, + {"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_mana"]}, ] ## Kapitel 3: drei Merkmale und volle Tische. const STAGES3 := [ - {"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]}, - {"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]}, - {"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "extra": 3, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]}, - {"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "extra": 3, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_mana"]}, - {"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "waechter_leben"]}, - {"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_mana", "flink_angriff", "resonanz_doppelt"]}, - {"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 4, "reward": 900, "perks": ["resonanz_doppelt", "wesen_leben", "start_energie"]}, - {"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]}, - {"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]}, - {"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 4, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_mana", "leben_35"]}, + {"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "life": 32, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]}, + {"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "life": 32, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]}, + {"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "life": 33, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]}, + {"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "life": 33, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_mana"]}, + {"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "life": 34, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "waechter_leben"]}, + {"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "life": 34, "reward": 800, "perks": ["extra_mana", "flink_angriff", "resonanz_doppelt"]}, + {"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "life": 35, "reward": 900, "perks": ["resonanz_doppelt", "wesen_leben", "start_energie"]}, + {"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "life": 35, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]}, + {"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "life": 36, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]}, + {"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "life": 36, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_mana", "leben_35"]}, ] const CHAPTERS := [STAGES, STAGES2, STAGES3] @@ -63,14 +63,9 @@ static func stage(i: int) -> Dictionary: return CHAPTERS[chapter(i) - 1][i % STAGES.size()] -## Gegner einer Stufe: der Boss handelt nur mit seinen Elementen, Begleiter mit allen. -static func opponents(stage_index: int) -> Array: +## Boss einer Stufe: handelt nur mit seinen Elementen, mit dem Leben und den Merkmalen der Stufe. +static func opponent(stage_index: int) -> Game.Player: var st := stage(stage_index) - var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE - var boss := Game.Player.new(st.name, false, life, st.title, st.elements) + var boss := Game.Player.new(st.name, false, int(st.life), st.title, st.elements) boss.perks = Array(st.perks) - var out := [boss] - var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) - for k in extra: - out.append(DeckGen.random_opponent(k + 1)) - return out + return boss diff --git a/scripts/cards.gd b/scripts/cards.gd index 0e3b934..da04744 100644 --- a/scripts/cards.gd +++ b/scripts/cards.gd @@ -26,7 +26,7 @@ const COMBOS := { const FIELD_COMBO_TEXT := { "Dampf": "Friere das stärkste gegnerische Wesen ein", "Lava": "1 Schaden an das stärkste gegnerische Wesen", - "Blitz": "2 Schaden an den schwächsten Gegner", + "Blitz": "2 Schaden an den Gegner", "Schlamm": "Heile dich um 3", "Sturm": "Erhalte 1 Mana", "Sand": "Das neue Wesen erhält +1/+1", @@ -165,9 +165,9 @@ class Effect extends RefCounted: "all_enemy_creatures": return tr("%s Schaden an alle gegnerischen Wesen") % n "all_enemy_heroes": - return tr("%s Schaden an alle Gegner") % n + return tr("%s Schaden an den Gegner") % n "all_enemies": - return tr("%s Schaden an alle Gegner und ihre Wesen") % n + return tr("%s Schaden an den Gegner und seine Wesen") % n "heal": return tr("Heile dich um %s") % n "mana": @@ -250,6 +250,15 @@ class Card extends RefCounted: return fx.target return "" + ## Nur die Effekte (die Karte zeigt Schlüsselwörter als Symbole). + func effect_text() -> String: + if effects.is_empty(): + return "" + var fx: Array[String] = [] + for f in effects: + fx.append(f.text()) + return ", ".join(fx) + "." + func text() -> String: var parts: Array[String] = [] if not keywords.is_empty(): diff --git a/scripts/deckgen.gd b/scripts/deckgen.gd index 91b9fd0..2ee5e1a 100644 --- a/scripts/deckgen.gd +++ b/scripts/deckgen.gd @@ -31,5 +31,5 @@ static func shop_cards(r: RandomNumberGenerator, income: int, elements: Array = ## Gegner mit eigenem Laden über alle Elemente. -static func random_opponent(index: int, life: int = Game.START_LIFE) -> Game.Player: - return Game.Player.new("Gegner %d" % index, false, life, "Magier") +static func random_opponent(life: int = Game.START_LIFE) -> Game.Player: + return Game.Player.new("Gegner", false, life) diff --git a/scripts/game.gd b/scripts/game.gd index 995baf3..fee572f 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -9,7 +9,6 @@ const SHOP_SIZE := 5 # so viele Karten liegen im Laden const REROLL_COST := 1 # Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben. const START_LIFE := 30 -const MAX_OPPONENTS := 5 # Merkmale der Kampagnen-Bosse (Player.perks). const PERKS := { @@ -18,7 +17,7 @@ const PERKS := { "waechter_leben": "Wächter kommen mit +2 Leben", "wesen_leben": "Wesen kommen mit +1 Leben", "extra_mana": "Bekommt jeden Zug 1 Mana mehr", - "leben_35": "Beginnt mit 35 Leben", + "leben_35": "Beginnt mit 5 Leben mehr", "schild_wesen": "Wesen kommen mit Schild", "alle_flink": "Alle Wesen sind flink", "start_energie": "Beginnt mit 3 Mana", @@ -151,8 +150,8 @@ class Player extends RefCounted: var human: Player # der (erste) menschliche Spieler -var humans: Array = [] # alle menschlichen Spieler (im Netz mehrere) -var opponents: Array = [] # alle außer dem ersten Menschen +var humans: Array = [] # alle menschlichen Spieler (im Netz zwei) +var opponent: Player # der andere am Tisch var players: Array = [] # in Zugreihenfolge var winner_player: Player = null var rng: RandomNumberGenerator @@ -167,10 +166,10 @@ var field_element := "" # gemeinsames Feld aller am Tisch, "" = neutral var field_charge := {} # Element -> gespielte Karten seit dem letzten Wechsel -func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void: +func _init(h: Player, o: Player, r: RandomNumberGenerator = null) -> void: human = h - opponents = opps - players = [h] + opps + opponent = o + players = [h, o] humans = players.filter(func(p): return p.is_human) if r == null: r = make_rng() @@ -199,11 +198,15 @@ func ref_of(target) -> Dictionary: return {"kind": "player", "index": players.find(target)} -## Jeder gegen jeden: alle anderen lebenden Spieler sind Gegner. +## Der Gegner von p, solange er lebt (als Liste, damit Effekte einheitlich bleiben). func enemies_of(p: Player) -> Array: return players.filter(func(o): return o != p and o.alive()) +func enemy_of(p: Player) -> Player: + return opponent if p == human else human + + func find_creature(uid: int) -> Creature: for p in players: for c in p.board: @@ -218,7 +221,7 @@ func clone() -> Game: for p in players: copies.append(p.clone()) var idx := players.find(human) - var g := Game.new(copies[idx], copies.slice(0, idx) + copies.slice(idx + 1), RandomNumberGenerator.new()) + var g := Game.new(copies[idx], copies[1 - idx], RandomNumberGenerator.new()) g.players = copies # gleiche Zugreihenfolge wie das Original g.turn = turn g.current_idx = current_idx @@ -483,6 +486,26 @@ static func resonance_for(partners: int) -> int: return DeckGen.RESONANCE_STEPS[mini(partners, DeckGen.RESONANCE_STEPS.size() - 1)] +## Vorschau fürs Ausspielen: Angriffsbonus aus Resonanz und Feld sowie die Feldkombo, +## die die Karte mit dem ersten eigenen Wesen aus anderem Element auslösen würde. +func forecast(p: Player, card: Cards.Card) -> Dictionary: + var partners := 0 + var combo := "" + var partner: Creature = null + for o in p.board: + if o.card.elements.any(func(e): return e in card.elements): + partners += 1 + if combo == "": + for e1 in card.elements: + for e2 in o.card.elements: + if e1 != e2 and combo == "": + combo = Cards.combo_name([e1, e2]) + partner = o + var resonance := resonance_for(partners) * (2 if "resonanz_doppelt" in p.perks else 1) + var field := 1 if field_element != "" and field_element in card.elements else 0 + return {"resonance": resonance, "field": field, "combo": combo, "partner": partner} + + ## Resonanz: abnehmender Angriffsbonus je Partner mit gleichem Element (siehe resonance_for). func update_resonance() -> void: for p in players: @@ -642,20 +665,19 @@ func cleanup() -> void: update_resonance() -## Ende: nur noch ein Spieler übrig, oder kein Mensch mehr am Tisch. +## Ende: einer von beiden hat kein Leben mehr. func check_end() -> void: if winner != "": return var alive := players.filter(func(p): return p.alive()) - var humans_alive := alive.filter(func(p): return p.is_human) - if humans_alive.is_empty(): - winner = "opponents" - winner_player = alive[0] if alive.size() == 1 else null - say("Du wurdest besiegt." if humans.size() == 1 else "Alle Spieler sind besiegt, die KI gewinnt.") - elif alive.size() == 1: - winner = "human" - winner_player = alive[0] - say("Alle Gegner sind besiegt!" if humans.size() == 1 else "%s gewinnt!" % winner_player.name) + if alive.size() != 1: + return + winner_player = alive[0] + winner = "human" if winner_player.is_human else "opponents" + if humans.size() == 1: + say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.") + else: + say("%s gewinnt!" % winner_player.name) # --- Zustand für das Netzwerk (siehe NetState) ------------------------------------ @@ -678,28 +700,23 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void: arr[j] = tmp -## rules: {"human_perks": [...], "opponent_life": int, "looks": {...}} -static func new_battle(n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game: - assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner") +## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}} +static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game: if r == null: r = make_rng() - var life: int = int(rules.get("opponent_life", START_LIFE)) - var opps := [] - for i in n_opp: - opps.append(DeckGen.random_opponent(i + 1, life)) - return new_battle_with(opps, r, false, rules) + return new_battle_with(DeckGen.random_opponent(int(rules.get("opponent_life", START_LIFE))), r, false, rules) -static func new_battle_with(opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game: +static func new_battle_with(opp: Player, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game: if r == null: r = make_rng() var human := Player.new("Spieler", true, START_LIFE) human.perks = Array(rules.get("human_perks", [])) human.looks = rules.get("looks", {}) - var g := Game.new(human, opps, r) + var g := Game.new(human, opp, r) for p in g.players: - if "leben_35" in p.perks and p.life == START_LIFE: - p.life = 35 - p.max_life = 35 + if "leben_35" in p.perks: + p.life += 5 + p.max_life += 5 g.start(fixed_order) return g diff --git a/scripts/net.gd b/scripts/net.gd index 584f272..df09ef0 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -48,7 +48,7 @@ func _ready() -> void: func host(port: int = PORT) -> Error: peer = ENetMultiplayerPeer.new() - var err := peer.create_server(port, Game.MAX_OPPONENTS) + var err := peer.create_server(port, 8) # ein Mitspieler, der Rest Zuschauer if err != OK: status = "Kann nicht hosten (Port %d belegt?)" % port return err @@ -307,10 +307,11 @@ func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void: lobby_changed.emit() -## Host: Spiel mit allen Verbundenen und n_ai KI-Gegnern starten. -func start_battle(n_ai: int) -> void: +## Host: Duell mit dem ersten Mitspieler starten; allein gegen die KI. +func start_battle() -> void: var ids: Array = names.keys() ids.sort() + ids = ids.slice(0, 2) var r := Game.make_rng() var players := [] seats = {} @@ -321,9 +322,9 @@ func start_battle(n_ai: int) -> void: if Cards.has_id(cid): p.looks[cid] = int(pick.looks[cid]) players.append(p) - for i in n_ai: - players.append(DeckGen.random_opponent(i + 1)) - game = Game.new(players[0], players.slice(1), r) + if players.size() < 2: + players.append(DeckGen.random_opponent()) + game = Game.new(players[0], players[1], r) game.start() for id in ids: seats[id] = game.players.find(players[ids.find(id)]) diff --git a/scripts/netstate.gd b/scripts/netstate.gd index 964ef54..27ea8c8 100644 --- a/scripts/netstate.gd +++ b/scripts/netstate.gd @@ -80,7 +80,7 @@ static func from_snapshot(d: Dictionary) -> Game: p.board.append(c) list.append(p) var hi := int(d.human) - var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new()) + var g := Game.new(list[hi], list[1 - hi], RandomNumberGenerator.new()) g.players = list g.turn = int(d.turn) g.current_idx = int(d.current_idx) diff --git a/scripts/profile.gd b/scripts/profile.gd index af5ab09..612daa5 100644 --- a/scripts/profile.gd +++ b/scripts/profile.gd @@ -10,7 +10,7 @@ const BOOSTER_COST := 100 const BOOSTER_SIZE := 5 const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5} const START_COINS := 150 -const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300} +const REWARD_WIN := 100 const REWARD_LOSS := 50 # Schwierigkeit: Leben der Gegner, KI und Münzen. const DIFFICULTIES := { @@ -190,10 +190,10 @@ func open_booster(r: RandomNumberGenerator = null) -> Array: ## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück. ## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei. -func record_battle(won: bool, n_opp: int, stage: int = -1, reward_factor: float = 1.0) -> int: +func record_battle(won: bool, stage: int = -1, reward_factor: float = 1.0) -> int: var reward := REWARD_LOSS if won: - reward = int(round(REWARD_WIN[n_opp] * reward_factor)) + reward = int(round(REWARD_WIN * reward_factor)) if stage >= 0 and stage == campaign_stage: campaign_stage += 1 reward = Campaign.stage(stage).reward diff --git a/scripts/ui/battle_fx.gd b/scripts/ui/battle_fx.gd index e9ea61e..4e108ef 100644 --- a/scripts/ui/battle_fx.gd +++ b/scripts/ui/battle_fx.gd @@ -4,6 +4,8 @@ extends RefCounted var screen # BattleScreen var enabled := true +var banners: Array = [] # wartende Ansagen [Text, Farbe] +var banner_running := false func _init(s) -> void: @@ -25,6 +27,7 @@ func play_events(evs: Array, centers: Dictionary) -> void: var atk: CreatureView = screen.tiles.get(ev.attacker.uid) lunge(atk, center_of(ev.target, centers)) flash_target(ev.target) + shake() delay = 0.12 "damage": sfx("damage") @@ -43,7 +46,7 @@ func play_events(evs: Array, centers: Dictionary) -> void: var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid}) if where.x < 0: where = screen.target_center({"kind": "player", "index": ev.player}) - popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1) + popup("%s!" % tr(ev.name), Style.GOLD, where + Vector2(0, -30), 0.1) "play": sfx("play") "fuse": @@ -51,7 +54,7 @@ func play_events(evs: Array, centers: Dictionary) -> void: popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0) "field": sfx("combo") - popup("Feld: %s" % Cards.FIELD_NAMES[ev.element], Cards.ELEMENT_COLORS[ev.element], Vector2(640, 196), 0.0) + banner("%s: %s" % [tr(Cards.FIELD_NAMES[ev.element]), tr(Cards.FIELD_TEXT[ev.element])], Cards.ELEMENT_COLORS[ev.element]) "buy": sfx("click") # die Karte landet sichtbar auf der Hand, das reicht "reroll": @@ -165,3 +168,108 @@ func move_to(tile: CreatureView, pos: Vector2) -> void: screen.create_tween().tween_property(tile, "position", pos, 0.2) else: tile.position = pos + + +## Ein Wesen fliegt von der Handkachel auf seinen Platz. +func fly_in(tile: CreatureView, from: Rect2) -> void: + if not enabled: + return + tile.busy = true + tile.position = from.position + tile.scale = from.size / tile.size + var tw := tile.create_tween().set_parallel(true) + tw.tween_property(tile, "position", tile.slot, 0.22).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + tw.tween_property(tile, "scale", Vector2.ONE, 0.22).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + tw.chain().tween_callback(func(): + tile.busy = false + tile.position = tile.slot) + + +## Eine gekaufte Karte fliegt aus dem Laden auf die Hand. +func fly_card(card: Cards.Card, from: Rect2, to: Rect2, rarity: String) -> void: + if not enabled: + return + var v := CardView.new(card, from.size, false, rarity) + v.position = from.position + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + v.pivot_offset = Vector2.ZERO + screen.fx_layer.add_child(v) + var tw: Tween = screen.create_tween().set_parallel(true) + tw.tween_property(v, "position", to.position, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT) + tw.tween_property(v, "scale", to.size / from.size, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT) + tw.tween_property(v, "modulate:a", 0.0, 0.1).set_delay(0.2) + tw.chain().tween_callback(v.queue_free) + + +## Ladenkarten werden nacheinander ausgeteilt. +func deal(v: CardView, i: int) -> void: + if not enabled: + return + v.modulate.a = 0.0 + v.position.y += 24 + var tw: Tween = screen.create_tween().set_parallel(true) + tw.tween_property(v, "modulate:a", 1.0, 0.15).set_delay(i * 0.05) + tw.tween_property(v, "position:y", v.position.y - 24, 0.2).set_delay(i * 0.05).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + + +## Kurzes Zittern des Bretts beim Treffer. +func shake() -> void: + if not enabled: + return + var layer: Control = screen.tile_layer + var tw: Tween = screen.create_tween() + for i in 3: + tw.tween_property(layer, "position", Vector2(3 if i % 2 == 0 else -3, 0), 0.03) + tw.tween_property(layer, "position", Vector2.ZERO, 0.03) + + +## Ansage quer über die Mitte: Zugbeginn, Feldwechsel. Mehrere folgen nacheinander. +func banner(text: String, color: Color) -> void: + if not enabled: + return + banners.append([text, color]) + if not banner_running: + _next_banner() + + +func _next_banner() -> void: + if banners.is_empty() or not is_instance_valid(screen) or not screen.is_inside_tree(): + banner_running = false + return + banner_running = true + var next: Array = banners.pop_front() + var text: String = next[0] + var color: Color = next[1] + var delay := 0.0 + var band := ColorRect.new() + band.color = Color(0, 0, 0, 0.55) + band.position = Vector2(0, 300) + band.size = Vector2(1280, 90) + band.mouse_filter = Control.MOUSE_FILTER_IGNORE + band.scale.y = 0.0 + band.pivot_offset = Vector2(640, 45) + screen.fx_layer.add_child(band) + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", 40) + l.add_theme_font_override("font", Style.FONT_BOLD) + l.add_theme_color_override("font_color", color) + l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9)) + l.add_theme_constant_override("outline_size", 8) + l.mouse_filter = Control.MOUSE_FILTER_IGNORE + screen.fx_layer.add_child(l) + l.reset_size() + l.position = Vector2(640, 345) - l.size / 2 + l.pivot_offset = l.size / 2 + l.scale = Vector2(0.6, 0.6) + l.modulate.a = 0.0 + var tw: Tween = screen.create_tween().set_parallel(true) + tw.tween_property(band, "scale:y", 1.0, 0.15).set_delay(delay).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + tw.tween_property(l, "scale", Vector2.ONE, 0.25).set_delay(delay).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + tw.tween_property(l, "modulate:a", 1.0, 0.12).set_delay(delay) + tw.tween_property(l, "modulate:a", 0.0, 0.3).set_delay(delay + 0.8) + tw.tween_property(band, "scale:y", 0.0, 0.2).set_delay(delay + 0.9) + tw.chain().tween_callback(func(): + l.queue_free() + band.queue_free() + _next_banner()) diff --git a/scripts/ui/battle_layout.gd b/scripts/ui/battle_layout.gd index 2e619b8..887aef6 100644 --- a/scripts/ui/battle_layout.gd +++ b/scripts/ui/battle_layout.gd @@ -1,42 +1,71 @@ class_name BattleLayout -## Feste Bildschirmaufteilung des Kampfes (1280x800). +## Feste Bildschirmaufteilung des Duells (1280x800), von unten nach oben: Laden mit Neuwürfeln +## und Zug beenden, Hand, eigenes Brett (mit eigener Leiste links), Feldband, oben der Gegner +## mit seinem Brett. -const SIZES := {1: [110, 110, 10], 2: [96, 110, 8], 3: [78, 100, 4], 4: [54, 72, 4], 5: [44, 62, 3]} -const HAND_W := 130 -const HAND_H := 185 -const HAND_GAP := 8 -const BAR := Rect2(335, 415, 610, 68) -const OWN_X := (1280 - 5 * 110 - 4 * 10) / 2 - - -static func compact(n_others: int) -> bool: - return n_others >= 4 - - -static func hero_box_rect(n_others: int, i: int) -> Rect2: - var col_w := 1280 / n_others - var margin := 6 if compact(n_others) else 10 - return Rect2(i * col_w + margin, 10, col_w - 2 * margin, 64) +const B_GIVEUP := Rect2(10, 10, 96, 32) +const B_LOG := Rect2(112, 10, 96, 32) +const OPP_BOX := Rect2(330, 10, 620, 56) +const FIELD := Rect2(240, 226, 800, 32) +const MSG := Rect2(240, 262, 800, 22) +const OWN_PANEL := Rect2(240, 288, 800, 160) +const STATUS := Rect2(20, 288, 210, 160) +const B_FUSE := Rect2(1050, 298, 210, 40) +const B_UNDO := Rect2(1050, 346, 210, 40) +const OWN_X := 345 +const HAND_W := 96 +const HAND_H := 62 +const HAND_GAP := 4 +const HAND_Y := 458 +const SHOP_Y := 532 +const SHOP_W := 150 +const SHOP_H := 205 +const B_REROLL := Rect2(20, 606, 200, 52) +const B_END := Rect2(1060, 600, 200, 60) +const LOG_PANEL := Rect2(240, 60, 800, 560) +const PREVIEW_SIZE := Vector2(180, 252) +const CENTER_X := 240 +const CENTER_W := 800 static func own_slot(j: int) -> Rect2: - return Rect2(OWN_X + j * 120, 250, 110, 140) + return Rect2(OWN_X + j * 120, 298, 110, 140) -static func other_slot(n_others: int, i: int, j: int) -> Rect2: - var col_w := 1280 / n_others - var sz: Array = SIZES[n_others] - var bx: int = i * col_w + (col_w - 5 * sz[0] - 4 * sz[2]) / 2 - return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1]) +## Brett des Gegners unter seinem Kasten. +static func opp_slot(j: int) -> Rect2: + return Rect2(OWN_X + j * 120, 76, 110, 140) -## Laden: fünf Angebote über der Hand. +## Laden: fünf große Angebote unten, wie in TFT. static func shop_rect(i: int) -> Rect2: - return Rect2(340 + i * 121, 487, 115, 112) + return Rect2(245 + i * (SHOP_W + 10), SHOP_Y, SHOP_W, SHOP_H) +## Hand: flache Kacheln zwischen Brett und Laden (die Bank). static func hand_rect(count: int, i: int) -> Rect2: var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP - var x := (1280 - total) / 2 - return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H) + var x := CENTER_X + (CENTER_W - total) / 2 + return Rect2(x + i * (HAND_W + HAND_GAP), HAND_Y, HAND_W, HAND_H) + +## Schwebende Vorschau neben der gezeigten Karte: über Hand- und Ladenkarten, sonst rechts +## davon (links, wenn rechts kein Platz ist); nie aus dem Bild. +static func preview_rect(anchor: Rect2) -> Rect2: + if anchor.position.y >= HAND_Y - 10: + var x := clampf(anchor.get_center().x - PREVIEW_SIZE.x / 2, 10, 1270 - PREVIEW_SIZE.x) + return Rect2(Vector2(x, anchor.position.y - 12 - PREVIEW_SIZE.y), PREVIEW_SIZE) + var x := anchor.end.x + 12 + if x + PREVIEW_SIZE.x > 1270: + x = anchor.position.x - 12 - PREVIEW_SIZE.x + var y := clampf(anchor.get_center().y - PREVIEW_SIZE.y / 2, 10, 800 - PREVIEW_SIZE.y - 10) + return Rect2(Vector2(x, y), PREVIEW_SIZE) + + +## Zusatzzeile (Resonanz, Feld, Feldkombo) neben der Vorschau, rechts davon oder links. +static func info_rect(preview: Rect2) -> Rect2: + var w := 230.0 + var x := preview.end.x + 8 + if x + w > 1275: + x = preview.position.x - 8 - w + return Rect2(x, preview.position.y + preview.size.y / 2 - 40, w, 80) diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index 68abf03..35021cb 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -4,7 +4,6 @@ extends Control ## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer). var app -var n_opp: int var stage := -1 # Kampagnenstufe oder -1 var net_play := false # Netzwerkspiel (auch als Zuschauer) var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1 @@ -24,8 +23,6 @@ var ai_copy: Game # Spielkopie, auf der die KI rechnet var ai_box := {} # Ergebnis der Berechnung (vom Thread befüllt) var reward := -1 var ended := false -var zoom_player = null -var preview_timer := 0.0 var pad: BattleGamepad var gp_zone: String: get: @@ -36,7 +33,6 @@ var gp_index: int: var skip_ai := false # Gegnerzüge im Schnelldurchlauf var smart_ai := true # Vorausschau-KI (Leicht: einfache KI) var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion -var saved_game: Game = null # fortgesetzter Kampf (siehe resume) var undo_log_len := 0 var undo_t := 0.0 @@ -54,11 +50,12 @@ var hero_layer: Control var tile_layer: Control var hand_layer: Control var shop_layer: Control -var zoom_layer: Control +var log_overlay: Control +var b_log: Button +var status_panel: Panel var fx_layer: Control var log_box: RichTextLabel var preview_holder: Control -var bar_panel: Panel var name_label: Label var life_label: Label var energy_label: Label @@ -69,38 +66,35 @@ var b_end: Button var b_fuse: Button var b_reroll: Button var b_giveup: Button -var field_label: Label +var field_view: FieldView +var link_view: Links # Resonanzlinien unter den Feldreihen +var info_label: Label # Vorschau: Resonanz, Feld, Feldkombo +var board_panel: Panel # eigenes Brett, färbt sich mit dem Feld +var arrivals := {} # uid -> Rect2: Wesen, das gerade aus der Hand aufs Brett fliegt +var banner_turn := -1 # Zug, für den die Zugansage schon lief +var deal_shop := false # nächster Aufbau teilt den Laden animiert aus var overlay: Control var result_label: Label var reward_label: Label var notice_label: Label var confirm: Control -var b_save_leave: Button var cursor_layer: Control var wait_label: Label var b_skip: Button var b_undo: Button -func _init(n: int = 2, campaign_stage: int = -1) -> void: - n_opp = n +func _init(campaign_stage: int = -1) -> void: stage = campaign_stage static func online(seat: int) -> BattleScreen: - var b := BattleScreen.new(0, -1) + var b := BattleScreen.new(-1) b.net_play = true b.online_seat = seat return b -## Unterbrochenen Kampf aus der Datei fortsetzen (siehe save_battle). -static func resume(data: Dictionary) -> BattleScreen: - var s := BattleScreen.new(int(data.get("n_opp", 1)), int(data.get("stage", -1))) - s.saved_game = Game.from_snapshot(data.game) - return s - - func is_online() -> bool: return net_play @@ -137,13 +131,11 @@ func _ready() -> void: net.snapshot_received.connect(_on_snapshot) wait_label.visible = true return # der erste Zustand kommt vom Host - elif saved_game != null: - game = saved_game elif stage >= 0: var r := Game.make_rng() - game = Game.new_battle_with(Campaign.opponents(stage), r, false, own_rules()) + game = Game.new_battle_with(Campaign.opponent(stage), r, false, own_rules()) else: - game = Game.new_battle(n_opp, Game.make_rng(), own_rules()) + game = Game.new_battle(Game.make_rng(), own_rules()) smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile() _begin() @@ -160,7 +152,6 @@ func own_rules() -> Dictionary: func _begin() -> void: - n_opp = game.players.size() - 1 if is_online(): perspective = game.players[maxi(online_seat, 0)] else: @@ -170,67 +161,94 @@ func _begin() -> void: if is_online() and net.is_host: net.broadcast() game.events.clear() - b_save_leave.visible = resumable() - save_battle() refresh() + announce_turn() func _build_static() -> void: - Style.panel(self, Rect2(10, 205, 320, 290)) - Style.label(self, "Verlauf", 14, Style.MUTED, Rect2(20, 209, 200, 20)) - log_box = RichTextLabel.new() - log_box.position = Vector2(18, 230) - log_box.size = Vector2(304, 258) - log_box.bbcode_enabled = true - log_box.scroll_following = true - log_box.add_theme_font_size_override("normal_font_size", 13) - log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(log_box) - b_giveup = Style.button(self, "Aufgeben", Rect2(10, 553, 150, 40), func(): confirm.visible = true) - Style.panel(self, Rect2(950, 205, 320, 380)) - Style.panel(self, Rect2(BattleLayout.OWN_X - 10, 240, 5 * 110 + 4 * 10 + 20, 160), Style.darker(Style.PANEL, 0.8), 10) - bar_panel = Style.panel(self, BattleLayout.BAR) - name_label = Style.label(self, "", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true) - life_label = Style.label(self, "", 24, Style.RED, Rect2(470, 418, 100, 34), "left", true) - energy_label = Style.label(self, "", 24, Style.BLUE, Rect2(570, 418, 150, 34), "left", true) - income_label = Style.label(self, "", 16, Style.MUTED, Rect2(760, 424, 170, 24), "right") - status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24)) - msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true) - b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c")) - b_skip = Style.button(self, "Gegnerzüge überspringen", Rect2(1005, 527, 210, 48), func(): skip_ai = true) + var L := BattleLayout + # oben links: Aufgeben und Verlauf, klein + b_giveup = Style.button(self, "Aufgeben", L.B_GIVEUP, func(): confirm.visible = true) + b_giveup.add_theme_font_size_override("font_size", 13) + b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log) + b_log.add_theme_font_size_override("font_size", 13) + # Mitte: Feldband, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben + Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8) + field_view = FieldView.new(L.FIELD) + add_child(field_view) + msg_label = Style.label(self, "", 15, Style.ORANGE, L.MSG, "center", true) + board_panel = Style.panel(self, L.OWN_PANEL, Style.darker(Style.PANEL, 0.8), 10) + var slots := Control.new() + slots.mouse_filter = Control.MOUSE_FILTER_IGNORE + slots.draw.connect(func(): + for j in Game.MAX_BOARD: + Style.border(slots, L.own_slot(j), Color(1, 1, 1, 0.08), 2, 8) + Style.border(slots, L.opp_slot(j), Color(1, 1, 1, 0.05), 2, 8)) + add_child(slots) + status_panel = Style.panel(self, L.STATUS) + var sx: float = L.STATUS.position.x + var sy: float = L.STATUS.position.y + name_label = Style.label(self, "", 18, Style.TEXT, Rect2(sx + 12, sy + 8, 190, 26), "left", true) + name_label.clip_text = true + life_label = Style.label(self, "", 26, Style.RED, Rect2(sx + 12, sy + 40, 190, 34), "left", true) + energy_label = Style.label(self, "", 26, Style.BLUE, Rect2(sx + 12, sy + 78, 100, 34), "left", true) + income_label = Style.label(self, "", 15, Style.MUTED, Rect2(sx + 100, sy + 88, 100, 22), "left") + status_label = Style.label(self, "", 13, Style.MUTED, Rect2(sx + 12, sy + 124, 190, 22), "left") + b_fuse = Style.button(self, "Verschmelzen", L.B_FUSE, toggle_fuse, Color("3c2858")) + b_fuse.add_theme_font_size_override("font_size", 15) + b_fuse.visible = false + b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo) + b_undo.add_theme_font_size_override("font_size", 15) + b_undo.visible = false + # unten: Laden mit Neuwürfeln links und Zug beenden rechts + b_reroll = Style.button(self, "", L.B_REROLL, do_reroll, Color("285a5a")) + b_reroll.add_theme_font_size_override("font_size", 15) + b_end = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY) + b_end.add_theme_font_size_override("font_size", 20) + b_skip = Style.button(self, "Gegnerzüge überspringen", L.B_END, func(): skip_ai = true) b_skip.add_theme_font_size_override("font_size", 14) b_skip.visible = false - b_undo = Style.button(self, "Rückgängig", Rect2(170, 553, 150, 40), undo) - b_undo.visible = false - field_label = Style.label(self, "", 15, Style.MUTED, Rect2(340, 211, 600, 24), "center") - b_fuse = Style.button(self, "Verschmelzen", Rect2(10, 503, 150, 40), toggle_fuse, Color("3c2858")) - b_fuse.visible = false - b_reroll = Style.button(self, "", Rect2(170, 503, 150, 40), do_reroll, Color("285a5a")) - b_reroll.add_theme_font_size_override("font_size", 14) wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center") wait_label.visible = false hero_layer = _layer() + link_view = Links.new() + add_child(link_view) tile_layer = _layer() hand_layer = _layer() shop_layer = _layer() preview_holder = _layer() - zoom_layer = _layer() + info_label = Style.label(self, "", 13, Style.TEXT, Rect2(0, 0, 220, 60), "center") + info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + info_label.visible = false fx_layer = _layer() cursor_layer = _layer() + # Verlauf als Überlagerung + log_overlay = Style.overlay(self) + log_overlay.get_child(0).gui_input.connect(func(ev): + if ev is InputEventMouseButton and ev.pressed: + log_overlay.visible = false) + Style.panel(log_overlay, L.LOG_PANEL, Style.PANEL, 10, Style.MUTED, 2) + Style.label(log_overlay, "Verlauf", 20, Style.GOLD, Rect2(L.LOG_PANEL.position + Vector2(20, 12), Vector2(300, 30)), "left", true) + log_box = RichTextLabel.new() + log_box.position = L.LOG_PANEL.position + Vector2(20, 50) + log_box.size = L.LOG_PANEL.size - Vector2(40, 110) + log_box.bbcode_enabled = true + log_box.scroll_following = true + log_box.add_theme_font_size_override("normal_font_size", 14) + log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE + log_overlay.add_child(log_box) + Style.button(log_overlay, "Schließen", Rect2(L.LOG_PANEL.end.x - 180, L.LOG_PANEL.end.y - 52, 160, 40), func(): log_overlay.visible = false) # Spielende overlay = _overlay() result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true) reward_label = Style.label(overlay, "", 26, Style.GOLD, Rect2(0, 335, 1280, 40), "center") notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center") Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c")) - # Aufgeben bestätigen - confirm = Style.overlay(self) - Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.MUTED, 2) - Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true) - Style.button(confirm, "Ja, aufgeben", Rect2(355, 420, 180, 46), leave, Color("5a2828")) - b_save_leave = Style.button(confirm, "Speichern und zurück", Rect2(550, 420, 180, 46), save_and_leave, Color("285a3c")) - b_save_leave.add_theme_font_size_override("font_size", 14) - Style.button(confirm, "Weiterspielen", Rect2(745, 420, 180, 46), func(): confirm.visible = false) + confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen") + + +func toggle_log() -> void: + log_overlay.visible = not log_overlay.visible func _layer() -> Control: @@ -244,54 +262,27 @@ func _overlay() -> Control: return Style.overlay(self) -## Einzelspieler-Kämpfe (auch Kampagne) lassen sich unterbrechen und fortsetzen. -func resumable() -> bool: - return counts_for_profile() - - -func save_battle() -> void: - if not resumable() or game == null or game.winner != "": - return - var d := {"version": 1, "n_opp": n_opp, "stage": stage, "game": game.snapshot(-1)} - var f := FileAccess.open(app.battle_save_path(), FileAccess.WRITE) - if f != null: - f.store_string(JSON.stringify(d)) - f.close() - - -func save_and_leave() -> void: - save_battle() - app.show_screen(MenuScreen.new()) - - func leave() -> void: if is_online(): net.leave() - if resumable() and game != null and game.winner == "": - app.clear_battle_save() # aufgegeben app.show_screen(MenuScreen.new()) # --- Layout-Helfer ------------------------------------------------------------------ +## Der Gegner aus Sicht der eigenen Perspektive (Zuschauer: der zweite Spieler). +func opponent() -> Game.Player: + return game.enemy_of(perspective) + + func others() -> Array: - var out := [] - var n := game.players.size() - var start := game.players.find(perspective) - for i in range(1, n): - out.append(game.players[(start + i) % n]) - return out - - -func compact() -> bool: - return BattleLayout.compact(others().size()) + return [opponent()] func slot_rect(p: Game.Player, j: int) -> Rect2: if p == perspective: return BattleLayout.own_slot(j) - var os := others() - return BattleLayout.other_slot(os.size(), os.find(p), j) + return BattleLayout.opp_slot(j) func ref_key(ref: Dictionary) -> String: @@ -306,47 +297,37 @@ func target_center(ref: Dictionary) -> Vector2: return Vector2(-1, -1) var p: Game.Player = game.players[ref.index] if p == perspective: - return BattleLayout.BAR.position + BattleLayout.BAR.size / 2 + return BattleLayout.STATUS.get_center() if hero_panels.has(p): var panel: Panel = hero_panels[p] return panel.position + panel.size / 2 return Vector2(-1, -1) +## Kasten des Gegners oben in der Mitte; ein Klick zielt auf den Helden. func build_hero_boxes() -> void: Style.clear(hero_layer) hero_panels = {} hero_labels = {} - var os := others() - var c := compact() - for i in os.size(): - var o: Game.Player = os[i] - var box := BattleLayout.hero_box_rect(os.size(), i) - var panel := Style.panel(hero_layer, box) - panel.gui_input.connect(func(ev): - if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT: - on_click("hero", o)) - if not o.perks.is_empty(): - panel.tooltip_text = "Merkmal: " + ", ".join(o.perks.map(func(k): return Game.PERKS[k])) - hero_panels[o] = panel - var labels := {} - var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name - if not o.perks.is_empty(): - title += " ★" - labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT, - Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (58 if c else 20), 26), "left", true) - labels.name.clip_text = true - labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED, - Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true) - labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED, - Rect2(box.position.x + 90, box.position.y + 38, box.size.x - 100, 20), "right") - hero_labels[o] = labels - if c: - var b := Style.button(hero_layer, "Lupe", Rect2(box.end.x - 44, box.position.y + 5, 38, 22), func(): - zoom_player = o - refresh()) - b.add_theme_font_size_override("font_size", 11) + var o := opponent() + var box := BattleLayout.OPP_BOX + var panel := Style.panel(hero_layer, box) + panel.gui_input.connect(func(ev): + if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT: + on_click("hero", o)) + if not o.perks.is_empty(): + panel.tooltip_text = "Merkmal: " + ", ".join(o.perks.map(func(k): return Game.PERKS[k])) + hero_panels[o] = panel + var labels := {} + var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name + if not o.perks.is_empty(): + title += " ★" + labels["name"] = Style.label(hero_layer, title, 18, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 4, box.size.x - 16, 22), "left", true) + labels.name.clip_text = true + labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + labels["life"] = Style.label(hero_layer, "", 20, Style.RED, Rect2(box.position.x + 8, box.position.y + 30, 120, 24), "left", true) + labels["stats"] = Style.label(hero_layer, "", 14, Style.MUTED, Rect2(box.position.x + 80, box.position.y + 34, box.size.x - 88, 20), "right") + hero_labels[o] = labels # --- Ablauf und Eingabe ------------------------------------------------------------------ @@ -358,7 +339,7 @@ func my_turn() -> bool: func any_overlay() -> bool: - return confirm.visible or overlay.visible + return confirm.visible or overlay.visible or log_overlay.visible func _input(event: InputEvent) -> void: @@ -376,7 +357,9 @@ func _input(event: InputEvent) -> void: KEY_ESCAPE: if confirm.visible: confirm.visible = false - elif selected != null or pending != null or fusing or zoom_player != null: + elif log_overlay.visible: + log_overlay.visible = false + elif selected != null or pending != null or fusing: cancel_all() elif not any_overlay(): confirm.visible = true @@ -402,7 +385,6 @@ func cancel_all() -> void: if confirm.visible: confirm.visible = false cancel() - zoom_player = null refresh() @@ -412,10 +394,6 @@ func _process(delta: float) -> void: msg_t = maxf(0.0, msg_t - delta) if msg_t <= 0.0 and msg_label.text != "": msg_label.text = "" - if preview_timer > 0.0: - preview_timer -= delta - if preview_timer <= 0.0: - show_preview(null, false) if undo_t > 0.0: undo_t -= delta if undo_t <= 0.0: @@ -477,10 +455,9 @@ func _finish() -> void: var won := game.winner == "human" if counts_for_profile(): var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0 - reward = app.profile.record_battle(won, n_opp, stage, factor) + reward = app.profile.record_battle(won, stage, factor) app.save() app.sfx.play("win" if won else "lose") - app.clear_battle_save() else: var wp := game.winner_player var mine: bool = wp != null and wp == perspective @@ -506,10 +483,13 @@ func play(card: Cards.Card, target) -> void: return var before: Game = game.clone() if not is_online() else null var log_len := game.log.size() + var from := _hand_rect_of(card) var err := game.play_card(perspective, card, target) if err != "": say(tr(err)) return + if from.size.x > 0 and not perspective.board.is_empty() and perspective.board[-1].card == card: + arrivals[perspective.board[-1].uid] = from # Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte und keine Kombos) if before != null and game.events.size() == 1 and game.events[0].type == "play": undo_state = before @@ -534,9 +514,15 @@ func do_buy(slot: int) -> void: if is_client(): net.send_action({"type": "buy", "slot": slot}) return + var from := BattleLayout.shop_rect(slot) + var offer = perspective.shop[slot] if slot < perspective.shop.size() else null var err := game.buy(perspective, slot) if err != "": say(tr(err)) + elif offer != null: + fx.sfx("buy") + fx.fly_card(offer, from, BattleLayout.hand_rect(perspective.hand.size(), perspective.hand.size() - 1), + Cards.RARITIES[int(perspective.looks.get(offer.id, 0))]) ## Würfelt den Laden neu (kostet Mana). @@ -551,6 +537,8 @@ func do_reroll() -> void: var err := game.reroll(perspective) if err != "": say(tr(err)) + else: + deal_shop = true after_action() @@ -596,7 +584,6 @@ func end_turn() -> void: return cancel() clear_undo() - zoom_player = null if is_client(): net.send_action({"type": "end"}) return @@ -629,7 +616,6 @@ func on_click(kind: String, obj) -> void: play(card, obj) else: say(tr("Abgebrochen")) - zoom_player = null after_action() return if selected != null: @@ -643,7 +629,6 @@ func on_click(kind: String, obj) -> void: var err := g.attack(cr, obj) if err != "": say(tr(err)) - zoom_player = null after_action() return match kind: @@ -677,17 +662,121 @@ func on_tile_click(cr: Game.Creature) -> void: on_click("enemy", cr) -func show_preview(card, entered: bool, owner_looks: Dictionary = {}) -> void: +## Zugansage, wenn der eigene Zug beginnt. +func announce_turn() -> void: + if game == null or not my_turn() or banner_turn == game.turn: + return + banner_turn = game.turn + fx.banner(tr("Dein Zug"), Style.GOLD) + fx.sfx("turn") + deal_shop = true + refresh() + + +func _hand_rect_of(card: Cards.Card) -> Rect2: + for v in hand_views: + if v.card == card: + return Rect2(v.position, v.size) + return Rect2() + + +## Vorschau schwebt neben der gezeigten Karte (anchor), darunter die Zusatzzeile (info). +func show_preview(card, entered: bool, owner_looks: Dictionary = {}, info: String = "", anchor: Rect2 = Rect2()) -> void: Style.clear(preview_holder) + info_label.visible = entered and info != "" + info_label.text = info if entered else "" if entered and card != null: - var v := CardView.new(card, Vector2(210, 294), false, Cards.RARITIES[int(owner_looks.get(card.id, 0))]) - v.position = Vector2(1005, 215) + var r := BattleLayout.preview_rect(anchor) + var v := CardView.new(card, r.size, false, Cards.RARITIES[int(owner_looks.get(card.id, 0))]) + v.position = r.position v.mouse_filter = Control.MOUSE_FILTER_IGNORE preview_holder.add_child(v) + var ir := BattleLayout.info_rect(r) + info_label.position = ir.position + info_label.size = ir.size -func show_hand_preview(card: Cards.Card, entered: bool) -> void: - show_preview(card, entered, perspective.looks) +## Eigene Hand- und Ladenkarte: zeigt, was das Ausspielen jetzt brächte, und markiert den Kombo-Partner. +func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void: + var f := game.forecast(perspective, card) + var lines: Array[String] = [] + var parts: Array[String] = [] + if f.resonance > 0: + parts.append(tr("Resonanz +%d") % f.resonance) + if f.field > 0: + parts.append(tr("Feld +%d") % f.field) + if not parts.is_empty(): + lines.append(" · ".join(parts)) + if f.combo != "": + lines.append(tr("Feldkombo %s mit %s: %s") % [tr(f.combo), f.partner.card.title(), tr(Cards.FIELD_COMBO_TEXT[f.combo])]) + for t in tiles.values(): + t.mark = "" + t.queue_redraw() + if entered and f.partner != null and tiles.has(f.partner.uid): + tiles[f.partner.uid].mark = f.combo + tiles[f.partner.uid].queue_redraw() + show_preview(card, entered, perspective.looks, "\n".join(lines), anchor) + + +## Hand- und Ladenkarte tragen ihren Bonus und ihre Feldkombo als Vorschau. +func apply_forecast(v: CardView) -> void: + var f := game.forecast(perspective, v.card) + v.bonus = f.resonance + f.field + v.tag = f.combo + + +## Was den Angriff eines Wesens gerade hebt. +func creature_info(cr: Game.Creature) -> String: + var parts: Array[String] = [] + if cr.resonance > 0: + parts.append(tr("Resonanz +%d") % cr.resonance) + if cr.field_bonus > 0: + parts.append(tr("Feld +%d") % cr.field_bonus) + var perk := cr.power() - cr.attack - cr.resonance - cr.field_bonus + if perk > 0: + parts.append(tr("Merkmal +%d") % perk) + return " · ".join(parts) + + +## Elementvorteil gegen ein mögliches Ziel: des gewählten Angreifers oder eines Schadenszaubers auf der Hand. +func matchup_for(cr: Game.Creature, valid: Array) -> int: + if selected != null and game.can_attack(selected, cr)[0]: + return Game.matchup_bonus(selected, cr) + if pending != null and cr in valid and pending.effects.any(func(fx): return fx.kind == "damage" and fx.target in Cards.CHOSEN_TARGETS): + return Cards.matchup(pending.elements, cr.card.elements) + return 0 + + +## Resonanzgruppen je Reihe: Wesen mit gleichem Element werden unter der Reihe verbunden. +func resonance_groups() -> Array: + var out := [] + for p in game.players: + var k := 0 + for e in Cards.ELEMENTS: + var xs: Array[float] = [] + for j in p.board.size(): + if e in p.board[j].card.elements: + xs.append(slot_rect(p, j).get_center().x) + if xs.size() < 2: + continue + out.append({"color": Style.lighter(Cards.ELEMENT_COLORS[e], 0.25), "y": slot_rect(p, 0).end.y + 4 + k * 6, "xs": xs}) + k += 1 + return out + + +class Links extends Control: + var groups: Array = [] + + func _init() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_IGNORE + + func _draw() -> void: + for gr in groups: + var y: float = gr.y + draw_line(Vector2(gr.xs[0], y), Vector2(gr.xs[-1], y), gr.color, 3.0) + for x in gr.xs: + draw_circle(Vector2(x, y), 4.0, gr.color) # --- Netzwerk ------------------------------------------------------------------------------- @@ -744,7 +833,7 @@ func after_action() -> void: centers[ref_key(ev[key])] = target_center(ev[key]) refresh() fx.play_events(evs, centers) - save_battle() + announce_turn() # --- Ansicht angleichen -------------------------------------------------------------------- @@ -781,8 +870,7 @@ func refresh() -> void: labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED) if o.alive(): labels.life.text = "♥ %d" % maxi(o.life, 0) - labels.stats.text = ("H %d ◆ %d" % [o.hand.size(), o.energy]) if compact() \ - else ("Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy]) + labels.stats.text = "Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy] else: labels.life.text = "besiegt" labels.life.add_theme_color_override("font_color", Style.MUTED) @@ -798,10 +886,11 @@ func refresh() -> void: if tiles.has(cr.uid): tile = tiles[cr.uid] tile.creature = cr - if tile.size != rect.size: - tile.size = rect.size + if tile.size != rect.size: # erst die Mindestgröße, sonst bleibt die Kachel groß tile.custom_minimum_size = rect.size + tile.size = rect.size tile.pivot_offset = rect.size / 2 + tile.set_process(Style.rarity_animated(tile.rarity())) else: tile = CreatureView.new(cr, rect.size) tile.position = rect.position @@ -809,13 +898,17 @@ func refresh() -> void: tiles[cr.uid] = tile tile_layer.add_child(tile) var looks: Dictionary = p.looks - tile.hover.connect(func(card, entered): show_preview(card, entered, looks)) + tile.hover.connect(func(card, entered): show_preview(card, entered, looks, creature_info(tile.creature) if entered else "", Rect2(tile.slot, tile.size))) var uid := cr.uid tile.clicked.connect(func(_v): var c := game.find_creature(uid) if c != null: on_tile_click(c)) - fx.pop_in(tile) + if arrivals.has(cr.uid): + fx.fly_in(tile, arrivals[cr.uid]) + arrivals.erase(cr.uid) + else: + fx.pop_in(tile) tile.slot = rect.position if not tile.busy and tile.position != rect.position: fx.move_to(tile, rect.position) @@ -835,6 +928,8 @@ func refresh() -> void: w = 3 tile.border = b tile.width = w + tile.mark = "" + tile.matchup = matchup_for(cr, valid) tile.queue_redraw() if p == h: own_views.append(tile) @@ -845,6 +940,8 @@ func refresh() -> void: var t: CreatureView = tiles[uid] tiles.erase(uid) fx.fade_out(t) + link_view.groups = resonance_groups() + link_view.queue_redraw() Style.clear(shop_layer) if not spectating(): @@ -856,17 +953,22 @@ func refresh() -> void: continue var sv := CardView.new(offer, sr.size, false, Cards.RARITIES[int(h.looks.get(offer.id, 0))]) sv.position = sr.position + sv.lift = mine + apply_forecast(sv) sv.dim = not mine or h.energy < offer.cost or h.hand.size() >= Game.MAX_HAND - sv.hover.connect(show_hand_preview) + sv.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(sv.position, sv.size))) var slot := i sv.set_meta("slot", slot) # der Platz im Laden, auch für das Gamepad sv.clicked.connect(func(_view): on_click("shop", slot)) shop_layer.add_child(sv) shop_views.append(sv) + if deal_shop: + fx.deal(sv, i) + deal_shop = false Style.clear(hand_layer) if spectating(): - Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(0, 690, 1280, 30), "center") + Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(240, BattleLayout.HAND_Y + 18, 800, 30), "center") else: for i in h.hand.size(): var card: Cards.Card = h.hand[i] @@ -874,12 +976,14 @@ func refresh() -> void: var r := BattleLayout.hand_rect(h.hand.size(), i) var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))]) v.position = r.position + v.lift = mine + apply_forecast(v) if card == pending: v.border = Style.YELLOW elif playable: v.border = Style.GREEN v.dim = not playable - v.hover.connect(show_hand_preview) + v.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(v.position, v.size))) v.clicked.connect(func(_view): on_click("hand", card)) hand_layer.add_child(v) hand_views.append(v) @@ -887,7 +991,11 @@ func refresh() -> void: var bar_border = Style.BLUE if mine else null if int(flash_until.get(g.players.find(h), 0)) > now: bar_border = Style.RED - bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0)) + status_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0)) + var board_col := Style.darker(Style.PANEL, 0.8) + if g.field_element != "": + board_col = board_col.lerp(Cards.ELEMENT_COLORS[g.field_element], 0.22) # das Feld färbt das Brett + board_panel.add_theme_stylebox_override("panel", Style.box(board_col, 10)) name_label.text = h.name life_label.text = "♥ %d" % maxi(h.life, 0) energy_label.text = "◆ %d" % h.energy @@ -902,18 +1010,10 @@ func refresh() -> void: b_undo.visible = undo_state != null and mine b_fuse.visible = mine and not spectating() and h.board.size() >= 2 b_reroll.visible = mine and not spectating() - b_reroll.text = tr("Neu würfeln (%d)") % Game.REROLL_COST + b_reroll.text = tr("Neu würfeln ◆ %d") % Game.REROLL_COST b_reroll.disabled = h.energy < Game.REROLL_COST b_fuse.text = tr("Abbrechen") if fusing else tr("Verschmelzen") - if g.field_element == "": - var parts: Array[String] = [] - for e in Cards.ELEMENTS: - parts.append("%s %d" % [tr(e), int(g.field_charge.get(e, 0))]) - field_label.text = tr("Feld: neutral") + " · " + " ".join(parts) - field_label.add_theme_color_override("font_color", Style.MUTED) - else: - field_label.text = "%s: %s" % [tr(Cards.FIELD_NAMES[g.field_element]), tr(Cards.FIELD_TEXT[g.field_element])] - field_label.add_theme_color_override("font_color", Cards.ELEMENT_COLORS[g.field_element]) + field_view.update_from(g) var lines: Array[String] = [] var start := maxi(0, g.log.size() - 60) for i in range(start, g.log.size()): @@ -929,10 +1029,6 @@ func refresh() -> void: log_box.text = "\n".join(lines) pad.draw_cursor() - Style.clear(zoom_layer) - if zoom_player != null and zoom_player.alive(): - build_zoom(valid) - if g.winner != "": var wp := g.winner_player if counts_for_profile(): @@ -951,30 +1047,3 @@ func refresh() -> void: overlay.visible = true confirm.visible = false - -func build_zoom(valid: Array) -> void: - var o: Game.Player = zoom_player - var g := game - var box := Rect2(190, 190, 900, 300) - var back := Style.panel(zoom_layer, box, Style.PANEL, 10, Style.GOLD, 2) - back.mouse_filter = Control.MOUSE_FILTER_STOP - Style.label(zoom_layer, "%s · %s ♥ %d Hand %d ◆ %d (+%d)" % [o.name, o.title, o.life, o.hand.size(), o.energy, o.max_energy], - 18, Style.TEXT, Rect2(box.position.x + 20, box.position.y + 12, 800, 30), "left", true) - for j in o.board.size(): - var cr: Game.Creature = o.board[j] - var b = null - if cr in valid or (selected != null and g.can_attack(selected, cr)[0]): - b = Style.ORANGE - var v := CreatureView.new(cr, Vector2(110, 140), b, 3 if b != null else 2) - v.position = Vector2(box.position.x + 20 + j * 122, box.position.y + 56) - v.hover.connect(show_preview) - v.clicked.connect(func(_x): on_tile_click(cr)) - zoom_layer.add_child(v) - if o.board.is_empty(): - Style.label(zoom_layer, "Keine Wesen auf dem Feld", 16, Style.MUTED, Rect2(box.position.x + 20, box.position.y + 110, 600, 30)) - var hero_ok: bool = (o in valid) or (selected != null and g.can_attack(selected, o)[0]) - var bh := Style.button(zoom_layer, "Held als Ziel", Rect2(box.position.x + 20, box.end.y - 60, 180, 44), func(): on_click("hero", o), Color("5a3c28")) - bh.disabled = not hero_ok - Style.button(zoom_layer, "Schließen", Rect2(box.end.x - 180, box.end.y - 60, 160, 44), func(): - zoom_player = null - refresh()) diff --git a/scripts/ui/booster_screen.gd b/scripts/ui/booster_screen.gd index f14e975..8821351 100644 --- a/scripts/ui/booster_screen.gd +++ b/scripts/ui/booster_screen.gd @@ -12,6 +12,8 @@ var msg_label: Label var b_buy: Button var b_all: Button var slots: Control +var fx_layer: Control +var fresh := {} # gerade aufgedeckt: wird umgedreht class CardBack extends Control: @@ -47,15 +49,18 @@ func slot_rect(i: int) -> Rect2: func _ready() -> void: - Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true) - coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center") + Style.header(self, "Booster", func(): app.show_screen(MenuScreen.new())) + coins_label = Style.chip(self, "", Rect2(860, 26, 240, 38)) + fx_layer = Control.new() + fx_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(fx_layer) slots = Control.new() slots.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(slots) - msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 700, 1280, 22), "center") - b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(290, 720, 330, 48), buy, Color("5a4628")) - b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all) - Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new())) + msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 640, 1280, 22), "center") + b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(370, 690, 330, 52), buy, Color("5a4628")) + b_buy.add_theme_font_size_override("font_size", 20) + b_all = Style.button(self, "Alle aufdecken", Rect2(720, 690, 200, 52), reveal_all) refresh() @@ -81,16 +86,44 @@ func buy() -> void: func reveal(i: int) -> void: revealed[i] = true - app.sfx.play("click") + fresh = {i: 0.0} + app.sfx.play("combo" if cards[i].rarity != "gewöhnlich" else "click") refresh() func reveal_all() -> void: + fresh = {} + var k := 0 for i in Profile.BOOSTER_SIZE: + if not revealed.has(i): + fresh[i] = k * 0.12 + k += 1 revealed[i] = true + app.sfx.play("play") refresh() +## Umdrehen: die Karte wächst aus der Mitte; seltene Stufen blitzen in ihrer Farbe auf. +func flip(v: CardView, delay: float, rarity: String) -> void: + if not Style.animations: + return + v.scale.x = 0.0 + var tw := create_tween() + tw.tween_interval(delay) + tw.tween_property(v, "scale:x", 1.0, 0.18).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + if rarity == "gewöhnlich": + return + var burst := Style.panel(fx_layer, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12) + burst.pivot_offset = burst.size / 2 + burst.modulate.a = 0.0 + var bt := create_tween() + bt.tween_interval(delay + 0.1) + bt.tween_property(burst, "modulate:a", 1.0, 0.05) + bt.parallel().tween_property(burst, "scale", Vector2(1.25, 1.25), 0.45).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + bt.tween_property(burst, "modulate:a", 0.0, 0.3) + bt.tween_callback(burst.queue_free) + + func refresh() -> void: var p: Profile = app.profile coins_label.text = "Münzen: %d" % p.coins @@ -107,6 +140,8 @@ func refresh() -> void: var v := CardView.new(c, r.size, false, res.rarity) v.position = r.position slots.add_child(v) + if fresh.has(i): + flip(v, fresh[i], res.rarity) Style.label(slots, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true) if res.upgraded: Style.label(slots, "Aufgewertet", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true) @@ -117,3 +152,4 @@ func refresh() -> void: back.position = r.position back.clicked.connect(reveal.bind(i)) slots.add_child(back) + fresh = {} diff --git a/scripts/ui/campaign_screen.gd b/scripts/ui/campaign_screen.gd index 27f0c53..9736097 100644 --- a/scripts/ui/campaign_screen.gd +++ b/scripts/ui/campaign_screen.gd @@ -1,32 +1,37 @@ class_name CampaignScreen extends Control +## Kampagne als Pfad: zehn Stationen je Kapitel, darunter die gewählte Stufe im Detail. var app var chapter := 1 +var selected := 0 # gewählte Stufe (Index über alle Kapitel) var perk_box: OptionButton +var node_buttons: Array = [] func _ready() -> void: var p: Profile = app.profile chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size()) + selected = mini(p.campaign_stage, Campaign.total() - 1) build() func build() -> void: Style.clear(self) + node_buttons = [] var p: Profile = app.profile - var col := Style.column(self, 1000, 16, 5) - Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true) - var tabs := Style.row(col, 10) - tabs.alignment = BoxContainer.ALIGNMENT_CENTER + Style.header(self, "Kampagne", func(): app.show_screen(MenuScreen.new())) + var n := Campaign.STAGES.size() + var x := 40 for c in Campaign.CHAPTERS.size(): - var b := Style.cbutton(tabs, "Kapitel %d" % (c + 1), Vector2(140, 36), set_chapter.bind(c + 1), Color("285a3c") if chapter == c + 1 else null) - b.disabled = p.campaign_stage < c * Campaign.STAGES.size() + var b := Style.button(self, "Kapitel %d" % (c + 1), Rect2(x, 88, 150, 38), set_chapter.bind(c + 1), Style.PRIMARY if chapter == c + 1 else null) + b.disabled = p.campaign_stage < c * n + x += 160 if p.perk_unlocked(): - var pr := Style.row(tabs, 8) - Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110) + Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(760, 96, 130, 24), "right") perk_box = OptionButton.new() - perk_box.custom_minimum_size = Vector2(300, 36) + perk_box.position = Vector2(900, 88) + perk_box.size = Vector2(340, 38) perk_box.add_item("keins") var keys: Array = Game.PERKS.keys() for k in keys: @@ -35,54 +40,90 @@ func build() -> void: perk_box.item_selected.connect(func(i: int): p.perk = "" if i == 0 else keys[i - 1] app.save()) - pr.add_child(perk_box) - var offset := (chapter - 1) * Campaign.STAGES.size() - for k in Campaign.STAGES.size(): + add_child(perk_box) + # Pfad mit Stationen + var offset := (chapter - 1) * n + var y := 190.0 + var step := (1280 - 200) / float(n - 1) + var centers: Array[Vector2] = [] + for k in n: + centers.append(Vector2(100 + k * step, y)) + var path := Control.new() + path.mouse_filter = Control.MOUSE_FILTER_IGNORE + path.draw.connect(func(): + for k in n - 1: + var done: bool = offset + k + 1 <= p.campaign_stage + path.draw_line(centers[k], centers[k + 1], Style.GREEN if done else Style.PANEL_LIGHT, 4.0)) + add_child(path) + for k in n: var i := offset + k - var st := Campaign.stage(i) var done := i < p.campaign_stage var next := i == p.campaign_stage - var panel := PanelContainer.new() - panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6, - Style.GOLD if next else null, 2 if next else 0)) - col.add_child(panel) - var pad := MarginContainer.new() # Innenabstand, damit Name und Knopf nicht am Rahmen kleben - for side in ["left", "right"]: - pad.add_theme_constant_override("margin_" + side, 14) - panel.add_child(pad) - var r := Style.row(pad, 10) - r.custom_minimum_size = Vector2(0, 50) - var nm := Style.clabel(r, "%s · %s" % [st.name, st.title], 16, Style.TEXT if not done else Style.MUTED, "left", true, 420) - nm.size_flags_horizontal = Control.SIZE_EXPAND_FILL # schiebt die Punkte und den Knopf nach rechts - var dots := Control.new() - dots.custom_minimum_size = Vector2(60, 0) - dots.mouse_filter = Control.MOUSE_FILTER_IGNORE - var elems: Array = st.elements - dots.draw.connect(func(): - for m in elems.size(): - dots.draw_circle(Vector2(10 + m * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[m]])) - r.add_child(dots) - if done: - Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i)) - elif next: - Style.cbutton(r, "Kämpfen", Vector2(130, 36), _start.bind(i), Color("285a3c")) - else: - var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass) - b.disabled = true + var b := Button.new() + b.text = str(i + 1) + b.position = centers[k] - Vector2(24, 24) + b.size = Vector2(48, 48) + var base: Color = Style.PRIMARY if done else (Color("5a4628") if next else Style.PANEL) + var edge: Color = Style.GOLD if i == selected else (Style.GREEN if done else Style.MUTED) + b.add_theme_stylebox_override("normal", Style.box(base, 24, edge, 3 if i == selected else 2)) + b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 24, edge, 3)) + b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 24, edge, 3)) + b.add_theme_stylebox_override("disabled", Style.box(Style.darker(Style.PANEL, 0.8), 24, Style.PANEL_LIGHT, 2)) + b.add_theme_stylebox_override("focus", Style.box(Color(0, 0, 0, 0), 24, Style.GOLD, 3)) + b.disabled = i > p.campaign_stage + b.pressed.connect(func(): + selected = i + build()) + Style.juice(b) + add_child(b) + node_buttons.append(b) + if next and Style.animations: + b.pivot_offset = b.size / 2 + var tw := b.create_tween().set_loops() + tw.tween_property(b, "scale", Vector2(1.1, 1.1), 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + tw.tween_property(b, "scale", Vector2.ONE, 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + # Gewählte Stufe + var st := Campaign.stage(selected) + var done := selected < p.campaign_stage + var next := selected == p.campaign_stage + Style.panel(self, Rect2(240, 270, 800, 300)) + Style.label(self, "%s · %s" % [st.name, st.title], 28, Style.TEXT, Rect2(270, 290, 740, 40), "center", true) + var dots := Control.new() + dots.mouse_filter = Control.MOUSE_FILTER_IGNORE + var elems: Array = st.elements + dots.draw.connect(func(): + var w := elems.size() * 26 - 6 + for m in elems.size(): + dots.draw_circle(Vector2(640 - w / 2.0 + 10 + m * 26, 348), 10, Cards.ELEMENT_COLORS[elems[m]])) + add_child(dots) + Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 370, 740, 24), "center") + var perks: Array = st.perks + var py := 402 + for k in perks: + Style.label(self, "★ " + tr(Game.PERKS[k]), 15, Style.GOLD, Rect2(270, py, 740, 22), "center") + py += 24 + if done: + Style.button(self, "Wiederholen", Rect2(540, 500, 200, 50), _start.bind(selected)) + elif next: + var b := Style.button(self, "Kämpfen", Rect2(540, 500, 200, 50), _start.bind(selected), Style.PRIMARY) + b.add_theme_font_size_override("font_size", 20) + else: + var b := Style.button(self, "Gesperrt", Rect2(540, 500, 200, 50), func(): pass) + b.disabled = true if p.campaign_stage >= Campaign.total(): - Style.clabel(col, "Alle Kapitel abgeschlossen", 16, Style.GREEN, "center", true) - var br := Style.row(col) - br.alignment = BoxContainer.ALIGNMENT_CENTER - Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new())) + Style.label(self, "Alle Kapitel abgeschlossen", 16, Style.GREEN, Rect2(0, 600, 1280, 24), "center", true) func set_chapter(c: int) -> void: chapter = c + var p: Profile = app.profile + var first := (c - 1) * Campaign.STAGES.size() + selected = p.campaign_stage if Campaign.chapter(mini(p.campaign_stage, Campaign.total() - 1)) == c else first build() func _start(stage: int) -> void: - app.show_screen(BattleScreen.new(0, stage)) + app.show_screen(BattleScreen.new(stage)) func _input(event: InputEvent) -> void: diff --git a/scripts/ui/card_grid.gd b/scripts/ui/card_grid.gd index 83445d6..16dc0f3 100644 --- a/scripts/ui/card_grid.gd +++ b/scripts/ui/card_grid.gd @@ -1,13 +1,13 @@ class_name CardGrid extends ScrollContainer -## Scrollbares Kartenraster mit Beschriftung unter jeder Karte. +## Scrollbares Kartenraster. signal card_clicked(card: Cards.Card) -const COLS := 7 -const CW := 100 -const CH := 140 -const GAP := 12 +const COLS := 6 +const CW := 112 +const CH := 157 +const GAP := 14 var grid: GridContainer @@ -24,19 +24,16 @@ func _init(rect: Rect2) -> void: add_child(grid) -## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool; rarity_fn(card) -> String (Optik) -func fill(cards: Array, label_fn: Callable, dim_fn: Callable, rarity_fn: Callable = Callable()) -> void: +## cards: Karten; rarity_fn(card) -> String: die Stufe, die die Karte zeigt (Optik) +func fill(cards: Array, rarity_fn: Callable) -> void: Style.clear(grid) for c in cards: var cell := Control.new() - cell.custom_minimum_size = Vector2(CW, CH + 16) + cell.custom_minimum_size = Vector2(CW, CH) cell.mouse_filter = Control.MOUSE_FILTER_IGNORE - var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c) if rarity_fn.is_valid() else "gewöhnlich") - v.dim = dim_fn.call(c) + var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c)) v.clicked.connect(func(_view): card_clicked.emit(c)) cell.add_child(v) - var text: String = label_fn.call(c) - Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center") grid.add_child(cell) diff --git a/scripts/ui/card_view.gd b/scripts/ui/card_view.gd index 1249e73..783195c 100644 --- a/scripts/ui/card_view.gd +++ b/scripts/ui/card_view.gd @@ -1,25 +1,37 @@ class_name CardView extends Control -## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau). +## Zeichnet eine Karte (Laden, Hand, Sammlung, Booster, Vorschau). +## Aufbau wie bei Hearthstone: Kostenkristall oben links, Bild, Namensband, Schlüsselwörter als +## Symbole, Effekttext, Angriff und Leben als Kreise unten. Das Element steht im Hintergrund, +## die Seltenheit in Rahmen, Glanz und Edelstein. Sehr flache Karten (Hand) zeigen nur das Nötigste. signal clicked(view: CardView) signal hover(card: Cards.Card, entered: bool) var card: Cards.Card -var border = null # Color oder null (dann Seltenheitsfarbe) -var rarity := "gewöhnlich" # nur Optik: Rahmenfarbe der eigenen Stufe +var border = null # Color oder null (dann Rahmen der Seltenheitsstufe) +var rarity := "gewöhnlich" # nur Optik var dim := false +var bonus := 0 # Vorschau im Kampf: Angriff, den die Karte auf dem Feld dazubekäme +var tag := "" # Vorschau im Kampf: Feldkombo, die das Ausspielen auslöst +var lift := false # hebt sich beim Zeigen an (Hand und Laden) ## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick. func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "gewöhnlich") -> void: card = c rarity = rar + set_process(Style.rarity_animated(rarity)) # bewegter Glanz custom_minimum_size = sz size = sz + pivot_offset = sz / 2 mouse_filter = Control.MOUSE_FILTER_STOP - mouse_entered.connect(func(): hover.emit(card, true)) - mouse_exited.connect(func(): hover.emit(card, false)) + mouse_entered.connect(func(): + hover.emit(card, true) + _lift(true)) + mouse_exited.connect(func(): + hover.emit(card, false) + _lift(false)) if focusable: focus_mode = Control.FOCUS_ALL focus_entered.connect(func(): @@ -30,6 +42,17 @@ func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "g queue_redraw()) +func _lift(up: bool) -> void: + if not lift or not Style.animations: + return + z_index = 1 if up else 0 + create_tween().tween_property(self, "scale", Vector2(1.07, 1.07) if up else Vector2.ONE, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) + + +func _process(_delta: float) -> void: + queue_redraw() + + func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: accept_event() @@ -41,47 +64,135 @@ func _gui_input(event: InputEvent) -> void: func _draw() -> void: var rect := Rect2(Vector2.ZERO, size) - var big := size.x >= 180 - var mid := size.x >= 115 - var f_name := 18 if big else (14 if mid else 12) - var f_text := 14 if big else (11 if mid else 10) - var f_stat := 24 if big else (18 if mid else 15) + var seed := hash(card.id) + var frame: Color = border if border != null else Style.rarity_frame(rarity, seed) + if rarity != "gewöhnlich": + Style.glow(self, rect, Style.rarity_color(rarity, seed), 8, 8 if size.x >= 180 else 6) Style.fill_elements(self, rect, card.elements, 8) - var bcol: Color = border if border != null else Cards.RARITY_COLORS[rarity] - Style.border(self, rect, bcol, 3 if big else 2, 8) - # Kosten - var r := 16 if big else 12 - var cc := Vector2(r + 4, r + 4) - draw_circle(cc, r, Color("14141c")) - Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true) - # Name und Kombo - var name_left := 2 * r + 10 - var nm := Style.fit(card.title(), true, f_name, size.x - name_left - 6) - Style.text(self, nm, f_name, Style.TEXT, Vector2((name_left + size.x - 6) / 2.0, 4), "midtop", true) - if mid: - var sub: String = tr(card.combo()) if card.is_combo() else "" - Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop") - # Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest - var tsize := Style.fs(f_text) - var text_w: float = size.x - 20 - var needed: float = Style.FONT.get_multiline_string_size(card.text(), HORIZONTAL_ALIGNMENT_LEFT, text_w, tsize).y + 8 if card.text() != "" else size.y * 0.42 - var box_bottom: float = size.y - (28 if big else 22) - var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0) # unter Name und Kombo - var box_top: float = maxf(maxf(size.y * 0.30, band_top), box_bottom - maxf(needed, size.y * 0.42)) - var box := Rect2(6, box_top, size.x - 12, box_bottom - box_top) - # Bild - var band_bottom: float = box_top - 4 - if band_bottom - band_top > 14: - var gsize: float = minf(band_bottom - band_top, size.x * 0.42) - Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize) - draw_rect(box, Color(0.06, 0.06, 0.09, 0.67)) - var max_lines := maxi(1, int((box.size.y - 6) / Style.FONT.get_height(tsize))) # große Schrift: lieber kürzen als überlaufen - draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)), - card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT) - # Werte - Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true) - Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true) + if size.y < 90: + _draw_compact(rect, frame, seed) + else: + _draw_full(rect, frame, seed) if dim: draw_rect(rect, Color(0, 0, 0, 0.47)) if has_focus(): Style.border(self, rect, Style.GOLD, 4, 8) + + +## Volle Karte: Laden, Sammlung, Booster, Vorschau. +func _draw_full(rect: Rect2, frame: Color, seed: int) -> void: + var big := size.x >= 180 + var mid := size.x >= 140 + var f_name := 17 if big else (14 if mid else 11) + var f_text := 13 if big else (11 if mid else 10) + var f_stat := 20 if big else (17 if mid else 14) + var r_cost := 17.0 if big else (14.0 if mid else 12.0) + var r_stat := 18.0 if big else (15.0 if mid else 12.0) + var icon := 11.0 if big else (9.0 if mid else 7.0) + # Bild: das Elementsymbol füllt den oberen Teil + var band_top: float = size.y * 0.36 + Style.glyph(self, card, Vector2(size.x / 2, band_top * 0.56), minf(size.x * 0.62, band_top * 0.95), 0.4) + Style.rarity_sheen(self, rect, rarity, seed) + # Namensband über die volle Breite + var band_h: float = Style.fs(f_name) + 10 + var band := Rect2(0, band_top, size.x, band_h) + draw_rect(band, Color(0.05, 0.05, 0.08, 0.82)) + var nf := Style.fit_size(card.title(), true, f_name, size.x - 12) + Style.text(self, Style.fit(card.title(), true, Style.fs(nf), size.x - 12), nf, Style.TEXT, Vector2(size.x / 2, band.get_center().y), "center", true) + var y := band.end.y + 4 + # Kombo eines verschmolzenen Wesens + if card.is_combo(): + Style.text(self, tr(card.combo()), f_text, Color("e6e6e6"), Vector2(size.x / 2, y), "midtop") + y += Style.fs(f_text) + 4 + # Schlüsselwörter als Symbole, auf großen Karten mit Wort + var kws: Array[String] = [] + for k in Cards.KEYWORDS: + if k in card.keywords: + kws.append(k) + if not kws.is_empty(): + var with_words := big + var widths: Array[float] = [] + var total := 0.0 + for k in kws: + var w: float = icon * 2 + 6 + (Style.FONT.get_string_size(tr(k), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(f_text)).x + 8 if with_words else 0) + widths.append(w) + total += w + 6 + if with_words and total > size.x - 12: + with_words = false + total = kws.size() * (icon * 2 + 12) + var x := (size.x - total + 6) / 2 + for i in kws.size(): + var k := kws[i] + Style.keyword_icon(self, k, Vector2(x + icon, y + icon + 2), icon) + if with_words: + Style.text(self, tr(k), f_text, Style.TEXT, Vector2(x + icon * 2 + 6, y + icon + 2), "midleft") + x += widths[i] + 6 + else: + x += icon * 2 + 12 + y += icon * 2 + 8 + # Effekttext: so hoch wie nötig, endet über den Wertkreisen + var box := Rect2() + var fx := card.effect_text() + var box_bottom: float = size.y - r_stat * 2 - 4 + if fx != "" and box_bottom - y > Style.fs(f_text) + 6: + var tsize := Style.fs(f_text) + var heading := big + var needed: float = Style.FONT.get_multiline_string_size(fx, HORIZONTAL_ALIGNMENT_LEFT, size.x - 20, tsize).y + 8 + (tsize + 2 if heading else 0) + var box_top: float = maxf(y, y + (box_bottom - y - needed) / 2) + box = Rect2(6, box_top, size.x - 12, box_bottom - box_top) + draw_rect(box, Color(0.06, 0.06, 0.09, 0.72)) + var ty := box.position.y + 4 + if heading: + Style.text(self, tr("Beim Ausspielen"), f_text - 2, Style.MUTED, Vector2(box.position.x + 4, ty), "topleft") + ty += tsize + 2 + var max_lines := maxi(1, int((box.end.y - ty - 2) / Style.FONT.get_height(tsize))) + draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, ty + Style.FONT.get_ascent(tsize)), + fx, HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT) + # Werte springen aus den unteren Ecken, dazwischen der Edelstein + Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat) + Style.stat_circle(self, Vector2(size.x - r_stat + 2, size.y - r_stat + 2), r_stat, str(card.health), Style.CRIMSON, f_stat) + if bonus > 0: + Style.text_outlined(self, "+%d" % bonus, f_text, Style.GREEN, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3) + Style.rarity_gem(self, rarity, Vector2(size.x / 2, size.y - r_stat + 1), 8 if big else 6, seed) + Style.border(self, rect, frame, 3 if big else 2, 8) + Style.cost_crystal(self, Vector2(r_cost + 2, r_cost + 2), r_cost, card.cost, f_stat) + if tag != "": + _draw_tag(Vector2(size.x - 6, 6), f_text - 1) + Style.rarity_sparkle(self, rect, rarity, seed, box) + + +## Flache Handkachel: Kosten, Name, Symbole, Werte. +func _draw_compact(rect: Rect2, frame: Color, seed: int) -> void: + Style.rarity_sheen(self, rect, rarity, seed) + Style.border(self, rect, frame, 2, 8) + Style.cost_crystal(self, Vector2(13, 13), 11, card.cost, 13) + var name_w: float = size.x - 30 - (12 if rarity != "gewöhnlich" else 0) + var nf := Style.fit_size(card.title(), true, 12, name_w) + Style.text(self, Style.fit(card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(28, 13), "midleft", true) + Style.rarity_gem(self, rarity, Vector2(size.x - 9, 13), 5, seed) + if tag != "": + var w := Style.FONT_BOLD.get_string_size(tr(tag), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(9)).x + 12 + _draw_tag(Vector2(size.x / 2 + w / 2, size.y - 21), 9) + else: + var kws: Array[String] = [] + for k in Cards.KEYWORDS: + if k in card.keywords: + kws.append(k) + var x := size.x / 2 - (kws.size() * 18 - 4) / 2.0 + for k in kws: + Style.keyword_icon(self, k, Vector2(x + 6, size.y - 13), 6) + x += 18 + Style.stat_circle(self, Vector2(11, size.y - 11), 10, str(card.attack), Style.AMBER, 12) + Style.stat_circle(self, Vector2(size.x - 11, size.y - 11), 10, str(card.health), Style.CRIMSON, 12) + if bonus > 0: + Style.text_outlined(self, "+%d" % bonus, 10, Style.GREEN, Vector2(4, size.y - 29), "midleft", 3) + Style.rarity_sparkle(self, rect, rarity, seed) + + +## Goldenes Schild mit der Feldkombo, rechts oben. +func _draw_tag(top_right: Vector2, font: int) -> void: + var w := Style.FONT_BOLD.get_string_size(tr(tag), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(font)).x + 12 + var h := Style.fs(font) + 6 + var r := Rect2(top_right - Vector2(w, 0), Vector2(w, h)) + draw_style_box(Style.box(Color("14141c"), 6, Style.GOLD, 1), r) + Style.text(self, tr(tag), font, Style.GOLD, r.get_center(), "center", true) diff --git a/scripts/ui/collection_screen.gd b/scripts/ui/collection_screen.gd index 75d0ed8..fe1b51a 100644 --- a/scripts/ui/collection_screen.gd +++ b/scripts/ui/collection_screen.gd @@ -18,33 +18,32 @@ var b_upgrade: Button func _ready() -> void: - Style.label(self, "Sammlung", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true) - Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) + Style.header(self, "Sammlung", func(): app.show_screen(MenuScreen.new())) + Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind("")) for i in Cards.ELEMENTS.size(): var e: String = Cards.ELEMENTS[i] - var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) + var fb := Style.button(self, e, Rect2(108 + i * 82, 86, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) fb.add_theme_font_size_override("font_size", 14) search_box = LineEdit.new() - search_box.position = Vector2(424, 55) - search_box.size = Vector2(200, 34) + search_box.position = Vector2(444, 86) + search_box.size = Vector2(220, 34) search_box.placeholder_text = "Suche" search_box.text_changed.connect(func(t: String): search = t refresh()) add_child(search_box) - grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690)) + grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650)) grid.card_clicked.connect(select) add_child(grid) - Style.panel(self, Rect2(845, 50, 420, 680)) - dust_label = Style.label(self, "", 22, Style.BLUE, Rect2(860, 58, 390, 30), "left", true) + Style.panel(self, Rect2(820, 86, 420, 696)) + dust_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE) preview_holder = Control.new() preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(preview_holder) - info_label = Style.label(self, "", 14, Style.MUTED, Rect2(860, 396, 390, 66), "center") - b_upgrade = Style.button(self, "", Rect2(860, 480, 390, 44), upgrade, Color("285a3c")) - b_dismantle = Style.button(self, "", Rect2(860, 534, 390, 44), dismantle, Color("5a3c28")) - msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 600, 390, 48), "center") - Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new())) + info_label = Style.label(self, "", 15, Style.MUTED, Rect2(840, 476, 380, 30), "center") + b_upgrade = Style.button(self, "", Rect2(840, 530, 380, 46), upgrade, Style.PRIMARY) + b_dismantle = Style.button(self, "", Rect2(840, 586, 380, 46), dismantle, Color("5a3c28")) + msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(840, 650, 380, 48), "center") refresh() @@ -58,6 +57,7 @@ func set_filter(e: String) -> void: refresh() +## Aufgewertete Karten zuerst, sonst in Kartenreihenfolge. func cards() -> Array: var out: Array = Cards.all().duplicate() if filter != "": @@ -65,6 +65,11 @@ func cards() -> Array: if search != "": var q := search.to_lower() out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower()) + var p: Profile = app.profile + var order := {} + for i in out.size(): + order[out[i].id] = i + out.sort_custom(func(a, b): return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id]) return out @@ -109,7 +114,7 @@ func dismantle() -> void: func refresh() -> void: var p: Profile = app.profile dust_label.text = "Staub: %d" % p.dust - grid.fill(cards(), func(c): return tr(p.rarity(c.id)), func(_c): return false, func(c): return p.rarity(c.id)) + grid.fill(cards(), func(c): return p.rarity(c.id)) Style.clear(preview_holder) if selected == null: info_label.text = "Keine Karte gewählt" @@ -118,8 +123,8 @@ func refresh() -> void: b_upgrade.disabled = true b_dismantle.disabled = true return - var v := CardView.new(selected, Vector2(200, 280), false, p.rarity(selected.id)) - v.position = Vector2(955, 100) + var v := CardView.new(selected, Vector2(220, 308), false, p.rarity(selected.id)) + v.position = Vector2(920, 152) v.mouse_filter = Control.MOUSE_FILTER_IGNORE preview_holder.add_child(v) var lvl := p.level(selected.id) diff --git a/scripts/ui/creature_view.gd b/scripts/ui/creature_view.gd index c3201f0..8e942be 100644 --- a/scripts/ui/creature_view.gd +++ b/scripts/ui/creature_view.gd @@ -11,6 +11,8 @@ var width := 2 var flash := false # kurz rot: wurde gerade angegriffen var slot: Vector2 # Zielposition im Raster var busy := false # wird gerade animiert (kein Neupositionieren) +var mark := "" # Feldkombo, die dieses Wesen als Partner auslösen würde +var matchup := 0 # Elementvorteil des gewählten Angreifers gegen dieses Wesen (+1/-1) func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void: @@ -20,58 +22,93 @@ func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void: custom_minimum_size = sz size = sz pivot_offset = sz / 2 + set_process(Style.rarity_animated(rarity())) # bewegter Glanz mouse_filter = Control.MOUSE_FILTER_STOP mouse_entered.connect(func(): hover.emit(creature.card, true)) mouse_exited.connect(func(): hover.emit(creature.card, false)) +func _process(_delta: float) -> void: + queue_redraw() + + func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: accept_event() clicked.emit(self) +func rarity() -> String: + var owner = creature.owner() + return Cards.RARITIES[int(owner.looks.get(creature.card.id, 0)) if owner != null else 0] + + func _draw() -> void: var rect := Rect2(Vector2.ZERO, size) var small := size.x < 100 var mini := size.x < 60 + var rar := rarity() + var seed := hash(creature.card.id) + creature.uid + if rar != "gewöhnlich": + Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 4 if mini else 6) Style.fill_elements(self, rect, creature.card.elements, 6) - var owner = creature.owner() - var lvl: int = int(owner.looks.get(creature.card.id, 0)) if owner != null else 0 - var bcol: Color = border if border != null else Cards.RARITY_COLORS[Cards.RARITIES[lvl]] + if not mini: + Style.glyph(self, creature.card, Vector2(size.x / 2, size.y * 0.5), size.x * 0.75, 0.3) + Style.rarity_sheen(self, rect, rar, seed) + var bcol: Color = border if border != null else Style.rarity_frame(rar, seed) if flash: bcol = Style.RED Style.border(self, rect, bcol, 4 if flash else width, 6) + if mark != "": + Style.border(self, rect.grow(3), Style.GOLD, 3, 8) if mini: _draw_mini() + Style.rarity_sparkle(self, rect, rar, seed) return var fs := 11 if small else 13 - var nm := Style.fit(creature.card.title(), true, fs, size.x - 6) - Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true) - var badges: Array[String] = [] - if creature.taunt(): - badges.append("Wächter") - if creature.shield: - badges.append("Schild") - if creature.attacks_per_turn() == 2: - badges.append("x2") - if creature.frozen: - badges.append("Eis") - if creature.resonance > 0: - badges.append("Resonanz") - if creature.field_bonus > 0: - badges.append("Feld") - var y := 18.0 if small else 22.0 - for b in badges: - Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop") - y += 12 if small else 15 - var f := 16 if small else 20 - var atk_col := Style.GREEN if creature.resonance + creature.field_bonus > 0 else Style.YELLOW - Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true) - var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8") - Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true) + var name_w: float = size.x - (30 if matchup != 0 else 6) # Platz für den Vorteilskreis oben rechts + var nf := Style.fit_size(creature.card.title(), true, fs, name_w) + draw_rect(Rect2(2, 2, size.x - 4, Style.fs(fs) + 8), Color(0.05, 0.05, 0.08, 0.7)) + Style.text(self, Style.fit(creature.card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(3 + name_w / 2, 6), "midtop", true) + # Schlüsselwörter als Symbole + var icon := 7.0 if small else 9.0 + var kws: Array[String] = [] + for k in Cards.KEYWORDS: + if k in creature.card.keywords: + kws.append(k) + if creature.shield and not ("Schild" in kws): + kws.append("Schild") + var x := size.x / 2 - (kws.size() * (icon * 2 + 6) - 6) / 2.0 + var y := Style.fs(fs) + 14 + icon + for k in kws: + Style.keyword_icon(self, k, Vector2(x + icon, y), icon) + x += icon * 2 + 6 + _draw_stats(15 if small else 17, 10 if small else 12, 12.0 if small else 14.0) + Style.rarity_gem(self, rar, Vector2(size.x / 2, size.y - 9), 5, seed) + if mark != "": + var w := Style.FONT_BOLD.get_string_size(tr(mark), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(11)).x + 14 + draw_style_box(Style.box(Color("14141c"), 6, Style.GOLD, 1), Rect2(size.x / 2 - w / 2, -9, w, 18)) + Style.text(self, tr(mark), 11, Style.GOLD, Vector2(size.x / 2, 0), "center", true) if creature.frozen: draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35)) + Style.text_outlined(self, "❄", 22 if small else 28, Color("dff2ff"), size / 2, "center", 3) + Style.rarity_sparkle(self, rect, rar, seed) + + +## Angriff (grün mit „+n“, wenn Resonanz, Feld oder Merkmale ihn heben), Leben, Elementvorteil. +func _draw_stats(f: int, f_small: int, r: float) -> void: + var extra := creature.power() - creature.attack + var atk_col := Style.GREEN if extra > 0 else Style.AMBER + Style.stat_circle(self, Vector2(r - 1, size.y - r + 1), r, str(creature.power()), atk_col, f) + if extra > 0: + Style.text_outlined(self, "+%d" % extra, f_small, Style.GREEN, Vector2(r * 2 + 3, size.y - r + 1), "midleft", 3) + var hp_col := Style.CRIMSON if creature.health < creature.max_health else Color("b84a4a") + Style.stat_circle(self, Vector2(size.x - r + 1, size.y - r + 1), r, str(creature.health), hp_col, f) + if matchup != 0: + var c := Vector2(size.x - 12, 12) + draw_circle(c, 11, Color("14141c")) + Style.text(self, ("+%d" % matchup) if matchup > 0 else ("−%d" % -matchup), f_small + 1, + Style.GREEN if matchup > 0 else Style.RED, c, "center", true) ## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe. @@ -84,15 +121,14 @@ func _draw_mini() -> void: if creature.attacks_per_turn() == 2: letters.append("2") if creature.frozen: - letters.append("E") - if creature.resonance > 0: - letters.append("R") - if creature.field_bonus > 0: - letters.append("F") + letters.append("❄") Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true) - var atk_col := Style.GREEN if creature.resonance + creature.field_bonus > 0 else Style.YELLOW - Style.text(self, str(creature.power()), 13, atk_col, Vector2(4, size.y - 2), "bottomleft", true) + var extra := creature.power() - creature.attack + Style.text_outlined(self, str(creature.power()), 13, Style.GREEN if extra > 0 else Style.YELLOW, Vector2(9, size.y - 9), "center", 3) var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8") - Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true) + Style.text_outlined(self, str(creature.health), 13, hp_col, Vector2(size.x - 9, size.y - 9), "center", 3) + if matchup != 0: + Style.text(self, ("+%d" % matchup) if matchup > 0 else ("−%d" % -matchup), 10, + Style.GREEN if matchup > 0 else Style.RED, Vector2(size.x - 3, 2), "topright", true) if creature.frozen: draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35)) diff --git a/scripts/ui/field_view.gd b/scripts/ui/field_view.gd new file mode 100644 index 0000000..ba7e99c --- /dev/null +++ b/scripts/ui/field_view.gd @@ -0,0 +1,53 @@ +class_name FieldView +extends Control +## Das gemeinsame Feld: je Element vier Ladepunkte, bei vollen Punkten kippt das Feld. +## Das aktive Feld steht farbig mit seinem Bonus da. + +var element := "" +var charge := {} + + +func _init(rect: Rect2) -> void: + position = rect.position + size = rect.size + mouse_filter = Control.MOUSE_FILTER_IGNORE + + +func update_from(g: Game) -> void: + element = g.field_element + charge = g.field_charge.duplicate() + queue_redraw() + + +## Der Inhalt als Text (Tests, Protokoll). +func text() -> String: + var parts: Array[String] = [] + for e in Cards.ELEMENTS: + if e == element: + parts.append("%s: +1 %s" % [tr(Cards.FIELD_NAMES[e]), tr("Angriff")]) + else: + parts.append("%s %d/%d" % [tr(e), int(charge.get(e, 0)), Cards.FIELD_CHARGE]) + return " · ".join(parts) + + +func _draw() -> void: + var w := size.x / Cards.ELEMENTS.size() + for i in Cards.ELEMENTS.size(): + var e: String = Cards.ELEMENTS[i] + var col: Color = Cards.ELEMENT_COLORS[e] + var x0 := i * w + var cy := size.y / 2 + if e == element: + draw_style_box(Style.box(Color(col, 0.35), 6, col, 2), Rect2(x0 + 2, 0, w - 4, size.y)) + Style.text(self, "%s +1 %s" % [tr(Cards.FIELD_NAMES[e]), tr("Angriff")], 13, Style.lighter(col, 0.4), Vector2(x0 + w / 2, cy), "center", true) + continue + Style.text(self, tr(e), 13, Style.MUTED, Vector2(x0 + 12, cy), "midleft") + var n := int(charge.get(e, 0)) + var px := x0 + 12 + Style.FONT.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 14 + for k in Cards.FIELD_CHARGE: + var c := Vector2(px + k * 13, cy) + if k < n: + draw_circle(c, 4.5, col) + else: + draw_circle(c, 4.5, Color(0, 0, 0, 0.35)) + draw_arc(c, 4.5, 0, TAU, 16, Color(col, 0.6), 1.0) diff --git a/scripts/ui/field_view.gd.uid b/scripts/ui/field_view.gd.uid new file mode 100644 index 0000000..f8b2e63 --- /dev/null +++ b/scripts/ui/field_view.gd.uid @@ -0,0 +1 @@ +uid://i4rdq5rmcyuk diff --git a/scripts/ui/lobby_screen.gd b/scripts/ui/lobby_screen.gd index 4ebbd95..40f9c20 100644 --- a/scripts/ui/lobby_screen.gd +++ b/scripts/ui/lobby_screen.gd @@ -11,8 +11,6 @@ var b_host: Button var b_join: Button var b_watch: Button var b_start: Button -var ai_buttons: Array = [] -var n_ai := 0 var addr_label: Label @@ -20,9 +18,8 @@ func _ready() -> void: net = app.net net.my_name = "Spieler" net.my_pick = {"looks": app.profile.looks()} - var col := Style.column(self, 760, 40, 10) - Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true) - Style.spacer(col, 10) + Style.header(self, "Netzwerkspiel", _back) + var col := Style.column(self, 760, 110, 10) var hr := Style.row(col, 12) hr.alignment = BoxContainer.ALIGNMENT_CENTER b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c")) @@ -38,17 +35,10 @@ func _ready() -> void: Style.spacer(col, 6) Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true) list_label = Style.clabel(col, "", 16, Style.TEXT) - Style.spacer(col, 6) - Style.clabel(col, "KI-Gegner", 16, Style.MUTED) - var ar := Style.row(col, 10) - ar.alignment = BoxContainer.ALIGNMENT_CENTER - for n in range(0, 5): - ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 40), func(): _set_ai(n))) Style.spacer(col, 10) var br := Style.row(col, 20) br.alignment = BoxContainer.ALIGNMENT_CENTER b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c")) - Style.cbutton(br, "Zurück", Vector2(160, 48), _back) net.lobby_changed.connect(refresh) net.started.connect(_started) net.ended.connect(func(_reason): refresh()) @@ -79,13 +69,8 @@ func _watch() -> void: refresh() -func _set_ai(n: int) -> void: - n_ai = n - refresh() - - func _start() -> void: - net.start_battle(n_ai) + net.start_battle() func _started(seat: int) -> void: @@ -112,9 +97,8 @@ func refresh() -> void: b_watch.disabled = net.connected ip_box.editable = not net.connected var humans := ids.size() - b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1) - for i in ai_buttons.size(): - ai_buttons[i].disabled = not net.is_host or i == n_ai or humans + i > Game.MAX_OPPONENTS + 1 + b_start.disabled = not net.is_host + b_start.text = tr("Start") if humans >= 2 else tr("Start gegen die KI") addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else "" diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 9f08984..a363b7e 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -32,13 +32,13 @@ func apply_settings() -> void: sfx.set_music_volume(profile.settings.music) set_language(profile.settings.language) Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0 + Style.animations = bool(profile.settings.get("animations", true)) theme = Style.make_theme() ## Neues Profil: Sammlung und Fortschritt werden verworfen. func reset_profile() -> void: profile = Profile.starter() - clear_battle_save() save() apply_settings() @@ -54,6 +54,9 @@ func show_screen(s: Control) -> void: s.app = self s.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(s) + if Style.animations: + s.modulate.a = 0.0 + create_tween().tween_property(s, "modulate:a", 1.0, 0.15) if pad_mode: focus_first_button.call_deferred() @@ -107,16 +110,6 @@ func save() -> void: profile.save(save_path) -## Datei für einen unterbrochenen Kampf (neben dem Spielstand). -func battle_save_path() -> String: - return save_path.get_basename() + "_kampf.json" - - -func clear_battle_save() -> void: - if FileAccess.file_exists(battle_save_path()): - DirAccess.remove_absolute(ProjectSettings.globalize_path(battle_save_path())) - - func _exit_tree() -> void: if net != null and net.is_inside_tree(): net.leave() diff --git a/scripts/ui/menu_screen.gd b/scripts/ui/menu_screen.gd index e61f339..3ae45e7 100644 --- a/scripts/ui/menu_screen.gd +++ b/scripts/ui/menu_screen.gd @@ -1,53 +1,55 @@ class_name MenuScreen extends Control +## Hauptmenü: links Titel und Spielarten, rechts drei Karten der eigenen Sammlung als Schaufenster. var app -var b_resume: Button var coins_label: Label var coll_label: Label var notice_label: Label func _ready() -> void: - var col := Style.column(self, 640, 40, 5) - Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true) - Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED) - Style.spacer(col, 8) - coins_label = Style.clabel(col, "", 20, Style.GOLD) - coll_label = Style.clabel(col, "", 16, Style.MUTED) - Style.spacer(col, 8) - var grid := GridContainer.new() - grid.columns = 2 - grid.add_theme_constant_override("h_separation", 12) - grid.add_theme_constant_override("v_separation", 8) - var center := CenterContainer.new() - center.add_child(grid) - col.add_child(center) - var sz := Vector2(270, 44) - if FileAccess.file_exists(app.battle_save_path()): - b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628")) - Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c")) - Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c")) - Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c")) - Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new())) - Style.cbutton(grid, "Sammlung", sz, func(): app.show_screen(CollectionScreen.new())) - Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new())) - Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit()) - Style.spacer(col, 10) - notice_label = Style.clabel(col, "", 16, Style.GREEN) + var p: Profile = app.profile + Style.label(self, "Dinger", 76, Style.GOLD, Rect2(80, 64, 600, 90), "left", true) + Style.label(self, "Ein Kartenspiel: Duell um das Feld", 22, Style.MUTED, Rect2(84, 156, 600, 30), "left") + coins_label = Style.chip(self, "", Rect2(930, 20, 310, 38)) + coll_label = Style.label(self, "", 15, Style.MUTED, Rect2(700, 64, 540, 24), "right") + var y := 230 + for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())], + ["Kampagne", func(): app.show_screen(CampaignScreen.new())], + ["Netzwerkspiel", func(): app.show_screen(LobbyScreen.new())]]: + var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY) + b.add_theme_font_size_override("font_size", 24) + y += 74 + y += 18 + var x := 80 + for entry in [["Booster öffnen", func(): app.show_screen(BoosterScreen.new())], + ["Sammlung", func(): app.show_screen(CollectionScreen.new())], + ["Einstellungen", func(): app.show_screen(SettingsScreen.new())], + ["Beenden", func(): get_tree().quit()]]: + Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1]) + x += 176 + notice_label = Style.label(self, "", 16, Style.GREEN, Rect2(80, y + 70, 760, 60), "left") notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD + _showcase(p) refresh() -## Unterbrochenen Kampf laden; eine unlesbare Datei wird verworfen. -func _resume() -> void: - var json := JSON.new() - var text := FileAccess.get_file_as_string(app.battle_save_path()) - if json.parse(text) != OK or not (json.data is Dictionary) or not json.data.has("game"): - app.clear_battle_save() - app.show_screen(MenuScreen.new()) - return - app.show_screen(BattleScreen.resume(json.data)) +## Drei Karten der Sammlung, die höchsten Stufen zuerst, leicht gefächert. +func _showcase(p: Profile) -> void: + var cards: Array = Cards.all().duplicate() + cards.sort_custom(func(a, b): + if p.level(a.id) != p.level(b.id): + return p.level(a.id) > p.level(b.id) + return hash(a.id) % 97 < hash(b.id) % 97) + var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]] + for i in 3: + var c: Cards.Card = cards[i] + var v := CardView.new(c, Vector2(180, 252), false, p.rarity(c.id)) + v.position = spots[i][0] + v.rotation = spots[i][1] + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(v) func refresh() -> void: diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd index b464963..5368292 100644 --- a/scripts/ui/opponent_select_screen.gd +++ b/scripts/ui/opponent_select_screen.gd @@ -1,79 +1,54 @@ class_name OpponentSelectScreen extends Control -## Einzelspieler: Schwierigkeit und Gegnerzahl wählen. Die Karten kommen im Kampf +## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf ## aus dem Laden (siehe DeckGen.shop_cards). var app var diff_buttons := {} -var count_slider: HSlider -var count_label: Label var info_label: Label func _ready() -> void: - var col := Style.column(self, 1000, 50, 12) - Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true) - Style.spacer(col, 8) + Style.header(self, "Einzelspieler", func(): app.show_screen(MenuScreen.new())) + var col := Style.column(self, 1000, 170, 12) Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true) var dr := Style.row(col, 10) dr.alignment = BoxContainer.ALIGNMENT_CENTER for key in Profile.DIFFICULTIES: var k: String = key diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k)) - Style.spacer(col, 20) - count_label = Style.clabel(col, "", 20, Style.TEXT, "center", true) - var sr := Style.row(col, 12) - sr.alignment = BoxContainer.ALIGNMENT_CENTER - Style.clabel(sr, "1", 15, Style.MUTED, "right", false, 20) - count_slider = HSlider.new() - count_slider.min_value = 1 - count_slider.max_value = Game.MAX_OPPONENTS - count_slider.step = 1 - count_slider.value = 1 - count_slider.tick_count = Game.MAX_OPPONENTS - count_slider.ticks_on_borders = true - count_slider.custom_minimum_size = Vector2(420, 32) - count_slider.value_changed.connect(func(_v): refresh()) - sr.add_child(count_slider) - Style.clabel(sr, str(Game.MAX_OPPONENTS), 15, Style.MUTED, "left", false, 20) info_label = Style.clabel(col, "", 15, Style.MUTED) Style.spacer(col, 24) var br := Style.row(col, 16) br.alignment = BoxContainer.ALIGNMENT_CENTER - Style.cbutton(br, "Starten", Vector2(190, 46), start, Color("285a3c")) - Style.cbutton(br, "Zurück", Vector2(150, 46), func(): app.show_screen(MenuScreen.new())) + var b := Style.cbutton(br, "Starten", Vector2(260, 56), start, Style.PRIMARY) + b.add_theme_font_size_override("font_size", 22) refresh() +func _input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + app.show_screen(MenuScreen.new()) + + func set_difficulty(key: String) -> void: app.profile.settings.difficulty = key app.save() refresh() -func opponents() -> int: - return int(count_slider.value) - - func start() -> void: - app.show_screen(BattleScreen.new(opponents())) + app.show_screen(BattleScreen.new()) func refresh() -> void: var p: Profile = app.profile for key in diff_buttons: _mark(diff_buttons[key], key == p.settings.difficulty) - var n := opponents() - count_label.text = tr("1 Gegner") if n == 1 else tr("%d Gegner") % n - info_label.text = (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n] + var d := p.difficulty() + info_label.text = tr("Gegner mit %d Leben · %d Münzen bei Sieg") % [int(d.life), int(round(Profile.REWARD_WIN * float(d.reward)))] func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void: var base: Color = col if active else Style.PANEL_LIGHT b.add_theme_stylebox_override("normal", Style.box(base, 8, Style.GOLD if active else Style.MUTED, 2)) - b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 8, Style.GOLD if active else Style.MUTED, 2)) - - -func _input(event: InputEvent) -> void: - if event.is_action_pressed("ui_cancel"): - app.show_screen(MenuScreen.new()) diff --git a/scripts/ui/settings_screen.gd b/scripts/ui/settings_screen.gd index 45e59e5..341a20f 100644 --- a/scripts/ui/settings_screen.gd +++ b/scripts/ui/settings_screen.gd @@ -16,9 +16,8 @@ var speed_label: Label func _ready() -> void: var s: Dictionary = app.profile.settings - var col := Style.column(self, 700, 50, 24) - Style.clabel(col, "Einstellungen", 36, Style.GOLD, "center", true) - Style.spacer(col, 30) + Style.header(self, "Einstellungen", func(): app.show_screen(MenuScreen.new())) + var col := Style.column(self, 700, 110, 24) var grid := GridContainer.new() grid.columns = 3 grid.add_theme_constant_override("h_separation", 24) @@ -80,7 +79,6 @@ func _ready() -> void: var r := Style.row(col, 16) r.alignment = BoxContainer.ALIGNMENT_CENTER Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828")) - Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.", "Ja, zurücksetzen", _reset, "Abbrechen", Style.RED) _labels() @@ -135,6 +133,7 @@ func _on_speed(v: float) -> void: func _on_anim(on: bool) -> void: app.profile.settings.animations = on + Style.animations = on app.save() diff --git a/scripts/ui/sfx.gd b/scripts/ui/sfx.gd index 0a17cc2..8004e63 100644 --- a/scripts/ui/sfx.gd +++ b/scripts/ui/sfx.gd @@ -18,6 +18,8 @@ func _ready() -> void: streams["damage"] = _tone(140, 0.12, "square") streams["heal"] = _tone(523, 0.22, "sine", 784) streams["combo"] = _tone(660, 0.2, "sine", 1100) + streams["buy"] = _tone(1200, 0.08, "sine", 1700) + streams["turn"] = _tone(520, 0.3, "sine", 1040) streams["win"] = _tone(400, 0.6, "sine", 1200) streams["lose"] = _tone(320, 0.7, "square", 90) for i in 4: diff --git a/scripts/ui/style.gd b/scripts/ui/style.gd index 9c92c02..26e6180 100644 --- a/scripts/ui/style.gd +++ b/scripts/ui/style.gd @@ -4,6 +4,7 @@ class_name Style const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf") const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf") static var font_scale := 1.0 # Einstellung „Große Schrift“ +static var animations := true # Einstellung „Animationen“: der Glanz seltener Karten bewegt sich const BG := Color("1a1c24") const PANEL := Color("282b36") @@ -17,6 +18,11 @@ const BLUE := Color("6eaaf0") const YELLOW := Color("ffe65a") const ORANGE := Color("ff963c") const PURPLE := Color("8c78dc") +const AMBER := Color("f0b429") # Angriff +const CRIMSON := Color("d64545") # Leben +const MANA := Color("3a78d2") # Kostenkristall +const PRIMARY := Color("285a3c") # Hauptaktion +const DANGER := Color("5a2828") ## Schriftgröße mit der Einstellung „Große Schrift“. @@ -80,7 +86,11 @@ static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8, var p := Panel.new() p.position = rect.position p.size = rect.size - p.add_theme_stylebox_override("panel", box(bg, radius, border_col, border_w)) + var sb := box(bg, radius, border_col, border_w) + sb.shadow_color = Color(0, 0, 0, 0.35) + sb.shadow_size = 6 + sb.shadow_offset = Vector2(0, 3) + p.add_theme_stylebox_override("panel", sb) p.mouse_filter = Control.MOUSE_FILTER_IGNORE parent.add_child(p) return p @@ -115,10 +125,35 @@ static func button(parent: Node, text: String, rect: Rect2, on_pressed: Callable b.add_theme_stylebox_override("normal", box(color, 8, MUTED, 2)) b.add_theme_stylebox_override("hover", box(lighter(color), 8, MUTED, 2)) b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2)) + juice(b) parent.add_child(b) return b +## Knöpfe federn beim Drücken kurz ein. +static func juice(b: Button) -> void: + b.button_down.connect(func(): + if animations: + b.pivot_offset = b.size / 2 + b.create_tween().tween_property(b, "scale", Vector2(0.96, 0.96), 0.05)) + b.button_up.connect(func(): + if animations: + b.pivot_offset = b.size / 2 + b.create_tween().tween_property(b, "scale", Vector2.ONE, 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)) + + +## Kopfzeile eines Unterbildschirms: Titel links, Zurück rechts oben. +static func header(parent: Node, title: String, on_back: Callable) -> Button: + label(parent, title, 34, GOLD, Rect2(40, 22, 700, 46), "left", true) + return button(parent, "Zurück", Rect2(1120, 26, 120, 40), on_back) + + +## Kleine Kachel mit Text, etwa für Münzen und Staub. +static func chip(parent: Node, text: String, rect: Rect2, color: Color = GOLD) -> Label: + panel(parent, rect, darker(PANEL, 0.9), rect.size.y / 2, PANEL_LIGHT, 1) + return label(parent, text, 17, color, rect, "center", true) + + ## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme). static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer: var v := VBoxContainer.new() @@ -230,6 +265,33 @@ static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vec ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color) +## Text mit dunklem Rand: bleibt auf jeder Farbe lesbar (Werte, Kosten). +static func text_outlined(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "center", outline: int = 4) -> void: + var size := fs(size_in) + var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size) + var x := pos.x + var y := pos.y + match anchor: + "center": + x -= dim.x / 2 + y -= dim.y / 2 + "midtop": + x -= dim.x / 2 + "midleft": + y -= dim.y / 2 + var at := Vector2(x, y + FONT_BOLD.get_ascent(size)) + ci.draw_string_outline(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, outline, Color(0.05, 0.05, 0.08, 0.95)) + ci.draw_string(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color) + + +## Schriftgröße, mit der ein Text in eine Breite passt (bis zur Mindestgröße). +static func fit_size(s: String, bold: bool, size: int, max_w: float, min_size: int = 9) -> int: + var font: Font = FONT_BOLD if bold else FONT + while size > min_size and font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, fs(size)).x > max_w: + size -= 1 + return size + + static func fit(s: String, bold: bool, size: int, max_w: float) -> String: var font: Font = FONT_BOLD if bold else FONT while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3: @@ -246,6 +308,88 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius ci.draw_style_box(sb, rect) +# --- Seltenheit als Aussehen ------------------------------------------------------ + +## Ab episch bewegt sich der Glanz; dann zeichnet die Karte jeden Frame neu. +static func rarity_animated(rar: String) -> bool: + return animations and Cards.RARITIES.find(rar) >= 2 + + +## Farbe einer Stufe; mythisch schillert über die Zeit durch alle Farben. +static func rarity_color(rar: String, seed: int = 0) -> Color: + if rar == "mythisch" and animations: + return Color.from_hsv(fmod(Time.get_ticks_msec() / 1000.0 * 0.12 + (seed % 97) / 97.0, 1.0), 0.55, 1.0) + return Cards.RARITY_COLORS[rar] + + +## Rahmenfarbe einer Stufe: gewöhnlich bleibt schlicht, alles darüber trägt seine Farbe. +static func rarity_frame(rar: String, seed: int = 0) -> Color: + return Color("3a3e4c") if rar == "gewöhnlich" else rarity_color(rar, seed) + + +## Lichtstreifen wie auf einer Folienkarte: selten steht er, ab episch wandert er über die Karte. +static func rarity_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> void: + var lvl := Cards.RARITIES.find(rar) + if lvl < 1: + return + var phase := (seed % 100) / 100.0 + if lvl >= 2 and animations: + phase = fmod(Time.get_ticks_msec() / 2500.0 + phase, 1.0) + var frame := PackedVector2Array([rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]) + var w := rect.size.x * 0.34 + var k := rect.size.y * 0.75 # Schräge + var x0 := rect.position.x - w + (rect.size.x + w + k) * phase + for layer in [[1.0, 0.07], [0.55, 0.09], [0.22, 0.13]]: + var lw: float = w * layer[0] + var lx := x0 + (w - lw) / 2 + var band := PackedVector2Array([Vector2(lx, rect.position.y), Vector2(lx + lw, rect.position.y), Vector2(lx + lw - k, rect.end.y), Vector2(lx - k, rect.end.y)]) + for poly in Geometry2D.intersect_polygons(band, frame): + ci.draw_colored_polygon(poly, Color(1, 1, 1, layer[1])) + + +## Funkeln ab legendär: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen. +## avoid: Bereich, der frei bleibt (der Kartentext). +static func rarity_sparkle(ci: CanvasItem, rect: Rect2, rar: String, seed: int, avoid: Rect2 = Rect2()) -> void: + var lvl := Cards.RARITIES.find(rar) + if lvl < 3: + return + var t := Time.get_ticks_msec() / 1000.0 + var n := 5 if lvl == 3 else 9 + for i in n: + var h := absi(hash(Vector2i(seed, i))) + var p := rect.position + Vector2(8 + (h % 1000) / 1000.0 * (rect.size.x - 16), 8 + ((h / 1000) % 1000) / 1000.0 * (rect.size.y - 16)) + var tw := 0.5 + 0.5 * sin(t * 2.5 + i * 1.9 + seed % 7) if animations else 0.35 + 0.65 * ((i * 7 + seed) % 5) / 4.0 + if tw < 0.15 or avoid.has_point(p): + continue + var s := 2.0 + 4.0 * tw + var col := Color(1, 1, 1, 0.3 + 0.65 * tw) + ci.draw_line(p - Vector2(s, 0), p + Vector2(s, 0), col, 1.4) + ci.draw_line(p - Vector2(0, s), p + Vector2(0, s), col, 1.4) + ci.draw_circle(p, 1.3, col) + + +## Weicher Schein um eine Karte höherer Stufe; vor dem Hintergrund zeichnen. +static func glow(ci: CanvasItem, rect: Rect2, color: Color, radius: int = 8, spread: int = 8) -> void: + var sb := StyleBoxFlat.new() + sb.bg_color = Color(0, 0, 0, 0) + sb.set_corner_radius_all(radius) + sb.shadow_color = Color(color, 0.6) + sb.shadow_size = spread + ci.draw_style_box(sb, rect) + + +## Edelstein in der Farbe der Stufe (ab selten). +static func rarity_gem(ci: CanvasItem, rar: String, c: Vector2, s: float, seed: int = 0) -> void: + if rar == "gewöhnlich": + return + var col := rarity_color(rar, seed) + var diamond := func(h: float, k: Color) -> void: + ci.draw_colored_polygon(PackedVector2Array([c + Vector2(0, -h), c + Vector2(h * 0.72, 0), c + Vector2(0, h), c + Vector2(-h * 0.72, 0)]), k) + diamond.call(s + 2, Color("14141c")) + diamond.call(s, col) + ci.draw_colored_polygon(PackedVector2Array([c + Vector2(0, -s * 0.7), c + Vector2(s * 0.3, -s * 0.15), c + Vector2(-s * 0.35, -s * 0.1)]), Color(1, 1, 1, 0.55)) + + ## Hintergrund in Elementfarbe; ein verschmolzenes Wesen zeigt seine Elemente als Streifen. static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void: if elements.size() == 1: @@ -265,12 +409,63 @@ static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: ci.draw_style_box(sb, Rect2(rect.position + Vector2(floorf(i * w), 0), Vector2(ceilf(w), rect.size.y))) +# --- Kartenteile ------------------------------------------------------ + +## Kostenkristall: sechseckig, blau, Zahl in der Mitte. +static func cost_crystal(ci: CanvasItem, c: Vector2, r: float, cost: int, font: int) -> void: + var pts := PackedVector2Array() + var outer := PackedVector2Array() + for i in 6: + var a := -PI / 2 + i * PI / 3 + pts.append(c + Vector2(cos(a), sin(a)) * r) + outer.append(c + Vector2(cos(a), sin(a)) * (r + 2)) + ci.draw_colored_polygon(outer, Color("14141c")) + ci.draw_colored_polygon(pts, MANA) + var shine := PackedVector2Array([pts[5], pts[0], c + Vector2(0, -r * 0.1), c + Vector2(-r * 0.55, r * 0.15)]) + ci.draw_colored_polygon(shine, Color(1, 1, 1, 0.22)) + text_outlined(ci, str(cost), font, Color.WHITE, c, "center", 3) + + +## Wertkreis (Angriff gelb, Leben rot) mit Zahl. +static func stat_circle(ci: CanvasItem, c: Vector2, r: float, value: String, color: Color, font: int) -> void: + ci.draw_circle(c, r + 2, Color("14141c")) + ci.draw_circle(c, r, color) + ci.draw_circle(c + Vector2(-r * 0.25, -r * 0.3), r * 0.45, Color(1, 1, 1, 0.16)) + text_outlined(ci, value, font, Color.WHITE, c, "center", 3) + + +## Schlüsselwort als Symbol auf dunklem Kreis: Wächter Mauer, Schild Schild, Flink Doppelpfeil, +## Doppelschlag gekreuzte Klingen. +static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: Color = Color("f2f2f6")) -> void: + ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.7)) + var w := maxf(1.5, r * 0.22) + match kw: + "Wächter": + ci.draw_rect(Rect2(c + Vector2(-r * 0.75, -r * 0.15), Vector2(r * 1.5, r * 0.85)), col) + for i in 3: + ci.draw_rect(Rect2(c + Vector2(-r * 0.75 + i * r * 0.6, -r * 0.6), Vector2(r * 0.3, r * 0.5)), col) + "Schild": + var pts := PackedVector2Array([c + Vector2(-r * 0.7, -r * 0.65), c + Vector2(r * 0.7, -r * 0.65), c + Vector2(r * 0.7, r * 0.05), + c + Vector2(0, r * 0.8), c + Vector2(-r * 0.7, r * 0.05)]) + ci.draw_colored_polygon(pts, col) + ci.draw_line(c + Vector2(0, -r * 0.55), c + Vector2(0, r * 0.6), Color(0.05, 0.05, 0.08, 0.6), w) + "Flink": + for i in 2: + var x := c.x - r * 0.55 + i * r * 0.55 + ci.draw_polyline(PackedVector2Array([Vector2(x, c.y - r * 0.6), Vector2(x + r * 0.5, c.y), Vector2(x, c.y + r * 0.6)]), col, w) + "Doppelschlag": + ci.draw_line(c + Vector2(-r * 0.65, r * 0.65), c + Vector2(r * 0.6, -r * 0.6), col, w) + ci.draw_line(c + Vector2(r * 0.65, r * 0.65), c + Vector2(-r * 0.6, -r * 0.6), col, w) + ci.draw_line(c + Vector2(-r * 0.7, r * 0.35), c + Vector2(-r * 0.35, r * 0.7), col, w) + ci.draw_line(c + Vector2(r * 0.7, r * 0.35), c + Vector2(r * 0.35, r * 0.7), col, w) + + # --- Kartensymbole (prozedural, je Element) -------------------------- -static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void: +static func glyph(ci: CanvasItem, card, center: Vector2, size: float, alpha: float = 0.95) -> void: var variant := absi(hash(card.id)) % 3 var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38) - col.a = 0.95 + col.a = alpha var elems: Array = card.elements if elems.size() == 1: _element_glyph(ci, elems[0], center, size, variant, col) @@ -278,7 +473,7 @@ static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void: # Verschmolzen: die ersten beiden Elemente nebeneinander, ab dem dritten ein Ring _element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col) var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38) - col2.a = 0.95 + col2.a = alpha _element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2) if elems.size() >= 3: ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2) @@ -321,7 +516,7 @@ static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void: ci.draw_colored_polygon(pts, col) var inner := PackedVector2Array([ c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)]) - ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75)) + ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75 * col.a)) ## Dezenter Hintergrund: Farbverlauf und schräge Linien. diff --git a/tests/frame_probe.gd b/tests/frame_probe.gd index df1c30d..f47458f 100644 --- a/tests/frame_probe.gd +++ b/tests/frame_probe.gd @@ -1,5 +1,5 @@ extends Node -## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen (5 Gegner). +## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen. var main: Node var max_dt := 0.0 @@ -14,7 +14,7 @@ func _ready() -> void: add_child(main) await get_tree().process_frame await get_tree().process_frame - var scr: BattleScreen = BattleScreen.new(5) + var scr: BattleScreen = BattleScreen.new() main.show_screen(scr) await get_tree().process_frame await get_tree().process_frame @@ -23,7 +23,7 @@ func _ready() -> void: var t0 := Time.get_ticks_usec() for i in 30: scr.refresh() - print("refresh() mit 5 Gegnern und 8 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30)) + print("refresh() mit 8 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30)) scr.ai_delay = 0.2 measuring = true var start := Time.get_ticks_msec() diff --git a/tests/net_bot.gd b/tests/net_bot.gd index afee194..72e78d3 100644 --- a/tests/net_bot.gd +++ b/tests/net_bot.gd @@ -38,7 +38,7 @@ func _ready() -> void: net.host() net.lobby_changed.connect(func(): if net.game == null and net.names.size() >= 2: - net.start_battle(0)) + net.start_battle()) net.action_applied.connect(func(): net.broadcast() _act_host.call_deferred()) diff --git a/tests/perf_probe.gd b/tests/perf_probe.gd index ec75941..7cdf7ad 100644 --- a/tests/perf_probe.gd +++ b/tests/perf_probe.gd @@ -15,12 +15,9 @@ func _initialize() -> void: var r := RandomNumberGenerator.new() r.seed = 3 print("Ladenangebot: %.2f ms" % ms(func(): DeckGen.shop_cards(r, 5), 50)) - print("Vorschau 1..5 Gegner: %.1f ms" % ms(func(): - for n in range(1, 6): - for i in n: - DeckGen.random_opponent(i + 1))) - for n in [1, 3, 5]: - var g := Game.new_battle(n, r) + print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20)) + for n in [1]: + var g := Game.new_battle(r) # ein volles Brett für alle, damit die Entscheidung teuer wird var uid := 5000 for p in g.players: @@ -41,7 +38,7 @@ func _initialize() -> void: var t_budget := ms(func(): AI.choose_action(g, h)) var cands := AI.candidate_actions(g, h).size() print("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget]) - print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(5, r).clone(), 20)) + print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(r).clone(), 20)) print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music())) - print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(5, r).snapshot(0), 10)) + print("Snapshot: %.2f ms" % ms(func(): Game.new_battle(r).snapshot(0), 10)) quit() diff --git a/tests/simulate.gd b/tests/simulate.gd index 2d28eec..1493eb6 100644 --- a/tests/simulate.gd +++ b/tests/simulate.gd @@ -1,6 +1,6 @@ extends SceneTree ## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln -## ist etwa 1/(Gegner+1) Siege zu erwarten: +## sind etwa 50 % Siege zu erwarten: ## godot --headless --path . -s tests/simulate.gd @@ -9,25 +9,37 @@ func _initialize() -> void: var r := RandomNumberGenerator.new() r.seed = 0 var t0 := Time.get_ticks_msec() - print("Alle gierig (Erwartung etwa 1/(Gegner+1)):") - for n in range(1, Game.MAX_OPPONENTS + 1): - _run(n, games, r, true, true) + print("Beide gierig (Erwartung etwa 50 %):") + _run(games, r, true, true) print("Du mit Vorausschau, Gegner gierig:") - for n in [1, 2, 3]: - _run(n, games / 2, r, false, true) - print("Alle mit Vorausschau:") - _run(1, games / 2, r, false, false) - _run(2, games / 4, r, false, false) + _run(games / 2, r, false, true) + print("Beide mit Vorausschau:") + _run(games / 2, r, false, false) + print("Kampagne (du mit Vorausschau), je Stufe:") + for stage in [0, 4, 9, 14, 19, 24, 29]: + _campaign(stage, games / 4, r) print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0)) quit() -func _run(n_opp: int, games: int, r: RandomNumberGenerator, - human_greedy: bool, opp_greedy: bool) -> void: +func _campaign(stage: int, games: int, r: RandomNumberGenerator) -> void: + var wins := 0 + for i in games: + var g := Game.new_battle_with(Campaign.opponent(stage), r) + while g.winner == "": + AI.take_turn(g, g.current(), false) + assert(g.turn < 300, "Spiel endet nicht") + if g.winner == "human": + wins += 1 + var st := Campaign.stage(stage) + print("Stufe %2d %-12s %2d Leben, %d Merkmale: Siege %3d%%" % [stage + 1, st.name, int(st.life), st.perks.size(), wins * 100 / games]) + + +func _run(games: int, r: RandomNumberGenerator, human_greedy: bool, opp_greedy: bool) -> void: var wins := 0 var turns := 0 for i in games: - var g := Game.new_battle(n_opp, r) + var g := Game.new_battle(r) while g.winner == "": var p := g.current() AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy) @@ -35,4 +47,4 @@ func _run(n_opp: int, games: int, r: RandomNumberGenerator, if g.winner == "human": wins += 1 turns += g.turn - print("gegen %d: Siege %3d%% Ø Züge %.1f" % [n_opp, wins * 100 / games, float(turns) / games]) + print("Siege %3d%% Ø Züge %.1f" % [wins * 100 / games, float(turns) / games]) diff --git a/tests/test_runner.gd b/tests/test_runner.gd index e23f8cb..c6d954a 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -18,25 +18,23 @@ func C(id: String) -> Cards.Card: return Cards.by_id(id) -func setup(human_hand: Array = [], opp_hand: Array = [], n_opp: int = 1) -> Array: +## Duell mit vollem Mana; der Gegner hat 15 Leben. Liefert [Spiel, Spieler, Gegner]. +func setup(human_hand: Array = [], opp_hand: Array = []) -> Array: var human := Game.Player.new("Spieler", true, 30) - var opps := [] - for i in n_opp: - opps.append(Game.Player.new("Gegner %d" % (i + 1), false, 15)) + var o := Game.Player.new("Gegner", false, 15) var r := RandomNumberGenerator.new() r.seed = 0 - var g := Game.new(human, opps, r) + var g := Game.new(human, o, r) g.turn = 1 human.max_energy = 10 human.energy = 10 for cid in human_hand: human.hand.append(C(cid)) - for o in opps: - o.max_energy = 10 - o.energy = 10 - for cid in opp_hand: - o.hand.append(C(cid)) - return [g, human, opps] + o.max_energy = 10 + o.energy = 10 + for cid in opp_hand: + o.hand.append(C(cid)) + return [g, human, o] var next_uid := 9000 @@ -60,7 +58,6 @@ func _initialize() -> void: test_wesen_spielen() test_flink() test_waechter() - test_waechter_nur_eigener_besitzer() test_kampf_beidseitig() test_schild() test_doppelschlag() @@ -76,15 +73,13 @@ func _initialize() -> void: test_mana_effekt() test_gleiche_regeln() test_zugreihenfolge() - test_tote_gegner() - test_jeder_gegen_jeden() - test_gegnerzahl() test_sieg_niederlage() test_booster() test_spielstand() test_resonanz() test_elementvorteil() test_feld() + test_vorschau() test_verschmelzen() test_feldkombos() test_schild_effekt_und_sonne() @@ -141,7 +136,7 @@ func test_flink() -> void: var s := setup(["l_falke"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] g.play_card(h, C("l_falke")) check(g.attack(h.board[0], o) == "", "Flink greift sofort an") check(o.life == 12, "Falke 3 Schaden") @@ -151,7 +146,7 @@ func test_waechter() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var a := put(h, "f_wolf") var w := put(o, "e_wall") check(g.attack(a, o) != "", "Wächter blockt Angriff auf Held") @@ -159,20 +154,11 @@ func test_waechter() -> void: check(w.health == 1 and a.health == 2, "Schaden verrechnet") -func test_waechter_nur_eigener_besitzer() -> void: - var s := setup([], [], 2) - var g: Game = s[0] - var h: Game.Player = s[1] - var a := put(h, "f_wolf") - put(s[2][0], "e_wall") - check(g.attack(a, s[2][1]) == "" and s[2][1].life == 12, "Wächter schützt nur Besitzer") - - func test_kampf_beidseitig() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var a := put(h, "f_asche") var d := put(o, "w_reiter") g.attack(a, d) @@ -184,7 +170,7 @@ func test_schild() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var sh := put(o, "ee_bastion") var a := put(h, "f_asche") g.attack(a, sh) @@ -198,7 +184,7 @@ func test_doppelschlag() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var a := put(h, "l_schlange") g.attack(a, o) g.attack(a, o) @@ -210,7 +196,7 @@ func test_einfrieren() -> void: var s := setup([], ["w_frost"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var a := put(h, "f_wolf") g.end_turn() check(g.play_card(o, C("w_frost"), a) == "", "Frostmagier spielen") @@ -226,30 +212,29 @@ func test_effekt_ziel() -> void: var s := setup(["w_frost", "f_ritter"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] check(g.play_card(h, C("w_frost")) == "" and h.board.size() == 1, "ohne Ziel kommt das Wesen trotzdem, der Effekt verpufft") g.play_card(h, C("f_ritter"), o) check(o.life == 14 and h.board.size() == 2, "Ritter macht 1 Schaden an den Helden") func test_flaechenschaden() -> void: - var s := setup(["ff_phoenix"], [], 2) + var s := setup(["ff_phoenix"]) var g: Game = s[0] var h: Game.Player = s[1] - var o1: Game.Player = s[2][0] - var o2: Game.Player = s[2][1] - put(o1, "f_kobold") - put(o2, "e_golem") + var o: Game.Player = s[2] + put(o, "f_kobold") + put(o, "e_golem") g.play_card(h, C("ff_phoenix")) - check(o1.life == 11 and o2.life == 11, "Helden 4 Schaden") - check(o1.board.is_empty() and o2.board[0].health == 1, "Wesen getroffen") + check(o.life == 11, "Held 4 Schaden") + check(o.board.size() == 1 and o.board[0].health == 1, "Wesen getroffen, Kobold tot") func test_zurueck_auf_hand() -> void: var s := setup(["wl_nebel"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var d := put(o, "e_troll") g.play_card(h, C("wl_nebel"), d) check(o.board.is_empty() and C("e_troll") in o.hand, "zurück auf Hand") @@ -285,11 +270,10 @@ func test_limits() -> void: ## Grenzfälle, die schon einmal falsch waren. func test_grenzfaelle() -> void: - var s := setup(["f_wolf"], [], 2) + var s := setup(["f_wolf"]) var g: Game = s[0] var h: Game.Player = s[1] - var o1: Game.Player = s[2][0] - var o2: Game.Player = s[2][1] + var o1: Game.Player = s[2] # Nur Karten von der eigenen Hand check(not g.can_play(h, C("e_troll"))[0] and g.play_card(h, C("e_troll")) == "Nicht auf der Hand", "fremde Karte lässt sich nicht spielen") check(g.play_card(h, C("f_wolf")) == "" and h.board.size() == 1, "eigene Handkarte geht") @@ -298,7 +282,7 @@ func test_grenzfaelle() -> void: o1.life = 0 g.cleanup() check(o1.board.is_empty() and not o1.alive() and g.log.any(func(l): return l.find("verschwindet mit") >= 0), "besiegter Spieler räumt sein Feld") - check(not (o1 in g.enemies_of(h)) and o2 in g.enemies_of(h), "Besiegte sind keine Ziele mehr") + check(g.enemies_of(h).is_empty(), "Besiegte sind keine Ziele mehr") check(dead != null, "Wesen war vorher da") # Verschmolzene Karte auf der Hand übersteht den Spielstand var a := put(h, "f_kobold") @@ -314,10 +298,10 @@ func test_grenzfaelle() -> void: ## Zweite Runde Grenzfälle: Konter ohne Angriff, Chimäre, Ereignisse mit verschmolzener Karte, ## ausgeschiedener Netzwerkspieler mitten im eigenen Zug. func test_grenzfaelle_2() -> void: - var s := setup([], [], 2) + var s := setup() var g: Game = s[0] var h: Game.Player = s[1] - var o1: Game.Player = s[2][0] + var o1: Game.Player = s[2] # Ein Wesen ohne Angriff schlägt nicht zurück, auch nicht mit Elementvorteil var wall := put(o1, "e_wall") # Erde 0/4, Erde schlägt Wasser var reiter := put(h, "w_reiter") # Wasser 3/3 @@ -347,10 +331,10 @@ func test_grenzfaelle_2() -> void: net.game = g net.rejoin_seconds = 0.0 net.waiting = {"Spieler": {"seat": g.players.find(h), "since": 0.0}} - o1.is_human = true # noch ein Mensch am Tisch, sonst wäre die Partie schlicht verloren + o1.is_human = true # ein Duell zweier Menschen check(g.current() == h, "Spieler ist dran") net.tick() - check(not h.alive() and h.board.is_empty() and g.current() != h, "Ausgeschiedener räumt das Feld und der Zug geht weiter") + check(not h.alive() and h.board.is_empty() and g.winner != "" and g.winner_player == o1, "Ausgeschiedener räumt das Feld, der andere gewinnt") net.free() @@ -421,7 +405,7 @@ func test_mana_effekt() -> void: func test_gleiche_regeln() -> void: var r := RandomNumberGenerator.new() r.seed = 0 - var g := Game.new_battle(3, r) + var g := Game.new_battle(r) var first: Game.Player = g.players[0] check(g.current() == first and first.max_energy == 1 and first.energy == 1, "Startspieler: 1 Mana, kein Bonus") check(first.shop.size() == Game.SHOP_SIZE and first.hand.is_empty(), "jeder beginnt mit einem Laden und leerer Hand") @@ -432,9 +416,9 @@ func test_gleiche_regeln() -> void: g.end_turn() var second: Game.Player = g.players[1] check(g.current() == second and second.max_energy == 1 and second.energy == 2, "Nachzieher: 1 Mana plus Ausgleich") - for i in 4: + for i in 2: g.end_turn() - check(g.current() == second and second.max_energy == 2, "Ausgleich nur einmal") + check(g.current() == second and second.max_energy == 2 and second.energy == 4, "Ausgleich nur einmal: 2 übrig plus 2 Einkommen") func test_zugreihenfolge() -> void: @@ -442,9 +426,9 @@ func test_zugreihenfolge() -> void: for seed in 12: var r := RandomNumberGenerator.new() r.seed = seed - var g := Game.new_battle(2, r) + var g := Game.new_battle(r) starters[g.players[0].name] = true - check(g.players.size() == 3 and g.human in g.players and g.opponents.size() == 2, "alle am Tisch") + check(g.players.size() == 2 and g.human in g.players and g.opponent in g.players, "beide am Tisch") for i in g.players.size(): check(g.players[i].order == i, "Reihenfolge nummeriert") check(g.log[0].begins_with("Zugreihenfolge:"), "Reihenfolge im Verlauf") @@ -453,58 +437,15 @@ func test_zugreihenfolge() -> void: check(starters.size() > 1, "Startspieler wechselt (%d verschiedene)" % starters.size()) -func test_tote_gegner() -> void: - var s := setup([], [], 2) - var g: Game = s[0] - s[2][0].life = 0 - g.end_turn() - check(g.current() == s[2][1], "toter Gegner übersprungen") - g.end_turn() - check(g.current() == s[1] and g.turn == 2, "zurück beim Spieler, Zug 2") - - -func test_jeder_gegen_jeden() -> void: - var s := setup([], [], 2) - var g: Game = s[0] - var h: Game.Player = s[1] - var o1: Game.Player = s[2][0] - var o2: Game.Player = s[2][1] - check(g.enemies_of(o1) == [h, o2], "Gegner bekämpft Spieler und anderen Gegner") - check(g.enemies_of(h) == [o1, o2], "Spieler bekämpft alle Gegner") - g.end_turn() - o1.hand = [] - var a := put(o1, "f_wolf") - check(g.can_attack(a, o2)[0], "Gegner darf Gegner angreifen") - check(g.attack(a, o2) == "" and o2.life == 12, "Gegner trifft Gegner") - check(AI.choose_action(g, o1).type == "buy", "KI kauft aus ihrem Laden") - o1.shop = [] - check(AI.choose_action(g, o1).type == "end", "ohne Angebot und ohne Angriff endet der Zug") - o2.life = 2 - var b := put(o1, "f_kobold") - check(AI.choose_action(g, o1).target == o2, "KI nutzt Lethal gegen anderen Gegner") - - -func test_gegnerzahl() -> void: - var r := RandomNumberGenerator.new() - r.seed = 2 - for n in range(1, Game.MAX_OPPONENTS + 1): - var g := Game.new_battle(n, r) - check(g.opponents.size() == n, "%d Gegner" % n) - check(g.players.all(func(o): return o.life == Game.START_LIFE), "gleiches Leben bei %d" % n) - check(g.players[0].max_energy == 1 and g.current() == g.players[0], "Startspieler bei %d" % n) - check(g.human.shop.size() == Game.SHOP_SIZE, "Laden bei %d" % n) - check(Profile.REWARD_WIN.has(n), "Belohnung bei %d" % n) - - func test_sieg_niederlage() -> void: - var s := setup([], [], 2) + var s := setup() var g: Game = s[0] - g.damage(s[2][0], 99) + g.damage(s[2], 14) g.check_end() check(g.winner == "", "noch offen") - g.damage(s[2][1], 99) + g.damage(s[2], 1) g.check_end() - check(g.winner == "human", "Sieg") + check(g.winner == "human" and g.winner_player == s[1], "Sieg") var s2 := setup() var g2: Game = s2[0] g2.damage(s2[1], 30) @@ -562,7 +503,7 @@ func test_ki_lethal() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] o.life = 3 var a := put(h, "f_asche") put(o, "w_reiter") @@ -573,23 +514,20 @@ func test_ki_lethal() -> void: func test_ki_spiele_enden() -> void: var r := RandomNumberGenerator.new() r.seed = 7 - for n in [1, 3, 5]: - for i in 6: - var pool: Array = Cards.ELEMENTS.duplicate() - Game.shuffle(pool, r) - var g := Game.new_battle(n, r) - var guard := 0 - while g.winner == "" and guard < 300: - AI.take_turn(g, g.current(), i % 2 == 0) - guard += 1 - check(g.winner != "", "Spiel endet (%d Gegner)" % n) + for i in 12: + var g := Game.new_battle(r) + var guard := 0 + while g.winner == "" and guard < 300: + AI.take_turn(g, g.current(), i % 2 == 0) + guard += 1 + check(g.winner != "", "Spiel endet (Partie %d)" % i) func test_resonanz() -> void: var s := setup(["f_wolf", "f_kobold", "e_wall"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] g.play_card(h, C("f_wolf")) check(h.board[0].power() == 3, "allein keine Resonanz") g.play_card(h, C("f_kobold")) @@ -621,7 +559,7 @@ func test_elementvorteil() -> void: var s := setup(["f_ritter"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var mine := put(h, "w_reiter") # Wasser 3/3 var his := put(o, "f_wolf") # Feuer 3/2 g.attack(mine, his) @@ -633,7 +571,7 @@ func test_elementvorteil() -> void: s = setup(["f_ritter"]) g = s[0] h = s[1] - o = s[2][0] + o = s[2] var air := put(o, "l_laeufer") # Luft 3/4, Feuer schlägt Luft g.play_card(h, C("f_ritter"), air) check(air.health == 2, "Feuereffekt gegen Luft: 1 Schaden mehr") @@ -644,7 +582,7 @@ func test_feld() -> void: var s := setup(["f_ritter"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] check(g.field_element == "" and int(g.field_charge["Feuer"]) == 0, "das Feld beginnt neutral") var wolf := put(h, "f_wolf") for i in Cards.FIELD_CHARGE - 1: @@ -669,11 +607,34 @@ func test_feld() -> void: ## Verschmelzen: zwei eigene Wesen werden eins, Werte Durchschnitt plus 1. +## Vorschau fürs Ausspielen: Resonanz, Feldbonus und Feldkombo mit Partner. +func test_vorschau() -> void: + var s := setup(["f_kobold", "e_wall"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var f := g.forecast(h, C("f_kobold")) + check(f.resonance == 0 and f.field == 0 and f.combo == "" and f.partner == null, "leeres Feld: keine Vorschau") + var wolf := put(h, "f_wolf") + put(h, "f_asche") + var reiter := put(h, "w_reiter") + f = g.forecast(h, C("f_kobold")) + check(f.resonance == 2 and f.combo == "Dampf" and f.partner == reiter, "zwei Feuerpartner geben +2, das Wasserwesen die Kombo Dampf") + f = g.forecast(h, C("e_wall")) + check(f.resonance == 0 and f.combo == "Lava" and f.partner == wolf, "Erde: kein Partner, Kombo Lava mit dem ersten Feuerwesen") + g.field_element = "Feuer" + check(g.forecast(h, C("f_kobold")).field == 1 and g.forecast(h, C("e_wall")).field == 0, "der Feldbonus zählt nur fürs eigene Element") + h.perks.append("resonanz_doppelt") + check(g.forecast(h, C("f_kobold")).resonance == 4, "doppelte Resonanz verdoppelt die Vorschau") + var real := g.play_card(h, C("f_kobold")) + var kobold: Game.Creature = h.board[-1] + check(real == "" and kobold.power() == 2 + 4 + 1, "das Ausspielen bringt genau den vorhergesagten Angriff") + + func test_verschmelzen() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var a := put(h, "f_wolf") # Feuer 3/2, Kosten 2 var b := put(h, "w_reiter") # Wasser 3/3, Kosten 2 check(Cards.fusion_cost(a.card, b.card) == 2 and Cards.fusion_cost(C("f_kobold"), C("f_asche")) == 2, "Kosten: Durchschnitt, aufgerundet") @@ -719,7 +680,7 @@ func test_feldkombos() -> void: var s := setup(["w_reiter"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] put(h, "f_wolf") var weak := put(o, "f_kobold") var strong := put(o, "e_troll") @@ -730,20 +691,19 @@ func test_feldkombos() -> void: s = setup(["e_golem"]) g = s[0] h = s[1] - o = s[2][0] + o = s[2] put(h, "f_wolf") var k := put(o, "f_kobold") var big := put(o, "e_troll") g.play_card(h, C("e_golem")) check(k in o.board and big.health == 5, "Lava trifft nur das stärkste Wesen") - # Blitz: 2 Schaden an den schwächsten Gegner - s = setup(["l_wind"], [], 2) + # Blitz: 2 Schaden an den Gegner + s = setup(["l_wind"]) g = s[0] h = s[1] put(h, "f_wolf") - s[2][1].life = 5 g.play_card(h, C("l_wind")) - check(s[2][1].life == 3 and s[2][0].life == 15, "Blitz trifft den schwächsten Gegner") + check(s[2].life == 13, "Blitz trifft den Gegner") # Schlamm heilt, Sturm gibt Mana, Sand gibt +1/+1 s = setup(["e_wall", "l_wind", "w_reiter"]) g = s[0] @@ -773,7 +733,7 @@ func test_feldkombos() -> void: s = setup(["fe_golem"]) g = s[0] h = s[1] - o = s[2][0] + o = s[2] put(h, "w_reiter") var t := put(o, "e_troll") g.play_card(h, C("fe_golem")) @@ -784,7 +744,7 @@ func test_schild_effekt_und_sonne() -> void: var s := setup(["el_duenen", "fff_sonne"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var a := put(h, "f_wolf") g.play_card(h, C("el_duenen"), a) check(a.shield, "Dünenläufer gibt Schild") @@ -792,7 +752,7 @@ func test_schild_effekt_und_sonne() -> void: s = setup(["fff_sonne"]) g = s[0] h = s[1] - o = s[2][0] + o = s[2] check(C("fff_sonne").elements == ["Feuer"] and C("fff_sonne").cost == 9, "Kern der Sonne ist ein teures Feuerwesen") var troll := put(o, "e_troll") g.play_card(h, C("fff_sonne")) @@ -803,7 +763,7 @@ func test_kopie() -> void: var s := setup(["f_wolf"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] var a := put(h, "f_asche") var g2 := g.clone() var a2 := g2.find_creature(a.uid) @@ -823,7 +783,7 @@ func test_ki_besser_als_gierig() -> void: for i in games: var pool: Array = Cards.ELEMENTS.duplicate() Game.shuffle(pool, r) - var g := Game.new_battle(1, r) + var g := Game.new_battle(r) # abwechselnd beginnt die schlaue oder die gierige KI var smart_is_human := i % 2 == 0 var guard := 0 @@ -855,22 +815,22 @@ func test_kampagne() -> void: var r := RandomNumberGenerator.new() r.seed = 4 check(Campaign.STAGES.size() == 10, "zehn Stufen") - var opps := Campaign.opponents(4) - check(opps.size() == 3 and opps[0].name == "Vulkar" and opps[0].title == "Herr der Lava", "Stufe 5: Vulkar mit 2 Begleitern") - check(opps[0].elements == ["Feuer", "Erde"] and opps[0].shop.all(func(c): return c.elements.all(func(e): return e in ["Feuer", "Erde"])), "Boss handelt nur mit seinen Elementen") - var g := Game.new_battle_with(opps, r) - check(g.opponents.size() == 3 and g.human.life == Game.START_LIFE, "Kampagnenkampf gestartet") + var boss := Campaign.opponent(4) + check(boss.name == "Vulkar" and boss.title == "Herr der Lava" and boss.life == 32, "Stufe 5: Vulkar mit 32 Leben") + check(boss.elements == ["Feuer", "Erde"] and boss.shop.all(func(c): return c.elements.all(func(e): return e in ["Feuer", "Erde"])), "Boss handelt nur mit seinen Elementen") + var g := Game.new_battle_with(boss, r) + check(g.opponent == boss and g.human.life == Game.START_LIFE, "Kampagnenkampf gestartet") var p := Profile.starter() - var reward := p.record_battle(true, 1, 0) + var reward := p.record_battle(true, 0) check(p.campaign_stage == 1 and reward == Campaign.STAGES[0].reward, "Stufe 1 abgeschlossen") - reward = p.record_battle(true, 1, 0) - check(p.campaign_stage == 1 and reward == Profile.REWARD_WIN[1], "Wiederholung gibt normale Belohnung") - reward = p.record_battle(false, 2, 1) + reward = p.record_battle(true, 0) + check(p.campaign_stage == 1 and reward == Profile.REWARD_WIN, "Wiederholung gibt normale Belohnung") + reward = p.record_battle(false, 1) check(p.campaign_stage == 1 and reward == Profile.REWARD_LOSS, "Niederlage schaltet nichts frei") p.campaign_stage = 9 - p.record_battle(true, 5, 9) + p.record_battle(true, 9) check(p.campaign_stage == 10, "Kampagne abgeschlossen") - var sim := Game.new_battle_with(Campaign.opponents(0), r) + var sim := Game.new_battle_with(Campaign.opponent(0), r) var guard := 0 while sim.winner == "" and guard < 300: AI.take_turn(sim, sim.current(), true) @@ -916,7 +876,7 @@ func test_merkmale() -> void: var s := setup(["f_wolf", "l_falke", "e_wall"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] h.perks = ["feuer_angriff", "flink_angriff", "waechter_leben", "schild_wesen", "alle_flink", "resonanz_doppelt"] g.play_card(h, C("f_wolf")) var wolf: Game.Creature = h.board[0] @@ -932,15 +892,15 @@ func test_merkmale() -> void: check(Campaign.STAGES.any(func(st): return st.perk == perk), "Merkmal vergeben: " + perk) var r := RandomNumberGenerator.new() r.seed = 2 - var mara: Game.Player = Campaign.opponents(1)[0] - check(mara.life == 35 and mara.perks == ["leben_35"], "Mara hat 35 Leben") - var g3 := Game.new_battle_with(Campaign.opponents(9), r, true) - var archi: Game.Player = g3.opponents[0] + var mara: Game.Player = Game.new_battle_with(Campaign.opponent(1), r).opponent + check(mara.life == 35 and mara.max_life == 35 and mara.perks == ["leben_35"], "Mara hat mit ihrem Merkmal 35 Leben") + var g3 := Game.new_battle_with(Campaign.opponent(9), r, true) + var archi: Game.Player = g3.opponent check(g3.players[0] == g3.human, "feste Reihenfolge: Mensch beginnt") g3.end_turn() check(g3.current() == archi and archi.energy == 3 + 1, "Archimagier beginnt mit 3 Mana (plus Ausgleich)") - var nyx: Game.Player = Campaign.opponents(5)[0] - var g4 := Game.new_battle_with([nyx], r, true) + var nyx: Game.Player = Campaign.opponent(5) + var g4 := Game.new_battle_with(nyx, r, true) g4.end_turn() # Einkommen 1, dazu 1 Mana für ihr Merkmal und 1 als Ausgleich für den späteren Start check(nyx.max_energy == 1 and nyx.energy == 3, "Nyx bekommt 1 Mana mehr als ihr Einkommen") @@ -952,7 +912,7 @@ func test_ki_zwei_schritte() -> void: var s := setup(["el_koenig"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] h.turns = 2 o.life = 5 put(h, "f_wolf", false) @@ -966,10 +926,10 @@ func test_ki_zwei_schritte() -> void: func test_snapshot() -> void: - var s := setup(["f_wolf"], ["w_frost"], 2) + var s := setup(["f_wolf"], ["w_frost"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2][0] + var o: Game.Player = s[2] g.play_card(h, C("f_wolf")) put(o, "e_troll") var hi := g.players.find(h) @@ -979,7 +939,7 @@ func test_snapshot() -> void: var snap := g.snapshot(hi) var json: Dictionary = JSON.parse_string(JSON.stringify(snap)) var g2 := Game.from_snapshot(json) - check(g2.players.size() == 3 and g2.turn == g.turn and g2.current_idx == g.current_idx, "Grunddaten") + check(g2.players.size() == 2 and g2.turn == g.turn and g2.current_idx == g.current_idx, "Grunddaten") check(g2.players[hi].hand.is_empty(), "eigene Hand sichtbar") check(g2.players[oi].hand.size() == 1 and g2.players[oi].hand[0] != C("w_frost"), "fremde Hand nur als Anzahl") check(g2.players[oi].shop.is_empty() and g2.players[hi].shop.size() == Game.SHOP_SIZE, "eigener Laden sichtbar, fremder nicht") @@ -1002,17 +962,17 @@ func test_kapitel_zwei_und_merkmalwahl() -> void: check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1)) var r := RandomNumberGenerator.new() r.seed = 8 - var opps := Campaign.opponents(19) - check(opps.size() == 5 and opps[0].name == "Archimagier" and opps[0].perks.size() == 3, "letzte Stufe: Archimagier mit drei Merkmalen, voller Tisch") + var archi2 := Campaign.opponent(19) + check(archi2.name == "Archimagier" and archi2.perks.size() == 3 and archi2.life == 34, "letzte Stufe von Kapitel 2: Archimagier mit drei Merkmalen und 34 Leben") var p := Profile.starter() check(not p.perk_unlocked(), "Merkmalwahl erst nach Kapitel 1") p.campaign_stage = 10 check(p.perk_unlocked(), "nach Kapitel 1 freigeschaltet") p.perk = "feuer_angriff" - var g := Game.new_battle(1, r, {"human_perks": [p.perk]}) + var g := Game.new_battle(r, {"human_perks": [p.perk]}) check(g.human.perks == ["feuer_angriff"] and g.log.any(func(l): return l.begins_with("Spieler: Feuerwesen")), "eigenes Merkmal wirkt und steht im Verlauf") p.campaign_stage = 19 - p.record_battle(true, 5, 19) + p.record_battle(true, 19) check(p.campaign_stage == 20, "Kapitel 2 abgeschlossen") var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) check(v.perk == "feuer_angriff" and v.campaign_stage == 20, "Version 5 gelesen") @@ -1021,7 +981,7 @@ func test_kapitel_zwei_und_merkmalwahl() -> void: func test_ki_zeitbudget() -> void: var r := RandomNumberGenerator.new() r.seed = 12 - var g := Game.new_battle(5, r) + var g := Game.new_battle(r) var h: Game.Player = g.players[0] h.energy = 10 h.max_energy = 10 @@ -1058,7 +1018,7 @@ func test_englische_karten() -> void: func test_kampf_speichern() -> void: var r := RandomNumberGenerator.new() r.seed = 7 - var g := Game.new_battle(2, r) + var g := Game.new_battle(r) g.start() AI.take_turn(g, g.current()) var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1))) @@ -1075,7 +1035,7 @@ func test_kampf_speichern() -> void: while g2.winner == "" and guard < 300: AI.take_turn(g2, g2.current()) guard += 1 - check(g2.winner != "", "fortgesetzte Partie ist bis zum Ende spielbar") + check(g2.winner != "", "der volle Spielstand ist bis zum Ende spielbar") var s2: Dictionary = g.snapshot(-2) check(s2.players.all(func(p): return not (p.hand is Array) and p.shop.is_empty()), "Zuschauer-Sicht ohne Hände und Läden") diff --git a/tests/ui_test.gd b/tests/ui_test.gd index 13dbba8..2a0ab8e 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -26,6 +26,7 @@ func frames(n: int = 2) -> void: func shot(name: String) -> void: if shots == "": return + await frames(10) # Einblenden und Austeilen abwarten await RenderingServer.frame_post_draw get_viewport().get_texture().get_image().save_png(shots.path_join(name + ".png")) @@ -142,18 +143,15 @@ func _ready() -> void: for suffix in ["", ".bak", ".corrupt"]: if FileAccess.file_exists(SAVE + suffix): DirAccess.remove_absolute(abs + suffix) - if FileAccess.file_exists(SAVE.get_basename() + "_kampf.json"): - DirAccess.remove_absolute(abs.get_basename() + "_kampf.json") main = load("res://scenes/main.tscn").instantiate() main.save_path = SAVE add_child(main) await frames(3) var fast := OS.get_environment("DINGER_FAST") != "" var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen, - test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_feld_und_verschmelzen, test_kampf_grenzen, - test_kampf_fortsetzen] + test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_feld_und_verschmelzen, test_kampf_grenzen] if not fast: - suite += [test_kampf.bind(2, 3), test_kampf_abbrechen] + suite += [test_kampf.bind(3), test_kampf_abbrechen] # Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten. # Sie laufen nur auf Wunsch: DINGER_NETZ=1 (oder gezielt über DINGER_ONLY). if OS.get_environment("DINGER_NETZ") != "": @@ -254,7 +252,7 @@ func human_turn(scr) -> void: "' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " pending=", scr.pending, " selected=", scr.selected) check(scr.selected == cr, "Wesen gewählt") var targets := [] - for o in g.opponents: + for o in [g.opponent]: if o.alive() and g.can_attack(cr, o)[0]: targets.append(o) for c in o.board: @@ -267,11 +265,9 @@ func human_turn(scr) -> void: await click(scr.b_end) -func start_battle(n_opp: int) -> void: +func start_battle() -> void: await click(find_button("Einzelspieler")) check(screen() is OpponentSelectScreen, "Gegnerauswahl") - screen().count_slider.value = n_opp - await frames(1) await click(find_button("Starten")) @@ -279,51 +275,33 @@ func test_gegnerauswahl() -> void: await click(find_button("Einzelspieler")) var sel = screen() check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet") - check(find_button("Neu würfeln") == null and find_button("1 Gegner") == null and find_button("Starten") != null, "kein Würfeln und keine Gegnerknöpfe, nur Starten") - check(find_button("Feuer") == null and find_button("Luft") == null, "keine Elementwahl mehr") - check(sel.count_slider.min_value == 1 and sel.count_slider.max_value == Game.MAX_OPPONENTS and sel.count_slider.step == 1, "Gegnerzahl als Schieberegler von 1 bis %d" % Game.MAX_OPPONENTS) - check(sel.opponents() == 1 and sel.count_label.text == "1 Gegner" and "Duell" in sel.info_label.text, "Regler beginnt beim Duell") - var rect: Rect2 = sel.count_slider.get_global_rect() - await click_at(Vector2(rect.end.x - 4, rect.get_center().y)) # ganz rechts anklicken - check(sel.opponents() == Game.MAX_OPPONENTS and sel.count_label.text == "%d Gegner" % Game.MAX_OPPONENTS, "Mausklick am rechten Rand stellt %d Gegner ein" % Game.MAX_OPPONENTS) - sel.count_slider.grab_focus() - await key(KEY_LEFT) - check(sel.opponents() == Game.MAX_OPPONENTS - 1, "Pfeiltaste links nimmt einen Gegner weg") - sel.count_slider.value = 4 - await frames(1) - check(sel.opponents() == 4 and sel.count_label.text == "4 Gegner" and "5 am Tisch" in sel.info_label.text - and str(Profile.REWARD_WIN[4]) in sel.info_label.text, "Regler auf 4: fünf am Tisch mit Belohnung") + check(find_button("Neu würfeln") == null and find_button("Feuer") == null and find_button("Starten") != null, "nur Schwierigkeit und Starten") await click(find_button("Schwer")) check(main.profile.settings.difficulty == "schwer" and Profile.load_or_new(SAVE).settings.difficulty == "schwer", "Schwer gewählt und gespeichert") + check(str(Profile.DIFFICULTIES.schwer.life) in sel.info_label.text and str(int(Profile.REWARD_WIN * 1.5)) in sel.info_label.text, "Leben des Gegners und Belohnung stehen dabei") await shot("07_gegnerauswahl") - sel.count_slider.value = 1 - await frames(1) await click(find_button("Starten")) var hard = screen() - check(hard is BattleScreen and hard.game.opponents.size() == 1, "Kampf mit einem Gegner gestartet") - var opp: Game.Player = hard.game.opponents[0] + check(hard is BattleScreen and hard.game.players.size() == 2, "Duell gestartet") + var opp: Game.Player = hard.game.opponent check(opp.max_life == Profile.DIFFICULTIES.schwer.life and hard.smart_ai, "Schwer: Gegner mit %d Leben, Vorausschau-KI" % Profile.DIFFICULTIES.schwer.life) check(hard.game.human.shop.size() == Game.SHOP_SIZE and hard.game.human.hand.is_empty(), "Start mit vollem Laden und leerer Hand") + check(hard.hero_panels[opp].position == BattleLayout.OPP_BOX.position, "der Gegner steht oben in der Mitte") + await shot("11_kampf") await give_up() await click(find_button("Einzelspieler")) await click(find_button("Leicht")) await click(find_button("Starten")) var easy = screen() - opp = easy.game.opponents[0] + opp = easy.game.opponent check(opp.max_life == Profile.DIFFICULTIES.leicht.life and not easy.smart_ai, "Leicht: Gegner mit %d Leben, einfache KI" % Profile.DIFFICULTIES.leicht.life) + check(easy.game.players[0].max_energy == 1 and easy.game.human.life == Game.START_LIFE, "gleiche Regeln für beide") await give_up() await click(find_button("Einzelspieler")) await click(find_button("Normal")) await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") - for n in [1, 5]: - await start_battle(n) - var scr = screen() - check(scr is BattleScreen and scr.game.opponents.size() == n, "Kampf gegen %d gestartet" % n) - check(scr.game.players[0].max_energy == 1 and scr.game.opponents[0].life == Game.START_LIFE, "gleiche Regeln gegen %d" % n) - await shot("11_kampf_%d" % n) - await give_up() ## Kampf verlassen: nach Spielende über den Ergebnisdialog, sonst per Aufgeben. @@ -348,32 +326,6 @@ func give_up() -> void: check(screen() is MenuScreen, "Aufgeben führt ins Menü") -## Kampf speichern, im Menü fortsetzen, Aufgeben löscht die Datei. -func test_kampf_fortsetzen() -> void: - await start_battle(1) - var scr = screen() - scr.ai_delay = 0.0 - await wait_for_human(scr) - var kampf: String = main.battle_save_path() - check(FileAccess.file_exists(kampf), "Kampf wird laufend gespeichert") - await click(scr.b_end) - await wait_for_human(scr) - var turn: int = scr.game.turn - var life: int = scr.game.human.life - var hand: int = scr.game.human.hand.size() - await click(find_button("Aufgeben")) - await click(find_button("Speichern und zurück")) - check(screen() is MenuScreen and find_button("Kampf fortsetzen") != null, "Speichern führt ins Menü mit Fortsetzen-Knopf") - await click(find_button("Kampf fortsetzen")) - var scr2 = screen() - check(scr2 is BattleScreen and scr2.game.turn == turn and scr2.game.human.life == life and scr2.game.human.hand.size() == hand - and scr2.game.current() == scr2.game.human, "Kampf fortgesetzt mit gleichem Zug, Leben und Hand") - scr2.ai_delay = 0.0 - await shot("30_kampf_fortgesetzt") - await give_up() - check(not FileAccess.file_exists(kampf) and find_button("Kampf fortsetzen") == null, "Aufgeben löscht den gespeicherten Kampf") - - func test_einstellungen() -> void: await click(find_button("Einstellungen")) var scr = screen() @@ -414,6 +366,14 @@ func test_sammlung() -> void: check(scr.selected.id == "ff_phoenix" and p.rarity("ff_phoenix") == "gewöhnlich", "Karte gewählt, noch gewöhnlich") await click(scr.b_upgrade) check(p.level("ff_phoenix") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["selten"], "auf selten aufgewertet") + scr.search_box.text = "" + scr.search_box.text_changed.emit("") + await frames(1) + var first: CardView = scr.grid.views()[0] + check(first.card.id == "ff_phoenix" and first.rarity == "selten", "die aufgewertete Karte steht vorn in der Sammlung") + scr.search_box.text = "Phönix" + scr.search_box.text_changed.emit("Phönix") + await frames(1) check(Profile.load_or_new(SAVE).level("ff_phoenix") == 1, "Sammlung gespeichert") var dust: int = p.dust await click(scr.b_dismantle) @@ -491,7 +451,7 @@ func test_sprache() -> void: ## Steuerung im Kampf: Tastatur, Rückgängig, Gamepad, Überspringen, Esc. func test_kampf_steuerung() -> void: - await start_battle(1) + await start_battle() var scr = screen() check(scr is BattleScreen, "Kampf gestartet") scr.ai_delay = 0.0 @@ -551,7 +511,7 @@ func test_kampf_steuerung() -> void: ## Grenzen im Kampf: volle Hand, volles Feld, kein Mana. func test_kampf_grenzen() -> void: - await start_battle(1) + await start_battle() var scr = screen() scr.ai_delay = 0.0 await wait_for_human(scr) @@ -603,26 +563,45 @@ func test_kampf_grenzen() -> void: ## Feldanzeige und Verschmelzen im Kampf. func test_kampf_feld_und_verschmelzen() -> void: - await start_battle(1) + await start_battle() var scr = screen() check(scr is BattleScreen, "Kampf gestartet") scr.ai_delay = 0.0 await wait_for_human(scr) var g: Game = scr.game g.human.board.clear() - for id in ["f_wolf", "w_reiter"]: - var cr := Game.Creature.new(Cards.by_id(id), g.human, g.next_uid) + var put := func(p: Game.Player, id: String) -> Game.Creature: + var cr := Game.Creature.new(Cards.by_id(id), p, g.next_uid) g.next_uid += 1 - g.human.board.append(cr) + cr.ready = true + cr.attacks_left = 1 + p.board.append(cr) + return cr + put.call(g.human, "f_wolf") + put.call(g.human, "w_reiter") + var falke: Game.Creature = put.call(scr.others()[0], "l_falke") + g.human.hand = [Cards.by_id("f_kobold")] g.human.energy = 10 + g.field_charge["Wasser"] = 2 + scr.refresh() + await frames(2) + check(scr.field_view.text() == "Feuer 0/4 · Wasser 2/4 · Erde 0/4 · Luft 0/4", "neutrales Feld zeigt die Ladung: " + scr.field_view.text()) for i in Cards.FIELD_CHARGE: g.charge_field(Cards.by_id("f_kobold")) scr.refresh() await frames(2) - check(scr.field_label.text.begins_with("Vulkan"), "Feldanzeige nennt das Feld: " + scr.field_label.text) + check(scr.field_view.text().begins_with("Vulkan: +1 Angriff"), "Feldanzeige nennt das Feld: " + scr.field_view.text()) check(g.human.board[0].field_bonus == 1 and g.human.board[0].power() == 4, "das Feuerwesen bekommt +1 Angriff") + check(scr.hand_views[0].bonus == 2 and scr.hand_views[0].tag == "Dampf", "die Handkarte zeigt +2 (Resonanz und Feld) und die Kombo Dampf") check(scr.b_fuse.visible and scr.b_fuse.text == "Verschmelzen", "Verschmelzen wird angeboten") await shot("23_feld") + await hover(scr.hand_views[0]) + check(scr.tiles[g.human.board[1].uid].mark == "Dampf" and "Dampf" in scr.info_label.text, "beim Zeigen auf die Handkarte ist der Kombo-Partner markiert") + await click(scr.own_views[0]) + check(scr.selected == g.human.board[0] and scr.tiles[falke.uid].matchup == 1, "Angreifer gewählt: das Luftwesen zeigt +1 für Feuer") + await shot("23b_angriffsziel") + await right_click() # Auswahl wieder aufheben + check(scr.selected == null and scr.tiles[falke.uid].matchup == 0, "Auswahl aufgehoben, Vorteil verschwindet") await click(scr.b_fuse) check(scr.fusing and scr.b_fuse.text == "Abbrechen", "Verschmelzen ausgewählt") await click(scr.own_views[0]) @@ -834,13 +813,15 @@ func test_kampagne() -> void: var scr = screen() check(scr is CampaignScreen, "Kampagne geöffnet") await shot("15_kampagne") - check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen") + check(scr.node_buttons.filter(func(b): return b.disabled).size() == Campaign.STAGES.size() - 1 and find_button("Kämpfen") != null, "neun Stationen gesperrt, die erste offen") + await click(scr.node_buttons[3]) + check(scr.selected == 0, "gesperrte Station lässt sich nicht wählen") check(find_button("Kapitel 2").disabled and scr.perk_box == null, "Kapitel 2 und Merkmal noch gesperrt") await click(find_button("Kämpfen")) var battle = screen() check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet") - check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner") - check(battle.game.opponents[0].perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv") + check(battle.game.opponent.name == "Fenn", "Fenn ist der Gegner") + check(battle.game.opponent.perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv") await shot("16_kampagne_kampf") await give_up() # Kapitel 1 als abgeschlossen setzen: Kapitel 2 und Merkmalwahl @@ -854,7 +835,7 @@ func test_kampagne() -> void: await shot("27_kampagne_kapitel2") await click(find_button("Kämpfen")) var b2 = screen() - check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponents[0].perks.size() == 2, "Kapitel-2-Boss mit zwei Merkmalen") + check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponent.perks.size() == 2 and b2.game.opponent.life == 30, "Kapitel-2-Boss mit zwei Merkmalen und 30 Leben") check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf") await give_up() main.profile.campaign_stage = 0 @@ -862,13 +843,13 @@ func test_kampagne() -> void: main.save() -func test_kampf(n_opp: int, shot_turn: int) -> void: - await start_battle(n_opp) +func test_kampf(shot_turn: int) -> void: + await start_battle() var scr = screen() check(scr is BattleScreen, "Kampfbildschirm") scr.ai_delay = 0.0 var g: Game = scr.game - check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost") + check(g.players.size() == 2 and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost") await wait_for_human(scr) check(g.current() == g.human, "Spieler ist dran") check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar") @@ -898,12 +879,12 @@ func test_kampf(n_opp: int, shot_turn: int) -> void: func test_kampf_abbrechen() -> void: - await start_battle(5) + await start_battle() var scr = screen() scr.ai_delay = 0.0 var g: Game = scr.game await wait_for_human(scr) - check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner") + check(g.opponent != null and g.human.max_energy == 1, "Duell läuft") # Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit g.human.energy = 10 var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000) @@ -913,7 +894,7 @@ func test_kampf_abbrechen() -> void: scr.refresh() var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0] check(reiter_view.rarity == "episch", "Handkarte trägt die eigene Stufe") - await hover(scr.log_box) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird + await hover(scr.field_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird await hover(reiter_view) var previews = scr.preview_holder.get_children().filter(func(c): return c is CardView) check(previews.size() == 1 and previews[0].rarity == "episch", "Vorschau ebenfalls") @@ -930,11 +911,10 @@ func test_kampf_abbrechen() -> void: print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(), " dran=", g.current().name, " Sieger=", g.winner, " hovered=", get_viewport().gui_get_hovered_control()) check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover") - await click(find_button("Lupe")) - check(scr.zoom_player == scr.others()[0], "Lupe öffnet die erste Spalte") - await shot("12_lupe") - await click(find_button("Schließen")) - check(scr.zoom_player == null, "Lupe geschlossen") + await click(scr.b_log) + check(scr.log_overlay.visible and scr.any_overlay(), "Verlauf öffnet als Überlagerung") + await key(KEY_ESCAPE) + check(not scr.log_overlay.visible, "Esc schließt den Verlauf") # Runden laufen lassen, damit die Gegner ihre Züge machen for round in 4: await click(scr.b_end) @@ -946,4 +926,4 @@ func test_kampf_abbrechen() -> void: and g.log.any(func(l): return " greift " in l) check(ai_attacked, "Gegner spielen Karten und greifen an") check(scr.tiles.size() == g.players.reduce(func(acc, p): return acc + p.board.size(), 0), "eine Kachel je Wesen") - await shot("10_kampf_fuenf") + await shot("10_kampf_runden") diff --git a/translations/ui.csv b/translations/ui.csv index cab5825..b958198 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -1,6 +1,6 @@ keys,en Dinger,Dinger -Ein Kartenspiel: jeder gegen jeden,A card game: free-for-all +Ein Kartenspiel: Duell um das Feld,A card game: duel for the field Einzelspieler,Single player Kampagne,Campaign Netzwerkspiel,Network game @@ -62,18 +62,14 @@ Normal,Normal Schwer,Hard Profil zurücksetzen,Reset profile Große Schrift,Large text -Duell,Duel -%d am Tisch,%d at the table %d Münzen,%d coins Abbrechen,Cancel "Ja, zurücksetzen","Yes, reset" -"Speichern und zurück","Save and go back" -"Kampf fortsetzen","Resume battle" %s Schaden an ein gegnerisches Wesen,%s damage to an enemy creature %s Schaden an ein Ziel,%s damage to a target %s Schaden an alle gegnerischen Wesen,%s damage to all enemy creatures -%s Schaden an alle Gegner,%s damage to all opponents -%s Schaden an alle Gegner und ihre Wesen,%s damage to all opponents and their creatures +%s Schaden an den Gegner,%s damage to the opponent +%s Schaden an den Gegner und seine Wesen,%s damage to the opponent and their creatures Heile dich um %s,Heal yourself for %s Erhalte 1 Mana,Gain 1 mana Erhalte %s Mana,Gain %s mana @@ -117,14 +113,10 @@ Zuschauen,Watch Zuschauer,Spectator "Zuschauer: Hände sind verdeckt","Spectator: hands are hidden" Starten,Start -1 Gegner,1 opponent -%d Gegner,%d opponents -KI-Gegner,AI opponents Sammlung,Collection Verschmelzen,Fuse Zwei eigene Wesen wählen,Pick two of your creatures Zweites Wesen wählen,Pick the second creature -Feld: neutral,Field: neutral Zwei verschiedene Wesen wählen,Pick two different creatures Nur eigene Wesen,Only your own creatures Vulkan,Volcano @@ -150,10 +142,26 @@ Gewöhnliche Karten lassen sich nicht zerlegen,Common cards cannot be dismantled Aufgewertet,Upgraded +%d Staub,+%d dust +%d je Zug,+%d per turn -Neu würfeln (%d),Reroll (%d) +Neu würfeln ◆ %d,Reroll ◆ %d +Dein Zug,Your turn +Beim Ausspielen,On play Nicht genug Mana,Not enough mana Kein Angebot,No offer Hand voll,Hand full Profil wirklich zurücksetzen?,Really reset your profile? "Sammlung, Münzen und Fortschritt gehen verloren.","Collection, coins and progress are lost." Chimäre,Chimera +Angriff,attack +Eis,Ice +Resonanz +%d,Resonance +%d +Feld +%d,Field +%d +Merkmal +%d,Perk +%d +Feldkombo %s mit %s: %s,Field combo %s with %s: %s +1 Schaden an das stärkste gegnerische Wesen,1 damage to the strongest enemy creature +2 Schaden an den Gegner,2 damage to the opponent +Heile dich um 3,Heal yourself for 3 +Das neue Wesen erhält +1/+1,The new creature gets +1/+1 +%d Leben,%d life +Gegner mit %d Leben · %d Münzen bei Sieg,Opponent with %d life · %d coins for a win +Start gegen die KI,Start against the AI +Beginnt mit 5 Leben mehr,Starts with 5 extra life diff --git a/translations/ui.en.translation b/translations/ui.en.translation index b8d5c73bfc7eb1f0dcfe2a3c8db8d8db4e3ad006..2775cd564e6ea0f1eadd4fb025251f1c50bf5aef 100644 GIT binary patch literal 6712 zcma)B30#zA8vi&X132fYBKPj|#?_4;UF{#+h%t@5VK(ucKW@ql&axuGFxkR$fjCZf8OwP=CisHj6dY!2X{$n~(P6d=Aaa}s5Ccdy zQj?P(mxH1*hr#9GG{lR~e@zwTvmKxU_5$>A<3srkkoR=%jJ*nY3g`=@0z}W1*L-;; z+Bobd0w!RJOt;G`-A}`Qj(lFMpwk_FmdmtNio12#*UIND3i=1~zCm6a6?aYY{s`8u zfm6Wu0NMBf>s8>Ue7=v>2g#y4_UVXouuO}<8V&T8&x}X8rpkAjSo8k}pHhbQ;}pD; zu`dIxfB?(`UIMCsb%0W~R&m~m{cd27g2s3cfz}Ls1N=*Ie_gTv1$$3Vlsym)^ae;i zNnW3p*Hn2OF0a}0O1z`7*8$@dv=^{11FXOdU=gq!SOwGoBuAfjuzw%et)LNoUz_tG zoST7jGW{af-+=po5Bfa-Ah}?y5kRzjCK~%BfR+jjm+!K%js^+<6F{}E0;XV(v`Dy+ z9=49&#t&x#qeriPKQR+cL^3p&7=2&Wift!xrolk14pq|K#XIu{pTU88i%>YJymLC_ zNtX21%?U~@Kts}*fUqRFZQlgE^(PQ$phN&u58s)%3uhJfNdU9^gmC@pZRn$(CNO#T z&aOf0VYe%2B){Ru2QyDVz9aTD9GHLQ6&dwk$nj82-oCfi^?)Lh>rUsN*4`QkxiIXX z1eo1$KQ3OtnZ}kfKc?5R207sb#Y$t7@&9dY!#S!s%1tML`B^WF&C-L;{J*#%bIEBq zLV78eO#aB+q^x9|X>Jl2-Ea2isal2qj6NN49R{6rDE<5Es6F~5FcFQw{CH(#M%PIB zok;)qvt9ZH%6^b9Oz-C6zaOU_KuRPq`U(H%yMLkJXL=W;^qrpzUh;$BJ96FC5D1gJ zQg6uW*I#}`@s3RI&{&W4JHShCLtyWAW8m<6T@-Ood1rUoeVl1-(>tx{x^et@*&k*6 zE1zq&SK^-DhIpABZSvV{0gn&@TBL%Waq95>iwZl8-=k^edIR{{=AqB4cLI-tzYFfD z?ihdR>f&RIaiF@Q`eXEMlPA=tAtyH}1X z_*wq5Uh8J9RJ>QM=E~0H@b5p~|LXp{0?4yEOt{>uax&!Dndz(A?SGkOCe?QbVC#t~ zElZIE+EZ-I&w2ZkU)3Wn_8yG>LHCti17vyXMMnR=_Vo^3z)$@_x*2^=|4H676Dem= z09=>uR=w9!eGq$A7rpjZhRz2c#YH-9t#*yyfHV2j186sD)bnEzOxt)?t84o91RwQx zG;nr9%49G2!R7?6Bzs#fx7^BtQ&cyEZ&FrX`W4EEkKT^?yYaotx65!QKM7DJ-JMw) zccU|ysOHJX12>P=4aS-I)3xQZk)<%!ww@|JYS_LEOl|A3?2|J~pN0XlK{}ZI=JgAc z)lkCfgZbZi2XC*(f!>K`>UHl!hp!-cRHw?h*3Y$b&}Jwl9?A*nC*2pWosD*5L$iTo zzWnHR_$3&ow<6lO#^2lJoTvvyLlaF;-PnLyjQaIBV6{yp1|T=d-F#9dYOm(ON_01^)0MS53^fy z{k41T;5YM!$sc=p$Ae2?A{+GH6BZbolVF$XjoygK2P|kFv;#!SD}m92x9$CNH_++b z=%+P{=PO-ayTOWhLPAdLDUkVSUVJEKEvS(7ksR~K5P15=Vw}kzvQztyJ3m=qEF60R zv+Fgmr|EeFNp|V`i`l(X`^mCPP)58-fW~aG)+qc~{=yrp*TOEV8_E;utXY4z%UG-| zZ^QOo-`*OKN!8!nu^ppkf$CaU5q~B_TBRt(BF3c z9@*LCHwa3IpTPK+=B9kM19Ykj0;6}Se51G*4sGp*Mn@7`W2YSXNu>* z9FG-t7;jtO19pA6m4{xckwie$UVN`e<|AIFZ*FZYyU_2G3&HlVZ|-lXbREDY%@-A5zwwN26ol|J5}2HKli$%>pb;P037lRy zpc?vF-+#R`;x{86f$rh~=Ese{m{y5UMl(lAukFA5R4n+}JFxfyfAOw)3Pvd&lG&2l zP&o(k^nDWzFuk{@1}^**be2~}_qXIb3!#kql0f?DZt8(Q4oJs_@w=xT(kuZVdxt%_ ztB1}2GsQ#iH2d0+Za>05<_FVXQn~kVsmw>bEI&g7)_l7bOfwPz z|8j64a2i z36Q5;DD!c;dF-%Bps{$Xj&5C%4ke85+oZ-%o_$O_NkiZ2dI^40KB58U&#D(2wtozM z_O6Vs)o#iB7JA8dnx)LoXJ+=D&>l==k7Rzpe?N`KnY{;lH{T_nCDL3ZI^{5UR&2j| ztRzSM{n_wNg+a&z3Knr{{Ax7 zWfr&$gRz{`3kHXP3xmU0#;Gk9PRttSRn?yKRz;KtkB z_87*A89#~Ty4n5XL~f$ZR>5g3HYW^^)3(IS@)5bwwo1GCOmS|n za|_<&M9s%OMXQa1$F65&jb0xIM}_G5F3aGY$>rM|6$T5JYj7Y~-9XQnG-txko-jFD z+Y;n?m*R+xbalXcX$)2)e&ta`L={Q&kWlaKExJM5mL=vyMD?m7sn}K;OM!e0t7bd{ zAMpg%rlq|zrtNvq;*P$fNE>nH&UjycvM4w;6N=%1nB#30Q|`3)mbbAX+ogSWl;`P& z`2L=c+#t%jE_u#z17q5f8-C}D&eN+ZKAd^CuJ}}U^|Ao5SIA^~0_oDuF6T`!;qr{w z>tH(hqAqjv4?RGh`nPz6wECy6en_0x5+lG9Xqa-MS~CiW1yMNuNh+tF=>E$Ldl$R+AK zy-5Ay;JHEriXKny5YX~w2RFi6!aJ-s6bNruVH9v;-lQ?!9S~Sbk}Lj}5Y znt>Zl9cRT9K{K0hftum;1}l7z4O!@iri^rZ_U;oK8M?`fI+nE<`Uhq)V?=#(L{rxQ zD3>>>2h-o4S`s>iT2vZFqQJgLE zJ`8asrWIzXLj8z9mUCepKLzfohOY>i|I&o!$Hk8KHTbpU>!WR~o zG)znwkQ`DM8`(Tc`s&c}4x7cIQk`3~Sel#i0G7*`hMZ)#wPEv%+a-{h+$YMU@kZY) zQAIKOlXGeKNMA3l_0ZGR;q75*&LL56hD{Cj$+MDcC6+}ZXAlJXCL3Yo@kQXYS!p(@ zFy&C}=qYJ8hR4?-k?`1ck*>sv#mHKLl;>1A+bNrAfCbin-lHXtwK+|(eywyipdO7O zw?}_2_o}Cw3Xa;Dif6yuvrh_#SGJf-=&P5e+O6}LnrjmLk)p<3PfHUtBy*LT+DqSb mGi-==hrB?4H}ptn@@l}gyPt1dlK|`Z(C;GpZKA{fGx|3xsg{fY literal 6396 zcma)B30PBC7JfjahzeSftr&wClo3R9#1?T0VG$LAi3(~*h{*#YAqiQuC|Z|RTdlN; zMZVUlYg?^bi*;OzRT=BjVOn&?Db>;X*=ild1$9PI`!fFt@6spZ%s2VI-*@lX&$;KG z7ot|DRdeD;hUasDFVG9n*qo+P(_G#-$*#9J%zCHEY5}?J&=>IzPkbH?o(l%uW_H=3 zZFiY@N4|A7Z?~I_Jl6%BV!fj{->IKz=DBXz&U6_{c&AA41Hs|6n=D0b1|0^w$>s!+ z9?+}rW20G0x4fDeEz0Q2{WWW68jdf=plc1E&p!ulf6EYkjk=M!;F_Po7u z-hdn!05Bhe#Px3_G{&Q6l7ya$^*A6$LYsnh0Z<6ofVseHz;d7lV74}i>#Y(R<4K>j z;O_^Hi*gO(^BkT(0oTR-eLNol9A7s?Xy}aEVBV<`SbHt6sVNEcP@Q=&Hk%hj;Dat1rqRPj`!uJH6z2Ble_A zc55pF;L(q3xsIp?kBQJ~XZB``nkPlE!E!*~#A5dfu3ypqzCy*I_UC^nhjy>(6~ zl3Z_5hUCKixtKCMY1~NjGOkG*PUl52(zs*({`6kmCc=L2;A)yo)`D%tQew4g$0LVZoozs-e`e&YH%Gsk=a@oGCwcZVM9zznX?U#X-2= z>FY0IkSz1#H*Q_?R2US8@4mhZ@w2)x|D%*%>vuqw&W6DJv?PvsoFu8ktX&6- z)ljDS`vR~wXmg%YtgEyhl`l8iXhu@pB+LA0;xDG3f_?~SatXcD{uX{I^6VH_%DILo zfgrNDm>sW^mFsDScdXaIw;y(Uf_Qs^NBzg-i>eAvFTsZTjb1*CzJ2D@y#^#p@${3> z=Y>6p{TX{!UnUAUn!iBR;#)AhE;xB5h4C%LkJ4-lV?q z=^n)WAN|w(RhAC=tS+(F`>)D>MxCk;cDddQbUJ@&TnoO6e|s+SW9P;4_(Si?pw6Ov zcS#K#S7?|AF z>#g_b{YK|O_*+8dKgJ;#lA|+a@o(O9{Q-|XO6&onAK}jV$k0TTD*-M%Iv18lw**3n z?hfU0;_m6%Vc4@cgIW)dGa`tN{a1RRZs#Qkb?oorLl>4$hja2zHdy@6@8)giv7s}g zFK4%8UpK_TDD^MhFD5sr{9ch88+MLET%QhafHBgc8nL*q+`QPA-lJ4ovT^3XgYZ$P z2jxbzN%jA7Ka}j5{aY>Fc@y}QFZHl$SxR{i7$w{B0IOG*U$W}y`%1??KRf2=m(&B~ zGaO*?1pMG?E<>EGZUalh?heC-^r;8x9~W*5fZ|}R2`rxX?zZe*C(2OVamw1aKgN^U zVexFZwdV0O=(D(){^{3u|8f;VOw=~WW+%+(wQ-RbLr11y(IB4dhqt6zn=wR zI!F5I&GPw+Hmg&Z=2wZ zYDHjvT?Z~5S^*`JqaJ2-pTvH~TEt25(EF0vRh!I~6Owq|n(=zUA;o zXGHHO7O&8&_VvXQdyL*=ZD~?DT5H#2JEEVNQ zmf4$MGH6{C_Ut?rzOVXz3Hvk$C~g+dw%zK#mOzP}FHC!Qp_k%wv25@ACmUC}Zh=X2 zLjl-YUea!W5=!p@^D8^)|Lq;nNRIq;o0B{2LonIBWqw;eFs>eh!=X7512Fw=_hlO< z!Vkqsysb%ft4_lS&F;Pc)9+%=c1(g2^*e#ZU3TJ~VM!2Tb#%}ET3rn}R>vb_t46Iy zy(k{)C1$U1)zOAZkxo2jZ&cT{->-!b%~Y!SBiAnr=x+nkCG0+X|I}9Kb*#hgxtrH# zfKF#j2p*%zI4Y52ezkS#{`5Bu$wmmeCIZN|C-#Bq}9%wAD>TkELq`#4v z-1md(4^=!PpFyKO3F;0yoqu0|#kqP(-OkIPv-5%}5AUV&!Bc%9M0SaH6@Q;h#-7!e zorB+-hvTRpi0-N9T;-5jtVxgV(%~^Zawj8Ssv*fSzgHsF=R&Yyc~Jf6`$Pd2LTdU8 z0w<_4HS!TMvyfCA>D`dpU;{~)o;&|d9hho;sZDPxvT)hFbB@(sq9~$YNd!$}%rsx$ ztQY(RPRBc)o*y?Rx=c;tlFRWol3_m` ztEq|o{6(Vnm6{CC+U+%(6H;yqCE9sIqXll7)mqA_%~l5-PtvqT&Gi+y3DzQRiq-5w zFj+2#$-t>H6%HHE8<9@u%ir`tU=#F~A{Tzg;Zm$thm*HEU^6;{!me=p3`iI}cw?Zy z+bf0>wBFXduo}dYs@$2AD{$$iqGBhfjrFOKRQRSDz8bkRHkvZeE-1~rDH_S_{Oel9jfmDP3ZJ(n>g6^^ zV`BX-!1olB!D+RZM{=orA+L9G1Cg46lH^iNg@q=A%j_)Y()5mUF57A^)tkAodOMQU zCVEFDJ7Vv6!{r1`Yk>D7*h+Qr=n$oRnOA^p8p@BQD-h?W)>{n3L5D==5^3!ukDRWsHdNp;c&9_5D>m_Fx;js5wMl3!3c3ksSKOcs zY6tZF98)1~c$-?P)rDU^6*~OLS;BL!xVSz7w>VPZcpL6zZtF;mPT=BnJ(keTvh!T7 z9@hiSVRvFUnCx7prI5EFVBd3`DF^K3mgf$pXS z^9I-62nM=~)9EdUT^X{-AJFTxgm9*AuYn2eS3LL1_-B#RG(SFztnz!7^kiF+U2o)#o;hVLq-kZqt#z=O z#a;!(kEhXv(X>w`-Un4p=FH^%A+EUO+%$BRke2(z2fZoUP7b zvf30*6S@HdC_*F8&yDaXd*(3r)Inl$ZQmC?VtMEmBn|^BQc^s#TNNC6En}INHaWnL z-Ye>=xXK7kD2=~QKBQ@mSXf@3A!Wqi(9adPV|21(MC38#7K)?Lyjbvj$DsGH I7XR<@Z-c;5-T(jq