From dfcd3cb713b7ae0a7b1a988f573ef6ead0e5af16 Mon Sep 17 00:00:00 2001 From: Team3 Date: Tue, 22 Sep 2026 13:23:02 +0200 Subject: [PATCH] update --- .github/workflows/tests.yml | 9 +- README.md | 79 +- anforderungen.md | 29 +- data/archetypen.json | 12 - data/karten.json | 1075 +------------------------- scripts/ai.gd | 85 +- scripts/campaign.gd | 57 +- scripts/cards.gd | 160 ++-- scripts/deckgen.gd | 159 +--- scripts/game.gd | 391 +++++----- scripts/net.gd | 51 +- scripts/netstate.gd | 65 +- scripts/profile.gd | 523 +++---------- scripts/ui/battle_fx.gd | 42 +- scripts/ui/battle_gamepad.gd | 41 +- scripts/ui/battle_layout.gd | 8 +- scripts/ui/battle_mulligan.gd | 63 -- scripts/ui/battle_mulligan.gd.uid | 1 - scripts/ui/battle_screen.gd | 295 ++++--- scripts/ui/booster_screen.gd | 77 +- scripts/ui/campaign_screen.gd | 2 +- scripts/ui/card_grid.gd | 8 +- scripts/ui/card_view.gd | 17 +- scripts/ui/collection_screen.gd | 96 +-- scripts/ui/creature_view.gd | 12 +- scripts/ui/deck_screen.gd | 422 ---------- scripts/ui/deck_screen.gd.uid | 1 - scripts/ui/lobby_screen.gd | 2 +- scripts/ui/main.gd | 4 +- scripts/ui/menu_screen.gd | 37 +- scripts/ui/opponent_select_screen.gd | 28 +- scripts/ui/settings_screen.gd | 3 +- scripts/ui/sfx.gd | 3 - scripts/ui/style.gd | 85 +- tests/frame_probe.gd | 5 +- tests/matchup.gd | 35 - tests/matchup.gd.uid | 1 - tests/net_bot.gd | 14 +- tests/perf_probe.gd | 15 +- tests/simulate.gd | 19 +- tests/strength_probe.gd | 41 - tests/strength_probe.gd.uid | 1 - tests/test_runner.gd | 1050 +++++++++---------------- tests/ui_test.gd | 431 ++++------- translations/ui.csv | 112 +-- translations/ui.en.translation | Bin 7732 -> 6396 bytes 46 files changed, 1525 insertions(+), 4141 deletions(-) delete mode 100644 data/archetypen.json delete mode 100644 scripts/ui/battle_mulligan.gd delete mode 100644 scripts/ui/battle_mulligan.gd.uid delete mode 100644 scripts/ui/deck_screen.gd delete mode 100644 scripts/ui/deck_screen.gd.uid delete mode 100644 tests/matchup.gd delete mode 100644 tests/matchup.gd.uid delete mode 100644 tests/strength_probe.gd delete mode 100644 tests/strength_probe.gd.uid diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 33ce9fa..a783458 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -72,10 +72,5 @@ jobs: chmod +x godot - name: Import run: ./godot --headless --path . --import || true - - name: Archetypen messen - run: ./godot --headless --path . -s tests/strength_probe.gd 2>&1 | grep -E "%|geschrieben" - - name: Ergebnis sichern - uses: actions/upload-artifact@v4 - with: - name: archetypen - path: data/archetypen.json + - name: Balance messen + run: ./godot --headless --path . -s tests/simulate.gd 2>&1 | grep -E "Siege|Dauer" diff --git a/README.md b/README.md index 18f3fbe..e83bfef 100644 --- a/README.md +++ b/README.md @@ -4,75 +4,58 @@ Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle ## Starten +Projektordner in Godot öffnen und auf „Play" drücken, oder im Terminal: + ``` ./start.sh # Linux/macOS (start.bat unter Windows); Godot 4.7 muss als `godot` im Pfad liegen +godot --path . # wenn das Projekt schon einmal importiert wurde ``` Frisch geklont fehlt das Verzeichnis `.godot` mit Godots Import-Cache. Die Startskripte holen den Import beim ersten Start nach; alternativ das Projekt einmal im Godot-Editor öffnen oder -`godot --headless --path . --import` ausführen. Danach genügt `godot --path .`. - -## Start - -Projektordner in Godot öffnen und auf „Play“ drücken, oder im Terminal: - -``` -godot --path . -``` +`godot --headless --path . --import` ausführen. Der Spielstand liegt unter `user://spielstand.json` (Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`). ## Spielsysteme -- 100 Karten in `data/karten.json`: 4 Elemente, 10 Zweier-Kombos (auch gleiche Elemente), 4 mythische Dreifach-Kombos, 4 Fallen -- Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus -- Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht) -- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern je Kapitel, Tagesaufgabe -- Booster, fünf Deck-Slots, Sammlung (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen) -- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`) -- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten -- Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand) -- Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch -- Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1 -- Bestenliste (Serie, schnellster Sieg) im Hintergrund: sie steuert den Schwierigkeitstipp -- Zehn Fallenkarten, Netzwerk mit Wiederverbinden (Platz bleibt 60 s frei) -- Deckstärke aus Seltenheit, Schlüsselwörtern und Effekten; Gegnerdecks werden auf die eigene Stärke gebaut -- Schwierigkeit Leicht/Normal/Schwer: Zielstärke 0,9/1,0/1,2-fach, schwache oder starke Archetypen, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach -- Gegnervorschau vor dem Kampf (Element und Stärke, neu würfelbar), Vorschlag zur Schwierigkeit nach drei Siegen oder Niederlagen -- Deckstärke mit Kurvenabzug und Gewichtung durch die eigene Kartenstatistik; Booster schlagen passende Karten fürs Deck vor -- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben, Gischtläufer für Sturm -- Gegnervorschau mit höchstens drei Neuwürfen, Schwierigkeitstipp nach Spielverlauf (deutliche Siege oder drei Niederlagen) -- KI mit Zeitbudget je Entscheidung (150 ms), Warnung bei ungespeichertem Deck -- Lava trifft nur noch das stärkste gegnerische Wesen; Archetypen-Werte kommen aus `data/archetypen.json` (Messlauf schreibt sie) -- KI-Vorausschau in zwei Stufen (erst alle Züge mit den besten Zielen, dann die acht besten vertieft): bei 5 Gegnern 0,1 s statt 2 s, und sie rechnet im Hintergrund-Thread, Musik ebenso -- KI-Bewertung mit Bedrohung (gegnerische Angriffskraft ohne eigenen Wächter, tödliche besonders) und Führenden-Bremse; im Duell 62:38 gegen die alte Bewertung +- 77 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos entstehen erst im Spiel (Verschmelzen, Feldkombos) +- 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 +- 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) +- 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 +- 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 +- 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 -- Gamepad im Deckbau, in der Sammlung und beim Booster: Karten, Deckzeilen und Kartenrücken sind fokussierbar, A wählt, B geht zurück -- Englisch auch für Kartennamen und Kartentexte (`name_en` in `data/karten.json`, Textbausteine über `tr()`) -- Deck-Codes zum Teilen (`DINGER1:id*anzahl,…`) mit Zwischenablage im Deckbau -- Kapitel 3 der Kampagne (drei Merkmale, volle Tische), acht neue Karten (je Elementpaar eine, zwei Fallen), Elementbooster für 120 Münzen -- Netzwerk: fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung (er bekommt laufend den vollen Spielstand), die anderen verbinden neu; Zuschauer sehen die Partie ohne Hände -- Balance in drei Messrunden (je 200 Partien pro Archetyp): Sand gibt +1/+1 statt Schild, Wasser und Feuer zäher, Lava-Karten leicht schwächer; Spanne jetzt 40–64 % statt 36–78 % (Feuer+Erde bleibt mit 64 % die Spitze) -- Große Schrift: Kartennamen neben dem Kostenkreis, Kartentext läuft nicht mehr über, breitere Knöpfe in Booster, Deckbau und Sammlung +- 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 ## Tests `tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`: -Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test und Archetypen-Messung nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft). +Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft). `DINGER_FAST=1` lässt den Fenster-Test ohne die langen Kämpfe laufen (etwa ein Drittel der Zeit). ``` -godot --headless --path . --import # einmalig nach dem Klonen: Ressourcen importieren (macht start.sh auch) +godot --headless --path . --import # einmalig nach dem Klonen: Ressourcen importieren godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI -godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar -godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH) DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots -DINGER_ONLY=test_deckbau godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests +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) -N=200 godot --headless --path . -s tests/strength_probe.gd # Messlauf mit 200 statt 60 Partien je Archetyp -godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Deckerzeugung, Musik +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 ``` @@ -88,11 +71,11 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe ## Aufbau - `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json` -- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln -- `scripts/deckgen.gd` – Deckstärke, Gegnerdecks entlang der Energiekurve, Archetypen +- `scripts/game.gd` – Spielregeln, Züge, Kampf, Feldkombos, Resonanz, Feld, Verschmelzen +- `scripts/deckgen.gd` – Ladenangebot und Gegner - `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) -- `scripts/profile.gd` – Spielstand (versioniert), Booster, Tagesaufgabe, Einstellungen +- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Booster, Einstellungen - `scripts/campaign.gd` – Kampagnenstufen - `scenes/main.tscn` – Startszene - `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden diff --git a/anforderungen.md b/anforderungen.md index f72a51a..8363584 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -1,25 +1,30 @@ -# Dinger +# Cards +## + ## 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 -- Kampagne gegen benannte Magier und Tagesaufgaben als Fortschritt +- Kampagne gegen benannte Magier als Fortschritt - Im lokalen Netz zu mehreren spielbar, auch als Zuschauer - Ein unterbrochener Kampf lässt sich später fortsetzen -- Bestenliste als Langzeitziel ## Sammeln -- Booster öffnen -- Karten haben Seltenheiten -- Aus der Sammlung werden Decks gebaut (mehrere Slots) und als Code geteilt -- Überschüssige Karten lassen sich zerlegen und neue herstellen +- 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) +- 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 +- Nicht ausgegebenes Mana bleibt liegen, neu würfeln kostet 1 Mana (wie in TFT) +- Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen ## Elemente -- Karten gehören zu Elementen -- Elemente sind kombinierbar, daraus entstehen viele Varianten -- Gleiche Elemente sind auch eine Kombo (Feuer + Feuer) -- Jedes Element und jede Kombo hat eine eigene Spielweise +- 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 -- Verdeckte Fallen bringen Bluff und Reaktion im gegnerischen Zug +- 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 +- 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/data/archetypen.json b/data/archetypen.json deleted file mode 100644 index f1965eb..0000000 --- a/data/archetypen.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "Erde": 50, - "Erde+Luft": 56, - "Feuer": 40, - "Feuer+Erde": 64, - "Feuer+Luft": 42, - "Feuer+Wasser": 57, - "Luft": 45, - "Wasser": 43, - "Wasser+Erde": 50, - "Wasser+Luft": 41 -} \ No newline at end of file diff --git a/data/karten.json b/data/karten.json index a1dd143..60d6425 100644 --- a/data/karten.json +++ b/data/karten.json @@ -1,23 +1,5 @@ { "karten": [ - { - "id": "f_funke", - "name": "Funke", - "name_en": "Spark", - "elements": [ - "Feuer" - ], - "cost": 1, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_any", - "amount": 2 - } - ] - }, { "id": "f_kobold", "name": "Feuerkobold", @@ -26,8 +8,6 @@ "Feuer" ], "cost": 1, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 2, "health": 1 }, @@ -39,29 +19,9 @@ "Feuer" ], "cost": 2, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 3, "health": 2 }, - { - "id": "f_feuerball", - "name": "Feuerball", - "name_en": "Fireball", - "elements": [ - "Feuer" - ], - "cost": 3, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_any", - "amount": 4 - } - ] - }, { "id": "f_asche", "name": "Aschekrieger", @@ -70,8 +30,6 @@ "Feuer" ], "cost": 3, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 4, "health": 3 }, @@ -83,8 +41,6 @@ "Feuer" ], "cost": 5, - "rarity": "selten", - "kind": "Wesen", "attack": 6, "health": 5, "effects": [ @@ -95,23 +51,6 @@ } ] }, - { - "id": "w_eis", - "name": "Eissplitter", - "name_en": "Ice Shard", - "elements": [ - "Wasser" - ], - "cost": 1, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "freeze", - "target": "enemy_creature" - } - ] - }, { "id": "w_geist", "name": "Flussgeist", @@ -120,13 +59,11 @@ "Wasser" ], "cost": 1, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 2, "health": 2, "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 1 } @@ -140,29 +77,9 @@ "Wasser" ], "cost": 2, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 3, "health": 3 }, - { - "id": "w_gezeiten", - "name": "Gezeiten", - "name_en": "Tides", - "elements": [ - "Wasser" - ], - "cost": 2, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "draw", - "target": "none", - "amount": 2 - } - ] - }, { "id": "w_frost", "name": "Frostmagier", @@ -171,8 +88,6 @@ "Wasser" ], "cost": 3, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 4, "health": 4, "effects": [ @@ -190,8 +105,6 @@ "Wasser" ], "cost": 5, - "rarity": "selten", - "kind": "Wesen", "attack": 6, "health": 6, "effects": [ @@ -209,8 +122,6 @@ "Erde" ], "cost": 1, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 0, "health": 4, "keywords": [ @@ -225,33 +136,12 @@ "Erde" ], "cost": 2, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 2, "health": 4, "keywords": [ "Wächter" ] }, - { - "id": "e_haerten", - "name": "Verhärten", - "name_en": "Harden", - "elements": [ - "Erde" - ], - "cost": 2, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "buff", - "target": "friendly_creature", - "amount": 2, - "amount2": 2 - } - ] - }, { "id": "e_golem", "name": "Felsgolem", @@ -260,8 +150,6 @@ "Erde" ], "cost": 3, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 3, "health": 5 }, @@ -273,8 +161,6 @@ "Erde" ], "cost": 4, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 3, "health": 6, "keywords": [ @@ -289,8 +175,6 @@ "Erde" ], "cost": 5, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 9, "keywords": [ @@ -312,8 +196,6 @@ "Luft" ], "cost": 1, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 1, "health": 1, "keywords": [ @@ -328,31 +210,12 @@ "Luft" ], "cost": 2, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 3, "health": 1, "keywords": [ "Flink" ] }, - { - "id": "l_stoss", - "name": "Windstoß", - "name_en": "Gust", - "elements": [ - "Luft" - ], - "cost": 2, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "bounce", - "target": "enemy_creature" - } - ] - }, { "id": "l_taenzer", "name": "Sturmtänzer", @@ -361,8 +224,6 @@ "Luft" ], "cost": 3, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 3, "health": 3, "keywords": [ @@ -377,8 +238,6 @@ "Luft" ], "cost": 3, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 2, "health": 2, "keywords": [ @@ -393,8 +252,6 @@ "Luft" ], "cost": 5, - "rarity": "selten", - "kind": "Wesen", "attack": 5, "health": 4, "keywords": [ @@ -402,42 +259,20 @@ ], "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 1 } ] }, - { - "id": "ff_walze", - "name": "Flammenwalze", - "name_en": "Flame Roller", - "elements": [ - "Feuer", - "Feuer" - ], - "cost": 4, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 3 - } - ] - }, { "id": "ff_elementar", "name": "Inferno-Elementar", "name_en": "Inferno Elemental", "elements": [ - "Feuer", "Feuer" ], "cost": 6, - "rarity": "episch", - "kind": "Wesen", "attack": 7, "health": 5, "effects": [ @@ -453,12 +288,9 @@ "name": "Phönix", "name_en": "Phoenix", "elements": [ - "Feuer", "Feuer" ], "cost": 7, - "rarity": "legendär", - "kind": "Wesen", "attack": 8, "health": 6, "keywords": [ @@ -472,45 +304,19 @@ } ] }, - { - "id": "ww_eiszeit", - "name": "Eiszeit", - "name_en": "Ice Age", - "elements": [ - "Wasser", - "Wasser" - ], - "cost": 3, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "freeze", - "target": "all_enemy_creatures" - }, - { - "kind": "draw", - "target": "none", - "amount": 1 - } - ] - }, { "id": "ww_schrecken", "name": "Tiefseeschrecken", "name_en": "Deep Sea Terror", "elements": [ - "Wasser", "Wasser" ], "cost": 6, - "rarity": "episch", - "kind": "Wesen", "attack": 7, "health": 7, "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 2 } @@ -521,12 +327,9 @@ "name": "Leviathan", "name_en": "Leviathan", "elements": [ - "Wasser", "Wasser" ], "cost": 8, - "rarity": "legendär", - "kind": "Wesen", "attack": 8, "health": 8, "keywords": [ @@ -534,7 +337,7 @@ ], "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 2 }, @@ -550,12 +353,9 @@ "name": "Felsbastion", "name_en": "Rock Bastion", "elements": [ - "Erde", "Erde" ], "cost": 3, - "rarity": "selten", - "kind": "Wesen", "attack": 1, "health": 7, "keywords": [ @@ -568,12 +368,9 @@ "name": "Bergriese", "name_en": "Mountain Giant", "elements": [ - "Erde", "Erde" ], "cost": 6, - "rarity": "episch", - "kind": "Wesen", "attack": 6, "health": 9, "keywords": [ @@ -585,12 +382,9 @@ "name": "Titan der Erde", "name_en": "Titan of Earth", "elements": [ - "Erde", "Erde" ], "cost": 8, - "rarity": "legendär", - "kind": "Wesen", "attack": 8, "health": 12, "keywords": [ @@ -605,40 +399,14 @@ } ] }, - { - "id": "ll_wirbel", - "name": "Wirbelsturm", - "name_en": "Whirlwind", - "elements": [ - "Luft", - "Luft" - ], - "cost": 3, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "bounce", - "target": "enemy_creature" - }, - { - "kind": "draw", - "target": "none", - "amount": 1 - } - ] - }, { "id": "ll_adler", "name": "Sturmadler", "name_en": "Storm Eagle", "elements": [ - "Luft", "Luft" ], "cost": 5, - "rarity": "episch", - "kind": "Wesen", "attack": 4, "health": 4, "keywords": [ @@ -651,12 +419,9 @@ "name": "Herr der Winde", "name_en": "Lord of Winds", "elements": [ - "Luft", "Luft" ], "cost": 7, - "rarity": "legendär", - "kind": "Wesen", "attack": 6, "health": 6, "keywords": [ @@ -665,46 +430,20 @@ ], "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 2 } ] }, - { - "id": "fw_wolke", - "name": "Dampfwolke", - "name_en": "Steam Cloud", - "elements": [ - "Feuer", - "Wasser" - ], - "cost": 2, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_creature", - "amount": 2 - }, - { - "kind": "freeze", - "target": "enemy_creature" - } - ] - }, { "id": "fw_kessel", "name": "Kesselgeist", "name_en": "Cauldron Spirit", "elements": [ - "Feuer", - "Wasser" + "Feuer" ], "cost": 4, - "rarity": "episch", - "kind": "Wesen", "attack": 3, "health": 3, "effects": [ @@ -714,7 +453,7 @@ "amount": 2 }, { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 1 } @@ -725,12 +464,9 @@ "name": "Geysir-Drache", "name_en": "Geyser Dragon", "elements": [ - "Feuer", "Wasser" ], "cost": 7, - "rarity": "legendär", - "kind": "Wesen", "attack": 5, "health": 6, "effects": [ @@ -750,12 +486,9 @@ "name": "Lavagolem", "name_en": "Lava Golem", "elements": [ - "Feuer", - "Erde" + "Feuer" ], "cost": 3, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 3, "effects": [ @@ -771,12 +504,9 @@ "name": "Magmawall", "name_en": "Magma Wall", "elements": [ - "Feuer", "Erde" ], "cost": 5, - "rarity": "episch", - "kind": "Wesen", "attack": 2, "health": 7, "keywords": [ @@ -795,12 +525,9 @@ "name": "Vulkanherz", "name_en": "Volcano Heart", "elements": [ - "Feuer", - "Erde" + "Feuer" ], "cost": 7, - "rarity": "legendär", - "kind": "Wesen", "attack": 6, "health": 8, "keywords": [ @@ -814,36 +541,14 @@ } ] }, - { - "id": "fl_schlag", - "name": "Blitzschlag", - "name_en": "Lightning Strike", - "elements": [ - "Feuer", - "Luft" - ], - "cost": 2, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_any", - "amount": 3 - } - ] - }, { "id": "fl_vogel", "name": "Donnervogel", "name_en": "Thunderbird", "elements": [ - "Feuer", "Luft" ], "cost": 4, - "rarity": "episch", - "kind": "Wesen", "attack": 3, "health": 3, "keywords": [ @@ -856,12 +561,9 @@ "name": "Gewittergott", "name_en": "Thunder God", "elements": [ - "Feuer", - "Luft" + "Feuer" ], "cost": 7, - "rarity": "legendär", - "kind": "Wesen", "attack": 7, "health": 6, "keywords": [ @@ -880,12 +582,9 @@ "name": "Sumpfkröte", "name_en": "Swamp Toad", "elements": [ - "Wasser", - "Erde" + "Wasser" ], "cost": 2, - "rarity": "selten", - "kind": "Wesen", "attack": 1, "health": 5, "keywords": [ @@ -903,12 +602,9 @@ "name": "Moorwächter", "name_en": "Bog Guardian", "elements": [ - "Wasser", "Erde" ], "cost": 5, - "rarity": "episch", - "kind": "Wesen", "attack": 4, "health": 8, "keywords": [ @@ -921,12 +617,9 @@ "name": "Sumpfkoloss", "name_en": "Swamp Colossus", "elements": [ - "Wasser", "Erde" ], "cost": 7, - "rarity": "legendär", - "kind": "Wesen", "attack": 5, "health": 14, "keywords": [ @@ -940,40 +633,14 @@ } ] }, - { - "id": "wl_boe", - "name": "Sturmbö", - "name_en": "Squall", - "elements": [ - "Wasser", - "Luft" - ], - "cost": 2, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "freeze", - "target": "enemy_creature" - }, - { - "kind": "draw", - "target": "none", - "amount": 1 - } - ] - }, { "id": "wl_nebel", "name": "Nebelreiter", "name_en": "Mist Rider", "elements": [ - "Wasser", - "Luft" + "Wasser" ], "cost": 4, - "rarity": "episch", - "kind": "Wesen", "attack": 4, "health": 4, "keywords": [ @@ -991,12 +658,9 @@ "name": "Gischtläufer", "name_en": "Spray Runner", "elements": [ - "Wasser", - "Luft" + "Wasser" ], "cost": 2, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 2, "keywords": [ @@ -1004,7 +668,7 @@ ], "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 1 } @@ -1015,12 +679,9 @@ "name": "Sturmkönigin", "name_en": "Storm Queen", "elements": [ - "Wasser", "Luft" ], "cost": 6, - "rarity": "legendär", - "kind": "Wesen", "attack": 6, "health": 6, "keywords": [ @@ -1038,12 +699,9 @@ "name": "Sandläufer", "name_en": "Sand Runner", "elements": [ - "Erde", "Luft" ], "cost": 2, - "rarity": "selten", - "kind": "Wesen", "attack": 2, "health": 2, "keywords": [ @@ -1056,12 +714,9 @@ "name": "Dünenwächter", "name_en": "Dune Guardian", "elements": [ - "Erde", - "Luft" + "Erde" ], "cost": 4, - "rarity": "episch", - "kind": "Wesen", "attack": 3, "health": 4, "keywords": [ @@ -1074,12 +729,9 @@ "name": "Wüstenkönig", "name_en": "Desert King", "elements": [ - "Erde", - "Luft" + "Erde" ], "cost": 7, - "rarity": "legendär", - "kind": "Wesen", "attack": 5, "health": 7, "keywords": [ @@ -1096,24 +748,6 @@ } ] }, - { - "id": "f_glutpfeil", - "name": "Glutpfeil", - "name_en": "Ember Arrow", - "elements": [ - "Feuer" - ], - "cost": 2, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_creature", - "amount": 3 - } - ] - }, { "id": "f_elementar", "name": "Feuerelementar", @@ -1122,8 +756,6 @@ "Feuer" ], "cost": 4, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 5, "health": 4 }, @@ -1135,8 +767,6 @@ "Feuer" ], "cost": 4, - "rarity": "selten", - "kind": "Wesen", "attack": 4, "health": 3, "keywords": [ @@ -1150,43 +780,6 @@ } ] }, - { - "id": "f_sturm", - "name": "Flammensturm", - "name_en": "Firestorm", - "elements": [ - "Feuer" - ], - "cost": 6, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 4 - } - ] - }, - { - "id": "w_schild", - "name": "Gezeitenschild", - "name_en": "Tide Shield", - "elements": [ - "Wasser" - ], - "cost": 1, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "buff", - "target": "friendly_creature", - "amount2": 3, - "amount": 1 - } - ] - }, { "id": "w_qualle", "name": "Quallenschwarm", @@ -1195,13 +788,11 @@ "Wasser" ], "cost": 2, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 2, "health": 3, "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 1 } @@ -1215,53 +806,12 @@ "Wasser" ], "cost": 4, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 4, "health": 6, "keywords": [ "Wächter" ] }, - { - "id": "w_flut", - "name": "Flutwelle", - "name_en": "Flood Wave", - "elements": [ - "Wasser" - ], - "cost": 5, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "freeze", - "target": "all_enemy_creatures" - }, - { - "kind": "heal", - "target": "self_hero", - "amount": 3 - } - ] - }, - { - "id": "e_steinhaut", - "name": "Steinhaut", - "name_en": "Stone Skin", - "elements": [ - "Erde" - ], - "cost": 1, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "shield", - "target": "friendly_creature" - } - ] - }, { "id": "e_kiesel", "name": "Kieselgolem", @@ -1270,8 +820,6 @@ "Erde" ], "cost": 2, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 2, "health": 3 }, @@ -1283,8 +831,6 @@ "Erde" ], "cost": 4, - "rarity": "selten", - "kind": "Wesen", "attack": 2, "health": 6, "keywords": [ @@ -1298,47 +844,6 @@ } ] }, - { - "id": "e_beben", - "name": "Erdbeben", - "name_en": "Earthquake", - "elements": [ - "Erde" - ], - "cost": 5, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 2 - }, - { - "kind": "buff", - "target": "all_friendly_creatures", - "amount2": 2 - } - ] - }, - { - "id": "l_brise", - "name": "Brise", - "name_en": "Breeze", - "elements": [ - "Luft" - ], - "cost": 1, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "draw", - "target": "none", - "amount": 1 - } - ] - }, { "id": "l_fuchs", "name": "Wolkenfuchs", @@ -1347,37 +852,12 @@ "Luft" ], "cost": 2, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 2, "health": 2, "keywords": [ "Flink" ] }, - { - "id": "l_himmel", - "name": "Himmelsstoß", - "name_en": "Sky Strike", - "elements": [ - "Luft" - ], - "cost": 3, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_any", - "amount": 2 - }, - { - "kind": "draw", - "target": "none", - "amount": 1 - } - ] - }, { "id": "l_laeufer", "name": "Windläufer", @@ -1386,8 +866,6 @@ "Luft" ], "cost": 4, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 4, "keywords": [ @@ -1399,12 +877,9 @@ "name": "Glutherz", "name_en": "Ember Heart", "elements": [ - "Feuer", "Feuer" ], "cost": 3, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 3, "effects": [ @@ -1420,12 +895,9 @@ "name": "Abyssalwächter", "name_en": "Abyssal Guardian", "elements": [ - "Wasser", "Wasser" ], "cost": 5, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 8, "keywords": [ @@ -1433,7 +905,7 @@ ], "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 1 } @@ -1444,12 +916,9 @@ "name": "Felsenfaust", "name_en": "Rock Fist", "elements": [ - "Erde", "Erde" ], "cost": 4, - "rarity": "selten", - "kind": "Wesen", "attack": 5, "health": 6, "keywords": [ @@ -1461,12 +930,9 @@ "name": "Zwillingswind", "name_en": "Twin Wind", "elements": [ - "Luft", "Luft" ], "cost": 4, - "rarity": "selten", - "kind": "Wesen", "attack": 2, "health": 3, "keywords": [ @@ -1479,12 +945,9 @@ "name": "Nebelgeist", "name_en": "Mist Spirit", "elements": [ - "Feuer", "Wasser" ], "cost": 3, - "rarity": "selten", - "kind": "Wesen", "attack": 2, "health": 4, "effects": [ @@ -1504,12 +967,9 @@ "name": "Magmaschlange", "name_en": "Magma Serpent", "elements": [ - "Feuer", - "Erde" + "Feuer" ], "cost": 4, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 4, "effects": [ @@ -1525,12 +985,9 @@ "name": "Sturmbote", "name_en": "Storm Herald", "elements": [ - "Feuer", "Luft" ], "cost": 3, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 3, "keywords": [ @@ -1549,12 +1006,9 @@ "name": "Moorkröte", "name_en": "Bog Toad", "elements": [ - "Wasser", - "Erde" + "Wasser" ], "cost": 3, - "rarity": "selten", - "kind": "Wesen", "attack": 2, "health": 6, "keywords": [ @@ -1573,12 +1027,9 @@ "name": "Böenreiter", "name_en": "Gale Rider", "elements": [ - "Wasser", "Luft" ], "cost": 3, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 4, "keywords": [ @@ -1586,7 +1037,7 @@ ], "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 1 } @@ -1597,12 +1048,9 @@ "name": "Dünenläufer", "name_en": "Dune Runner", "elements": [ - "Erde", "Luft" ], "cost": 3, - "rarity": "selten", - "kind": "Wesen", "attack": 3, "health": 3, "keywords": [ @@ -1623,8 +1071,6 @@ "Feuer" ], "cost": 2, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 2, "health": 3, "effects": [ @@ -1643,48 +1089,9 @@ "Feuer" ], "cost": 5, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 6, "health": 5 }, - { - "id": "f_magma", - "name": "Magmaschlag", - "name_en": "Magma Blow", - "elements": [ - "Feuer" - ], - "cost": 4, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_creature", - "amount": 5 - } - ] - }, - { - "id": "f_falle", - "name": "Feuerfalle", - "name_en": "Fire Trap", - "elements": [ - "Feuer" - ], - "cost": 2, - "rarity": "selten", - "kind": "Falle", - "effects": [ - { - "kind": "damage", - "target": "trigger_source", - "amount": 3 - } - ], - "trigger": "attacked" - }, { "id": "w_schlange", "name": "Flussschlange", @@ -1693,8 +1100,6 @@ "Wasser" ], "cost": 3, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 4, "health": 4 }, @@ -1706,58 +1111,16 @@ "Wasser" ], "cost": 4, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 4, "health": 5, "effects": [ { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 1 } ] }, - { - "id": "w_quelle", - "name": "Heilende Quelle", - "name_en": "Healing Spring", - "elements": [ - "Wasser" - ], - "cost": 2, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "heal", - "target": "self_hero", - "amount": 6 - } - ] - }, - { - "id": "w_falle", - "name": "Frostfalle", - "name_en": "Frost Trap", - "elements": [ - "Wasser" - ], - "cost": 2, - "rarity": "selten", - "kind": "Falle", - "effects": [ - { - "kind": "cancel", - "target": "none" - }, - { - "kind": "freeze", - "target": "trigger_source" - } - ], - "trigger": "attacked" - }, { "id": "e_lehm", "name": "Lehmkrieger", @@ -1766,29 +1129,9 @@ "Erde" ], "cost": 2, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 3, "health": 2 }, - { - "id": "e_steinschlag", - "name": "Steinschlag", - "name_en": "Rockfall", - "elements": [ - "Erde" - ], - "cost": 3, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_creature", - "amount": 3 - } - ] - }, { "id": "e_hueter", "name": "Waldhüter", @@ -1797,33 +1140,12 @@ "Erde" ], "cost": 5, - "rarity": "selten", - "kind": "Wesen", "attack": 4, "health": 7, "keywords": [ "Wächter" ] }, - { - "id": "e_falle", - "name": "Erdwall", - "name_en": "Earth Wall", - "elements": [ - "Erde" - ], - "cost": 2, - "rarity": "selten", - "kind": "Falle", - "effects": [ - { - "kind": "summon", - "target": "none", - "card_id": "e_wall" - } - ], - "trigger": "attacked" - }, { "id": "l_bote", "name": "Windbote", @@ -1832,37 +1154,12 @@ "Luft" ], "cost": 1, - "rarity": "gewöhnlich", - "kind": "Wesen", "attack": 1, "health": 2, "keywords": [ "Flink" ] }, - { - "id": "l_rueckenwind", - "name": "Rückenwind", - "name_en": "Tailwind", - "elements": [ - "Luft" - ], - "cost": 2, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "buff", - "target": "friendly_creature", - "amount": 2 - }, - { - "kind": "draw", - "target": "none", - "amount": 1 - } - ] - }, { "id": "l_jaeger", "name": "Himmelsjäger", @@ -1871,227 +1168,20 @@ "Luft" ], "cost": 5, - "rarity": "selten", - "kind": "Wesen", "attack": 5, "health": 3, "keywords": [ "Flink" ] }, - { - "id": "l_falle", - "name": "Windfalle", - "name_en": "Wind Trap", - "elements": [ - "Luft" - ], - "cost": 2, - "rarity": "selten", - "kind": "Falle", - "effects": [ - { - "kind": "bounce", - "target": "trigger_source" - } - ], - "trigger": "creature_played" - }, - { - "id": "f_aschefalle", - "name": "Aschefalle", - "name_en": "Ash Trap", - "elements": [ - "Feuer" - ], - "cost": 3, - "rarity": "episch", - "kind": "Falle", - "trigger": "creature_played", - "effects": [ - { - "kind": "damage", - "target": "trigger_source", - "amount": 3 - } - ] - }, - { - "id": "w_nebelfalle", - "name": "Nebelfalle", - "name_en": "Mist Trap", - "elements": [ - "Wasser" - ], - "cost": 1, - "rarity": "selten", - "kind": "Falle", - "trigger": "attacked", - "effects": [ - { - "kind": "cancel", - "target": "none" - }, - { - "kind": "draw", - "target": "none", - "amount": 1 - } - ] - }, - { - "id": "e_dornen", - "name": "Dornenfalle", - "name_en": "Thorn Trap", - "elements": [ - "Erde" - ], - "cost": 3, - "rarity": "episch", - "kind": "Falle", - "trigger": "attacked", - "effects": [ - { - "kind": "damage", - "target": "trigger_source", - "amount": 2 - }, - { - "kind": "heal", - "target": "self_hero", - "amount": 3 - } - ] - }, - { - "id": "e_sandfalle", - "name": "Sandfalle", - "name_en": "Sand Trap", - "elements": [ - "Erde" - ], - "cost": 2, - "rarity": "selten", - "kind": "Falle", - "trigger": "creature_played", - "effects": [ - { - "kind": "freeze", - "target": "trigger_source" - } - ] - }, - { - "id": "l_boenfalle", - "name": "Böenfalle", - "name_en": "Gale Trap", - "elements": [ - "Luft" - ], - "cost": 3, - "rarity": "episch", - "kind": "Falle", - "trigger": "attacked", - "effects": [ - { - "kind": "bounce", - "target": "trigger_source" - } - ] - }, - { - "id": "l_spiegel", - "name": "Spiegelfalle", - "name_en": "Mirror Trap", - "elements": [ - "Luft" - ], - "cost": 4, - "rarity": "episch", - "kind": "Falle", - "trigger": "creature_played", - "effects": [ - { - "kind": "summon", - "target": "none", - "card_id": "l_wind" - } - ] - }, - { - "id": "w_ebbe", - "name": "Ebbe", - "name_en": "Ebb", - "elements": [ - "Wasser" - ], - "cost": 1, - "rarity": "gewöhnlich", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_creature", - "amount": 1 - }, - { - "kind": "freeze", - "target": "enemy_creature" - } - ] - }, - { - "id": "w_tiefenstoss", - "name": "Tiefenstoß", - "name_en": "Deep Strike", - "elements": [ - "Wasser" - ], - "cost": 2, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "enemy_creature", - "amount": 3 - } - ] - }, - { - "id": "w_strudel", - "name": "Strudel", - "name_en": "Maelstrom", - "elements": [ - "Wasser" - ], - "cost": 3, - "rarity": "selten", - "kind": "Zauber", - "effects": [ - { - "kind": "bounce", - "target": "enemy_creature" - }, - { - "kind": "draw", - "target": "none", - "amount": 1 - } - ] - }, { "id": "fff_sonne", "name": "Kern der Sonne", "name_en": "Core of the Sun", "elements": [ - "Feuer", - "Feuer", "Feuer" ], "cost": 9, - "rarity": "mythisch", - "kind": "Wesen", "attack": 9, "health": 7, "keywords": [ @@ -2110,13 +1200,9 @@ "name": "Herz des Ozeans", "name_en": "Heart of the Ocean", "elements": [ - "Wasser", - "Wasser", "Wasser" ], "cost": 9, - "rarity": "mythisch", - "kind": "Wesen", "attack": 7, "health": 10, "keywords": [ @@ -2128,7 +1214,7 @@ "target": "all_enemy_creatures" }, { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 3 } @@ -2139,13 +1225,9 @@ "name": "Seele des Gebirges", "name_en": "Soul of the Mountains", "elements": [ - "Erde", - "Erde", "Erde" ], "cost": 10, - "rarity": "mythisch", - "kind": "Wesen", "attack": 10, "health": 15, "keywords": [ @@ -2171,13 +1253,9 @@ "name": "Atem des Himmels", "name_en": "Breath of the Sky", "elements": [ - "Luft", - "Luft", "Luft" ], "cost": 9, - "rarity": "mythisch", - "kind": "Wesen", "attack": 7, "health": 7, "keywords": [ @@ -2191,40 +1269,14 @@ } ] }, - { - "id": "fw_siedend", - "name": "Siedende Flut", - "name_en": "Boiling Flood", - "elements": [ - "Feuer", - "Wasser" - ], - "cost": 5, - "rarity": "episch", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 2 - }, - { - "kind": "freeze", - "target": "strongest_enemy_creature" - } - ] - }, { "id": "fe_aschegolem", "name": "Aschegolem", "name_en": "Ash Golem", "elements": [ - "Feuer", - "Erde" + "Feuer" ], "cost": 6, - "rarity": "episch", - "kind": "Wesen", "attack": 4, "health": 7, "keywords": [ @@ -2243,12 +1295,9 @@ "name": "Sturmfalke", "name_en": "Storm Falcon", "elements": [ - "Feuer", "Luft" ], "cost": 5, - "rarity": "episch", - "kind": "Wesen", "attack": 4, "health": 4, "keywords": [ @@ -2261,12 +1310,9 @@ "name": "Schlammriese", "name_en": "Mud Giant", "elements": [ - "Wasser", "Erde" ], "cost": 6, - "rarity": "episch", - "kind": "Wesen", "attack": 4, "health": 9, "keywords": [ @@ -2289,12 +1335,9 @@ "name": "Sturmgeist", "name_en": "Storm Spirit", "elements": [ - "Wasser", - "Luft" + "Wasser" ], "cost": 5, - "rarity": "episch", - "kind": "Wesen", "attack": 5, "health": 4, "keywords": [ @@ -2306,77 +1349,11 @@ "target": "enemy_creature" }, { - "kind": "draw", + "kind": "mana", "target": "none", "amount": 1 } ] - }, - { - "id": "el_sandsturm", - "name": "Sandsturm", - "name_en": "Sandstorm", - "elements": [ - "Erde", - "Luft" - ], - "cost": 5, - "rarity": "episch", - "kind": "Zauber", - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 2 - }, - { - "kind": "draw", - "target": "none", - "amount": 1 - } - ] - }, - { - "id": "w_flutfalle", - "name": "Flutfalle", - "name_en": "Flood Trap", - "elements": [ - "Wasser" - ], - "cost": 3, - "rarity": "selten", - "kind": "Falle", - "trigger": "creature_played", - "effects": [ - { - "kind": "freeze", - "target": "trigger_source" - }, - { - "kind": "heal", - "target": "self_hero", - "amount": 2 - } - ] - }, - { - "id": "f_funkenfalle", - "name": "Funkenfalle", - "name_en": "Spark Trap", - "elements": [ - "Feuer" - ], - "cost": 1, - "rarity": "gewöhnlich", - "kind": "Falle", - "trigger": "attacked", - "effects": [ - { - "kind": "damage", - "target": "trigger_source", - "amount": 2 - } - ] } ] } diff --git a/scripts/ai.gd b/scripts/ai.gd index 45d4bca..6f421fb 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -30,13 +30,21 @@ static func board_value(p: Game.Player) -> float: return v -## Stellung aus Sicht von p: eigenes Leben, Feld und Hand 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. +## Karten auf der Hand zählen nach ihren Werten: ein Drache ist mehr wert als ein Kobold. +static func hand_value(p: Game.Player) -> float: + var v := 0.0 + for c in p.hand: + v += 0.15 * (c.attack + c.health) + return v + + +## 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. static func evaluate(g: Game, p: Game.Player) -> float: if not p.alive(): return -10000.0 - var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5 + var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.02 var alive_enemies := 0 var threat := 0.0 var leader := 0.0 @@ -117,6 +125,18 @@ static func _top(list: Array, rank: Callable, n: int) -> Array: static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array: var out := [] var valid_cache := {} + var afford := false + var seen := {} + for i in p.shop.size(): + var offer = p.shop[i] + if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND: + afford = true + if not seen.has(offer.id): # dasselbe Angebot zweimal zu prüfen bringt nichts + seen[offer.id] = true + out.append({"type": "buy", "slot": i, "card": offer}) + # Neu würfeln lohnt nur, wenn ein Laden da ist und nichts Bezahlbares darin liegt. + if not afford and not p.shop.is_empty() and p.energy >= Game.REROLL_COST and p.hand.size() < Game.MAX_HAND: + out.append({"type": "reroll"}) for card in p.hand: if not game.can_play(p, card)[0]: continue @@ -144,6 +164,14 @@ static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) - valid = valid_cache[key] for t in valid: out.append({"type": "play", "card": card, "target": t}) + var pairs := [] + for i in p.board.size(): + for j in range(i + 1, p.board.size()): + if game.can_fuse(p, p.board[i], p.board[j])[0]: + pairs.append({"type": "fuse", "creature": p.board[i], "other": p.board[j]}) + if prune: # Verschmelzen lohnt vor allem bei schwachen Wesen + pairs = _top(pairs, func(a): return -float(_value(a.creature) + _value(a.other)), MAX_TARGETS) + out.append_array(pairs) for cr in p.board: if not cr.can_attack(): continue @@ -165,10 +193,17 @@ static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void: var target = null if action.get("target") != null: target = map_target(g2, game, action.target) - if action.type == "play": - g2.play_card(p2, action.card, target) - else: - g2.attack(g2.find_creature(action.creature.uid), target) + match action.type: + "play": + g2.play_card(p2, action.card, target) + "buy": + g2.buy(p2, int(action.slot)) + "reroll": + g2.reroll(p2) + "fuse": + g2.fuse(p2, g2.find_creature(action.creature.uid), g2.find_creature(action.other.uid)) + _: + g2.attack(g2.find_creature(action.creature.uid), target) static func map_target(g2: Game, game: Game, t): @@ -182,31 +217,26 @@ static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary var out := action.duplicate() if action.has("creature"): out["creature"] = game.find_creature(action.creature.uid) + if action.has("other"): + out["other"] = game.find_creature(action.other.uid) if action.get("target") != null: out["target"] = map_target(game, copy, action.target) return out -## Mulligan der KI: teure Karten im ersten Zug zurücklegen. -static func mulligan_cards(p: Game.Player) -> Array: - return p.hand.filter(func(c): return c.cost >= 5) - - ## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer ## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte ## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug. static func choose_action(game: Game, p: Game.Player) -> Dictionary: if game.winner != "" or game.enemies_of(p).is_empty(): return {"type": "end"} - if not p.mulligan_done and p.turns == 1 and p == game.current(): - return {"type": "mulligan", "cards": mulligan_cards(p)} var t0 := Time.get_ticks_msec() var base := evaluate(game, p) + 0.01 var firsts := [] # {action, game, score} for action in candidate_actions(game, p, true): if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms: break - var g2 := game.clone_for(p) + var g2 := game.clone() apply_on_clone(g2, game, action) firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])}) firsts.sort_custom(func(a, b): return a.score > b.score) @@ -223,7 +253,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary: if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms: break count += 1 - var g3 := g2.clone_for(p2) + var g3 := g2.clone() apply_on_clone(g3, g2, nxt) score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001) if score > best_score: @@ -240,7 +270,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary: # --- Gierige Vergleichs-KI ------------------------------------------------------- static func _kills(attacker, defender) -> bool: - return not defender.shield and defender.health <= attacker.power() + return not defender.shield and defender.health <= attacker.power() + Game.matchup_bonus(attacker, defender) static func _value(c) -> int: @@ -276,8 +306,15 @@ static func _weakest(heroes: Array): static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: if game.winner != "" or game.enemies_of(p).is_empty(): return {"type": "end"} - if not p.mulligan_done and p.turns == 1 and p == game.current(): - return {"type": "mulligan", "cards": mulligan_cards(p)} + # Gierig: erst das teuerste Bezahlbare kaufen, dann ausspielen, dann angreifen. + var best := -1 + for k in p.shop.size(): + var offer = p.shop[k] + if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND: + if best < 0 or offer.cost > p.shop[best].cost: + best = k + if best >= 0: + return {"type": "buy", "slot": best} var playable := p.hand.filter(func(c): return game.can_play(p, c)[0]) if not playable.is_empty(): Game.shuffle(playable, game.rng) @@ -360,10 +397,14 @@ static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void match action.type: "play": game.play_card(p, action.card, action.target) + "buy": + game.buy(p, int(action.slot)) + "reroll": + game.reroll(p) "attack": game.attack(action.creature, action.target) - "mulligan": - game.mulligan(p, action.cards) + "fuse": + game.fuse(p, action.creature, action.other) _: game.end_turn() diff --git a/scripts/campaign.gd b/scripts/campaign.gd index b86494e..846d240 100644 --- a/scripts/campaign.gd +++ b/scripts/campaign.gd @@ -6,8 +6,8 @@ const STAGES := [ {"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": "zauber_schaden"}, - {"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_karten"}, + {"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"}, @@ -18,46 +18,34 @@ const STAGES := [ ## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge 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_karten"]}, + {"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": ["zauber_schaden", "feuer_angriff"]}, - {"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_karten", "flink_angriff"]}, - {"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]}, + {"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": 5, "reward": 500, "perks": ["alle_flink", "start_energie"]}, - {"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]}, + {"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"]}, ] -## Kapitel 3: drei Merkmale, volle Tische, die stärksten Decks. +## 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", "zauber_schaden"]}, - {"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_karten", "schild_wesen"]}, + {"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_karten"]}, - {"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["zauber_schaden", "feuer_angriff", "waechter_leben"]}, - {"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_karten", "flink_angriff", "resonanz_doppelt"]}, - {"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 5, "reward": 900, "perks": ["resonanz_doppelt", "zauber_schaden", "start_energie"]}, - {"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 5, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]}, - {"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 1000, "perks": ["alle_flink", "start_energie", "zauber_schaden"]}, - {"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 5, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_karten", "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"]}, ] const CHAPTERS := [STAGES, STAGES2, STAGES3] -# Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks. -const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6] -const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4] -const MIN_RATING3 := [3.2, 3.3, 3.5, 3.6, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4] -const MIN_RATINGS := [MIN_RATING, MIN_RATING2, MIN_RATING3] - - -static func target_rating(stage_index: int, player_rating: float) -> float: - var base: float = MIN_RATINGS[chapter(stage_index) - 1][stage_index % STAGES.size()] - return maxf(base, player_rating) - - static func total() -> int: return STAGES.size() * CHAPTERS.size() @@ -75,15 +63,14 @@ static func stage(i: int) -> Dictionary: return CHAPTERS[chapter(i) - 1][i % STAGES.size()] -## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist). -static func opponents(stage_index: int, r: RandomNumberGenerator, player_rating: float = 0.0) -> Array: +## Gegner einer Stufe: der Boss handelt nur mit seinen Elementen, Begleiter mit allen. +static func opponents(stage_index: int) -> Array: var st := stage(stage_index) - var target := target_rating(stage_index, player_rating) var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE - var boss := Game.Player.new(st.name, Game.build_opponent_deck_rated(r, st.elements, target), false, life, st.title) + var boss := Game.Player.new(st.name, false, 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(Game.random_opponent(r, k + 1, Game.START_LIFE, target)) + out.append(DeckGen.random_opponent(k + 1)) return out diff --git a/scripts/cards.gd b/scripts/cards.gd index 074ac09..0e3b934 100644 --- a/scripts/cards.gd +++ b/scripts/cards.gd @@ -8,7 +8,8 @@ const ELEMENT_COLORS := { "Erde": Color("73552d"), "Luft": Color("78aac8"), } -# Jede Paarung ist eine Kombo, auch gleiche Elemente. +# Jede Karte hat genau ein Element. Kombos entstehen erst im Spiel: beim Verschmelzen +# (der Name des neuen Wesens) und als Feldkombo zwischen benachbarten Elementen. const COMBOS := { "Feuer+Feuer": "Inferno", "Wasser+Wasser": "Tiefsee", @@ -20,11 +21,6 @@ const COMBOS := { "Wasser+Erde": "Schlamm", "Wasser+Luft": "Sturm", "Erde+Luft": "Sand", - # Dreifach-Kombos: die mythische Stufe - "Feuer+Feuer+Feuer": "Sonne", - "Wasser+Wasser+Wasser": "Ozean", - "Erde+Erde+Erde": "Gebirge", - "Luft+Luft+Luft": "Himmel", } # Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird. const FIELD_COMBO_TEXT := { @@ -32,10 +28,21 @@ const FIELD_COMBO_TEXT := { "Lava": "1 Schaden an das stärkste gegnerische Wesen", "Blitz": "2 Schaden an den schwächsten Gegner", "Schlamm": "Heile dich um 3", - "Sturm": "Ziehe 1 Karte", + "Sturm": "Erhalte 1 Mana", "Sand": "Das neue Wesen erhält +1/+1", } -const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern." +# Elementkreis: Wasser löscht Feuer, Feuer verbrennt Luft, Luft trägt Erde ab, Erde saugt Wasser auf. +const BEATS := {"Wasser": "Feuer", "Feuer": "Luft", "Luft": "Erde", "Erde": "Wasser"} +# Das Feld gehört allen am Tisch: jede gespielte Karte lädt ihre Elemente, bei FIELD_CHARGE kippt es. +const FIELD_CHARGE := 4 +const FIELD_NAMES := {"Feuer": "Vulkan", "Wasser": "Flut", "Erde": "Fels", "Luft": "Wirbel"} +const FIELD_TEXT := { + "Feuer": "Feuerwesen haben +1 Angriff", + "Wasser": "Wasserwesen haben +1 Angriff", + "Erde": "Erdwesen haben +1 Angriff", + "Luft": "Luftwesen haben +1 Angriff", +} +# Seltenheit ist reine Optik: jede Karte gibt es in jeder Stufe, die Werte bleiben gleich. const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"] const RARITY_COLORS := { "gewöhnlich": Color("aaaaaa"), @@ -45,15 +52,7 @@ const RARITY_COLORS := { "mythisch": Color("ff5a8c"), } const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"] -const KEYWORD_HELP := { - "Wächter": "Muss zuerst angegriffen werden.", - "Flink": "Kann sofort angreifen.", - "Schild": "Blockt den nächsten Schaden.", - "Doppelschlag": "Greift zweimal pro Zug an.", -} const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"] -# Näherung der Kartenstärke: Seltenheit plus Zuschläge für Schlüsselwörter und Effekte. -const RARITY_POINTS := {"gewöhnlich": 1.0, "selten": 2.0, "episch": 3.0, "legendär": 4.0, "mythisch": 5.0} const DATA_PATH := "res://data/karten.json" @@ -63,10 +62,54 @@ static func combo_key(elements: Array) -> String: return "+".join(sorted) +## Name der Elementkombination eines verschmolzenen Wesens; drei oder vier Elemente sind eine Chimäre. static func combo_name(elements: Array) -> String: if elements.size() == 1: return elements[0] - return COMBOS[combo_key(elements)] + return COMBOS.get(combo_key(elements), "Chimäre") + + +## Elementvorteil aus Sicht von own: +1 im Vorteil, -1 im Nachteil, sonst 0. +## Mehrfachelemente nehmen den für sie besseren Wert. +static func matchup(own: Array, other: Array) -> int: + for e in own: + if BEATS.get(e, "") in other: + return 1 + for e in other: + if BEATS.get(e, "") in own: + return -1 + return 0 + + +## Mana für eine Verschmelzung: Durchschnitt der Kartenkosten, aufgerundet. +static func fusion_cost(a: Card, b: Card) -> int: + return ceili((a.cost + b.cost) / 2.0) + + +## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt). +static func fusion_name(elements: Array) -> String: + return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]]) + + +## Karte des verschmolzenen Wesens: Elemente und Schlüsselwörter beider Karten, +## Werte kommen vom Spiel (Durchschnitt plus 1, siehe Game.fuse). +static func fused(a: Card, b: Card, attack: int, health: int) -> Card: + var els := [] + for e in a.elements + b.elements: + if not (e in els): + els.append(e) + els.sort_custom(func(x, y): return ELEMENTS.find(x) < ELEMENTS.find(y)) + var c := Card.new() + c.id = "fus_%s_%s" % [a.id, b.id] + c.name = fusion_name(els) + c.elements = els + c.cost = fusion_cost(a, b) + c.attack = attack + c.health = health + for k in KEYWORDS: + if k in a.keywords or k in b.keywords: + c.keywords.append(k) + return c static func field_combo_effect(name: String) -> Effect: @@ -80,31 +123,23 @@ static func field_combo_effect(name: String) -> Effect: "Schlamm": return Effect.new("heal", "self_hero", 3) "Sturm": - return Effect.new("draw", "none", 1) + return Effect.new("mana", "none", 1) "Sand": return Effect.new("buff", "self_creature", 1, 1) return null -const TRIGGER_TEXT := { - "attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift", - "creature_played": "Wenn ein Gegner ein Wesen ausspielt", -} - - class Effect extends RefCounted: - var kind: String # damage, heal, draw, freeze, bounce, buff, shield, summon, cancel + var kind: String # damage, heal, mana, freeze, bounce, buff, shield var target: String # siehe CHOSEN_TARGETS und Game.resolve() var amount: int var amount2: int - var card_id: String # für summon - func _init(k: String, t: String, a: int = 0, a2: int = 0, cid: String = "") -> void: + func _init(k: String, t: String, a: int = 0, a2: int = 0) -> void: kind = k target = t amount = a amount2 = a2 - card_id = cid func to_dict() -> Dictionary: var d := {"kind": kind, "target": target} @@ -112,34 +147,15 @@ class Effect extends RefCounted: d["amount"] = amount if amount2 != 0: d["amount2"] = amount2 - if card_id != "": - d["card_id"] = card_id return d static func from_dict(d: Dictionary) -> Effect: - return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", ""))) + return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0))) - ## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl). - func text(trigger: String = "") -> String: + ## Kartentext in der aktuellen Sprache. + func text() -> String: var n := str(amount) - if target == "trigger_source": - var played := trigger == "creature_played" - match kind: - "damage": - if played: - return tr("%s Schaden an dieses Wesen") % n - return tr("%s Schaden an den Angreifer") % n - "freeze": - if played: - return tr("Es wird eingefroren") - return tr("Der Angreifer wird eingefroren") - "bounce": - return tr("Es geht zurück auf die Hand") match kind: - "cancel": - return tr("Der Angriff verpufft") - "summon": - return tr("Beschwöre einen %s") % Cards.by_id(card_id).title() "damage": match target: "enemy_creature": @@ -154,10 +170,10 @@ class Effect extends RefCounted: return tr("%s Schaden an alle Gegner und ihre Wesen") % n "heal": return tr("Heile dich um %s") % n - "draw": + "mana": if amount == 1: - return tr("Ziehe 1 Karte") - return tr("Ziehe %s Karten") % n + return tr("Erhalte 1 Mana") + return tr("Erhalte %s Mana") % n "freeze": if target == "enemy_creature": return tr("Friere ein gegnerisches Wesen ein") @@ -188,31 +204,18 @@ class Card extends RefCounted: var name_en := "" # englischer Name var elements: Array var cost: int - var rarity: String - var kind: String # "Wesen", "Zauber" oder "Falle" var attack: int = 0 var health: int = 0 var keywords: Array = [] var effects: Array = [] - var trigger: String = "" # nur Fallen: attacked, creature_played - - func is_creature() -> bool: - return kind == "Wesen" - - func is_trap() -> bool: - return kind == "Falle" func to_dict() -> Dictionary: - var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind} - if is_creature(): - d["attack"] = attack - d["health"] = health + var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, + "attack": attack, "health": health} if not keywords.is_empty(): d["keywords"] = keywords if not effects.is_empty(): d["effects"] = effects.map(func(e): return e.to_dict()) - if trigger != "": - d["trigger"] = trigger return d static func from_dict(d: Dictionary) -> Card: @@ -222,14 +225,11 @@ class Card extends RefCounted: c.name_en = str(d.get("name_en", "")) c.elements = Array(d.elements) c.cost = int(d.cost) - c.rarity = d.rarity - c.kind = d.kind c.attack = int(d.get("attack", 0)) c.health = int(d.get("health", 0)) c.keywords = Array(d.get("keywords", [])) for e in d.get("effects", []): c.effects.append(Effect.from_dict(e)) - c.trigger = str(d.get("trigger", "")) return c ## Anzeigename in der aktuellen Sprache. @@ -261,14 +261,8 @@ class Card extends RefCounted: if not effects.is_empty(): var fx: Array[String] = [] for f in effects: - fx.append(f.text(trigger)) - var s := ", ".join(fx) + "." - if is_trap(): - parts.append(tr("Falle. %s: %s") % [tr(Cards.TRIGGER_TEXT[trigger]), s]) - elif is_creature(): - parts.append(tr("Beim Ausspielen: %s") % s) - else: - parts.append(s) + fx.append(f.text()) + parts.append(tr("Beim Ausspielen: %s") % (", ".join(fx) + ".")) return " ".join(parts) @@ -294,14 +288,6 @@ static func has_id(id) -> bool: return _by_id.has(id) -static func card_value(c: Card) -> float: - return RARITY_POINTS[c.rarity] + 0.3 * c.keywords.size() + 0.3 * c.effects.size() - - -static func of_rarity(r: String) -> Array: - return all().filter(func(c): return c.rarity == r) - - ## Lädt den Kartenpool aus data/karten.json. static func _build() -> void: var json := JSON.new() diff --git a/scripts/deckgen.gd b/scripts/deckgen.gd index 032a9e3..91b9fd0 100644 --- a/scripts/deckgen.gd +++ b/scripts/deckgen.gd @@ -1,142 +1,35 @@ class_name DeckGen -## Deckstärke, Gegnerdecks und Archetypen (ausgelagert aus den Spielregeln). +## Ladenangebot und Gegner (ausgelagert aus den Spielregeln). +## Es gibt keine Decks: jeder kauft jede Runde aus seinem eigenen Laden. -const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3} # Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner). const RESONANCE_STEPS := [0, 1, 2, 3, 3] -# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, 60 Partien je Archetyp, tests/strength_probe.gd). -const ARCHETYPE_STRENGTH := {"Erde": 50, "Erde+Luft": 56, "Feuer": 40, "Feuer+Erde": 64, "Feuer+Luft": 42, "Feuer+Wasser": 57, "Luft": 45, "Wasser": 43, "Wasser+Erde": 50, "Wasser+Luft": 41} + +## Karten, die im Laden auftauchen dürfen: höchstens Einkommen plus eins teuer, +## bei gesetzten Elementen nur aus diesen. +static func pool(income: int, elements: Array = []) -> Array: + var limit := income + 1 + return Cards.all().filter(func(c): + return c.cost <= limit and (elements.is_empty() or c.elements.all(func(e): return e in elements))) - -const ARCHETYPE_PATH := "res://data/archetypen.json" -static var _strength: Dictionary = {} +## Angebot des Ladens: n zufällige Karten (Wiederholungen erlaubt, wie in TFT). +## Mindestens eine Karte ist vom Einkommen allein bezahlbar, sonst wäre der Zug tot. +static func shop_cards(r: RandomNumberGenerator, income: int, elements: Array = [], n: int = Game.SHOP_SIZE) -> Array: + var p := pool(income, elements) + if p.is_empty(): + p = pool(Game.MAX_ENERGY, elements) + assert(not p.is_empty(), "kein Angebot für " + str(elements)) + var out := [] + for i in n: + out.append(p[r.randi_range(0, p.size() - 1)]) + var cheap := p.filter(func(c): return c.cost <= income) + if not cheap.is_empty() and not out.any(func(c): return c.cost <= income): + out[r.randi_range(0, out.size() - 1)] = cheap[r.randi_range(0, cheap.size() - 1)] + return out -## Gemessene Werte aus data/archetypen.json (vom Messlauf geschrieben), sonst die eingebaute Tabelle. -static func strength() -> Dictionary: - if _strength.is_empty(): - _strength = ARCHETYPE_STRENGTH.duplicate() - if FileAccess.file_exists(ARCHETYPE_PATH): - var json := JSON.new() - if json.parse(FileAccess.get_file_as_string(ARCHETYPE_PATH)) == OK and json.data is Dictionary: - for k in json.data: - if _strength.has(k): - _strength[k] = int(json.data[k]) - return _strength - - -## Elementkombinationen einer Stufe: "leicht" die schwächere Hälfte, "schwer" die stärkere, sonst alle. -static func archetypes(tier: String = "") -> Array: - var table := strength() - var keys: Array = table.keys() - keys.sort_custom(func(a, b): return table[a] < table[b]) - var half: int = keys.size() / 2 - match tier: - "leicht": - return keys.slice(0, half) - "schwer": - return keys.slice(half) - return keys - - -# Energiekurve der Gegnerdecks: Anzahl Karten je Kosten (6 = sechs und mehr). -const CURVE := {1: 3, 2: 5, 3: 4, 4: 3, 5: 2, 6: 3} - - - -## Gegnerdeck aus den Elementen entlang der Energiekurve. bias > 1 bevorzugt seltene Karten, bias < 1 gewöhnliche. -static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array: - var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements)) - var deck := [] - var counts := {} - for bucket in CURVE: - for i in CURVE[bucket]: - var c = _pick_for_cost(r, pool, counts, bucket, bias) - if c != null: - counts[c.id] = counts.get(c.id, 0) + 1 - deck.append(c) - while deck.size() < Game.DECK_SIZE: - var c = _pick_for_cost(r, pool, counts, r.randi_range(1, 6), bias) - if c == null: - break - counts[c.id] = counts.get(c.id, 0) + 1 - deck.append(c) - return deck - - -## Karte nahe den gewünschten Kosten, gewichtet nach Seltenheit (bias) und Kostenabstand. -## Bei extremer Seltenheitsvorgabe darf die Kurve nachgeben, sonst wäre das Ziel unerreichbar. -static func _pick_for_cost(r: RandomNumberGenerator, pool: Array, counts: Dictionary, cost: int, bias: float): - var candidates := [] - var weights := PackedFloat32Array() - for c in pool: - if counts.get(c.id, 0) >= Game.MAX_COPIES: - continue - var spread: int = absi(mini(c.cost, 6) - cost) - var w: float = OPPONENT_RARITY_WEIGHT[c.rarity] - w = w / bias if c.rarity == "gewöhnlich" else w * bias - w *= pow(0.3, spread) - candidates.append(c) - weights.append(w) - if candidates.is_empty(): - return null - return candidates[r.rand_weighted(weights)] - - -## Gegnerdeck auf eine Zielstärke: die Gewichtung wird angepasst, bis das Deck nahe am Ziel liegt. -static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array: - var bias := 1.0 - var best := [] - var best_diff := 99.0 - for attempt in 24: - var deck := build_opponent_deck(r, elements, bias) - var diff := deck_rating(deck) - target - if absf(diff) < best_diff: - best = deck - best_diff = absf(diff) - if absf(diff) <= tolerance: - break - bias = clampf(bias * (1.35 if diff < 0 else 1.0 / 1.35), 0.05, 20.0) - return best - - -## Zufälliger Gegner. target > 0: Deck auf diese Stärke gebaut. tier: Archetypen der Schwierigkeit. -static func random_opponent(r: RandomNumberGenerator, index: int, life: int = Game.START_LIFE, target: float = 0.0, tier: String = "") -> Game.Player: - var options := archetypes(tier) - var elements: Array = options[r.randi_range(0, options.size() - 1)].split("+") - var deck := build_opponent_deck_rated(r, elements, target) if target > 0.0 else build_opponent_deck(r, elements) - return Game.Player.new("Gegner %d" % index, deck, false, life, Cards.combo_name(elements) + "-Magier") - - -## Kartenwert, bei genug Partien mit der Siegquote der Karte gewichtet (Statistik aus Spieltests). -static func card_value(c: Cards.Card, stats: Dictionary = {}) -> float: - var v := Cards.card_value(c) - if stats.has(c.id): - var st: Dictionary = stats[c.id] - if int(st.games) >= 10: - v *= 0.5 + float(st.wins) / float(st.games) - return v - - -## Abzug für schlechte Energiekurven: zu wenig günstige oder zu viele teure Karten. -static func curve_penalty(cards: Array) -> float: - var cheap := 0 - var expensive := 0 - for c in cards: - if c.cost <= 2: - cheap += 1 - if c.cost >= 5: - expensive += 1 - return minf(0.05 * maxi(0, 6 - cheap) + 0.05 * maxi(0, expensive - 5), 0.4) - - -## Deckstärke: Durchschnitt der Kartenwerte minus Kurvenabzug (1 = nur gewöhnliche Karten, bis etwa 3,5). -static func deck_rating(cards: Array, stats: Dictionary = {}) -> float: - if cards.is_empty(): - return 0.0 - var sum := 0.0 - for c in cards: - sum += card_value(c, stats) - return sum / cards.size() - curve_penalty(cards) +## 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") diff --git a/scripts/game.gd b/scripts/game.gd index 69b607d..995baf3 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -4,26 +4,24 @@ extends RefCounted const MAX_BOARD := 5 const MAX_HAND := 8 -const MAX_ENERGY := 10 -const START_HAND := 4 +const MAX_ENERGY := 10 # Einkommen je Zug, höher steigt es nicht +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 -const RESONANCE_MAX := 4 -const MAX_TRAPS := 2 - # Merkmale der Kampagnen-Bosse (Player.perks). const PERKS := { "feuer_angriff": "Feuerwesen haben +1 Angriff", "flink_angriff": "Flinke Wesen haben +1 Angriff", "waechter_leben": "Wächter kommen mit +2 Leben", - "extra_karten": "Zieht 2 Karten pro Zug", + "wesen_leben": "Wesen kommen mit +1 Leben", + "extra_mana": "Bekommt jeden Zug 1 Mana mehr", "leben_35": "Beginnt mit 35 Leben", - "zauber_schaden": "Schadenszauber machen +1 Schaden", "schild_wesen": "Wesen kommen mit Schild", "alle_flink": "Alle Wesen sind flink", - "start_energie": "Beginnt mit 3 Energie", + "start_energie": "Beginnt mit 3 Mana", "resonanz_doppelt": "Resonanz zählt doppelt", } @@ -40,9 +38,6 @@ static func make_rng() -> RandomNumberGenerator: r.randomize() return r -const DECK_SIZE := 20 -const MAX_COPIES := 3 -const MAX_DECK_ELEMENTS := 2 class Creature extends RefCounted: @@ -52,6 +47,7 @@ class Creature extends RefCounted: var uid: int var attack: int var resonance := 0 # +1, wenn ein anderes eigenes Wesen ein Element teilt + var field_bonus := 0 # +1, wenn das Feld auf dem eigenen Element steht var health: int var max_health: int var shield: bool @@ -74,9 +70,9 @@ class Creature extends RefCounted: func owner(): return owner_ref.get_ref() - ## Angriffswert inklusive Resonanz und Boss-Merkmalen. + ## Angriffswert inklusive Resonanz, Feld und Boss-Merkmalen. func power() -> int: - var v := attack + resonance + var v := attack + resonance + field_bonus var o = owner() if o != null: if "feuer_angriff" in o.perks and "Feuer" in card.elements: @@ -98,6 +94,7 @@ class Creature extends RefCounted: var c := Creature.new(card, new_owner, uid) c.attack = attack c.resonance = resonance + c.field_bonus = field_bonus c.health = health c.max_health = max_health c.shield = shield @@ -113,43 +110,41 @@ class Player extends RefCounted: var is_human: bool var life: int var max_life: int - var deck: Array = [] + var shop: Array = [] # SHOP_SIZE Plätze, gekaufte sind null var hand: Array = [] var board: Array = [] - var energy := 0 - var max_energy := 0 - var fatigue := 0 - var start_bonus := 0 # einmalige Extra-Energie im ersten Zug (Ausgleich fürs Nachziehen) - var traps: Array = [] # verdeckt liegende Fallenkarten + var energy := 0 # Mana; nicht ausgegebenes bleibt liegen + var max_energy := 0 # Einkommen je Zug + var start_bonus := 0 # einmaliges Extra-Mana für den späteren Start var order := 0 # Platz in der Zugreihenfolge var perks: Array = [] # Boss-Merkmale (siehe PERKS) + var elements: Array = [] # Laden nur aus diesen Elementen (leer = alle) + var looks := {} # Karten-ID -> Seltenheitsstufe, reine Optik (siehe Profile) var turns := 0 # begonnene eigene Züge - var mulligan_done := false - func _init(n: String, d: Array, h: bool, l: int, t: String = "") -> void: + func _init(n: String, h: bool, l: int, t: String = "", els: Array = []) -> void: name = n title = t is_human = h life = l max_life = l - deck = d.duplicate() + elements = els.duplicate() func alive() -> bool: return life > 0 func clone() -> Player: - var p := Player.new(name, deck, is_human, life, title) + var p := Player.new(name, is_human, life, title, elements) p.max_life = max_life + p.shop = shop.duplicate() p.hand = hand.duplicate() p.energy = energy p.max_energy = max_energy - p.fatigue = fatigue p.start_bonus = start_bonus - p.traps = traps.duplicate() p.order = order p.perks = perks.duplicate() + p.looks = looks p.turns = turns - p.mulligan_done = mulligan_done for c in board: p.board.append(c.clone(p)) return p @@ -168,6 +163,8 @@ var events: Array = [] # strukturierte Ereignisse für Animationen var winner := "" # "", "human" oder "opponents" var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse var next_uid := 1 +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: @@ -178,6 +175,8 @@ func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void: if r == null: r = make_rng() rng = r + for e in Cards.ELEMENTS: + field_charge[e] = 0 func current() -> Player: @@ -225,20 +224,12 @@ func clone() -> Game: g.current_idx = current_idx g.winner = winner g.next_uid = next_uid + g.field_element = field_element + g.field_charge = field_charge.duplicate() g.quiet = true return g -## Kopie aus Sicht von p: fremde Fallen sind unbekannt und fehlen (die KI schummelt nicht). -func clone_for(p: Player) -> Game: - var g := clone() - var p2: Player = g.players[players.find(p)] - for q in g.players: - if q != p2: - q.traps = [] - return g - - # --- Zugablauf --------------------------------------------------------------- func start(fixed_order: bool = false) -> void: @@ -247,13 +238,9 @@ func start(fixed_order: bool = false) -> void: for i in players.size(): var p: Player = players[i] p.order = i - if "start_energie" in p.perks: - p.max_energy = 2 - shuffle(p.deck, rng) - # Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug - # einmalig 1 Energie extra (Ausgleich für den Startvorteil). - draw(p, START_HAND + (0 if i == 0 else 1)) + # Wer nicht beginnt, bekommt im ersten Zug einmalig 1 Mana extra. p.start_bonus = 0 if i == 0 else 1 + roll_shop(p) turn = 1 current_idx = 0 say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name))) @@ -263,28 +250,54 @@ func start(fixed_order: bool = false) -> void: begin_turn(current()) -func draw(p: Player, n: int = 1) -> void: - for i in n: - if p.deck.is_empty(): - p.fatigue += 1 - say("%s hat keine Karten mehr und erleidet %d Schaden." % [p.name, p.fatigue]) - damage(p, p.fatigue) - continue - var card = p.deck.pop_back() - if p.hand.size() >= MAX_HAND: - say("%s: Hand voll, %s verbrennt." % [p.name, card.name]) - else: - p.hand.append(card) - check_end() +## Füllt den Laden neu: SHOP_SIZE Karten, höchstens so teuer wie das Einkommen plus eins. +func roll_shop(p: Player) -> void: + p.shop = DeckGen.shop_cards(rng, p.max_energy, p.elements) + + +## Kauft eine Karte aus dem Laden auf die Hand. Gibt "" zurück oder eine Fehlermeldung. +func buy(p: Player, slot: int) -> String: + if winner != "" or p != current(): + return "Nicht dein Zug" + if slot < 0 or slot >= p.shop.size() or p.shop[slot] == null: + return "Kein Angebot" + var card: Cards.Card = p.shop[slot] + if p.energy < card.cost: + return "Nicht genug Mana" + if p.hand.size() >= MAX_HAND: + return "Hand voll" + p.energy -= card.cost + p.shop[slot] = null + p.hand.append(card) + say("%s kauft %s." % [p.name, card.name]) + emit({"type": "buy", "player": players.find(p), "card": card}) + return "" + + +## Würfelt den Laden neu. Gibt "" zurück oder eine Fehlermeldung. +func reroll(p: Player) -> String: + if winner != "" or p != current(): + return "Nicht dein Zug" + if p.energy < REROLL_COST: + return "Nicht genug Mana" + p.energy -= REROLL_COST + roll_shop(p) + say("%s würfelt den Laden neu." % p.name) + emit({"type": "reroll", "player": players.find(p)}) + return "" func begin_turn(p: Player) -> void: p.max_energy = mini(MAX_ENERGY, p.max_energy + 1) - p.energy = p.max_energy + # Nicht ausgegebenes Mana bleibt liegen. + p.energy += p.max_energy + (1 if "extra_mana" in p.perks else 0) + if p.turns == 0 and "start_energie" in p.perks: + p.energy += 2 # einmalige Starthilfe, kein dauerhafter Vorsprung if p.start_bonus > 0: p.energy += p.start_bonus - say("%s erhält %d Energie extra für den späteren Start." % [p.name, p.start_bonus]) + say("%s erhält %d Mana extra für den späteren Start." % [p.name, p.start_bonus]) p.start_bonus = 0 + roll_shop(p) for c in p.board: c.ready = true if c.frozen: @@ -296,28 +309,6 @@ func begin_turn(p: Player) -> void: p.turns += 1 say("--- Zug %d: %s ---" % [turn, p.name]) emit({"type": "turn", "player": players.find(p)}) - draw(p, 2 if "extra_karten" in p.perks else 1) - - -## Tauscht einmalig im ersten eigenen Zug Karten der Hand: zurück ins Deck, mischen, nachziehen. -func mulligan(p: Player, cards: Array) -> String: - if p.mulligan_done: - return "Schon getauscht" - if p != current() or p.turns != 1: - return "Nur im ersten eigenen Zug" - p.mulligan_done = true - var n := 0 - for c in cards: - if c in p.hand: - p.hand.erase(c) - p.deck.append(c) - n += 1 - if n > 0: - shuffle(p.deck, rng) - draw(p, n) - say(("%s tauscht 1 Karte." % p.name if n == 1 else "%s tauscht %d Karten." % [p.name, n]) if n > 0 else "%s behält die Hand." % p.name) - emit({"type": "mulligan", "player": players.find(p), "count": n}) - return "" func end_turn() -> void: @@ -336,18 +327,14 @@ func end_turn() -> void: # --- Karten spielen ---------------------------------------------------------- +## Ausspielen kostet nichts mehr: bezahlt wird beim Kauf im Laden. func can_play(p: Player, card: Cards.Card) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] - if p.energy < card.cost: - return [false, "Nicht genug Energie"] - if card.is_creature() and p.board.size() >= MAX_BOARD: + if not (card in p.hand): + return [false, "Nicht auf der Hand"] + if p.board.size() >= MAX_BOARD: return [false, "Dein Feld ist voll"] - if card.is_trap() and p.traps.size() >= MAX_TRAPS: - return [false, "Höchstens %d Fallen" % MAX_TRAPS] - var tt := card.target_type() - if tt != "" and not card.is_creature() and valid_targets(p, tt).is_empty(): - return [false, "Kein gültiges Ziel"] return [true, ""] @@ -379,37 +366,40 @@ func play_card(p: Player, card: Cards.Card, target = null) -> String: if valid.is_empty(): target = null p.hand.erase(card) - p.energy -= card.cost - if card.is_trap(): - p.traps.append(card) - say("%s legt eine Falle." % p.name) - emit({"type": "trap_set", "player": players.find(p)}) - check_end() - return "" say("%s spielt %s." % [p.name, card.name]) - var cr: Creature = null - var bonus := 0 - if card.is_creature(): - cr = _enter(p, card) - else: - emit({"type": "spell", "player": players.find(p), "card": card}) - if "zauber_schaden" in p.perks: - bonus = 1 + var cr := _enter(p, card) for fx in card.effects: - resolve(p, fx, target, cr, bonus) - if cr != null and cr in p.board: - for e in enemies_of(p): - if not (cr in p.board): - break - trigger_trap(e, "creature_played", cr) - if cr != null and cr in p.board: + resolve(p, fx, target, cr, card.elements) + if cr in p.board: trigger_field_combos(p, cr) + charge_field(card) update_resonance() cleanup() check_end() return "" +## Jede gespielte Karte lädt ihre Elemente. Wer FIELD_CHARGE erreicht, kippt das +## gemeinsame Feld auf sein Element; danach beginnen alle Zähler von vorn. +func charge_field(card: Cards.Card) -> void: + for e in card.elements: + field_charge[e] = int(field_charge.get(e, 0)) + 1 + var best := "" + for e in Cards.ELEMENTS: + if e == field_element or int(field_charge.get(e, 0)) < Cards.FIELD_CHARGE: + continue + if best == "" or int(field_charge[e]) > int(field_charge[best]): + best = e + if best == "": + return + field_element = best + for e in Cards.ELEMENTS: + field_charge[e] = 0 + say("Das Feld wird zu %s: %s." % [Cards.FIELD_NAMES[best], Cards.FIELD_TEXT[best]]) + emit({"type": "field", "element": best}) + update_resonance() # der Feldbonus gilt sofort + + ## Feldkombo: das neue Wesen bildet mit dem ersten eigenen Wesen aus anderem ## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus. func trigger_field_combos(p: Player, cr: Creature) -> void: @@ -427,26 +417,6 @@ func trigger_field_combos(p: Player, cr: Creature) -> void: return -## Löst die erste passende Falle von owner aus. Gibt true zurück, wenn der -## auslösende Angriff abgebrochen wird. -func trigger_trap(owner: Player, trigger: String, source: Creature) -> bool: - for trap in owner.traps: - if trap.trigger != trigger: - continue - owner.traps.erase(trap) - say("Falle von %s: %s!" % [owner.name, trap.name]) - emit({"type": "trap", "player": players.find(owner), "card": trap, "source": ref_of(source)}) - var cancel := false - for fx in trap.effects: - if fx.kind == "cancel": - cancel = true - else: - resolve(owner, fx, null, source) - cleanup() - return cancel - return false - - ## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt. func _enter(p: Player, card: Cards.Card) -> Creature: var cr := Creature.new(card, p, next_uid) @@ -454,6 +424,9 @@ func _enter(p: Player, card: Cards.Card) -> Creature: if "waechter_leben" in p.perks and cr.taunt(): cr.health += 2 cr.max_health += 2 + if "wesen_leben" in p.perks: + cr.health += 1 + cr.max_health += 1 if "schild_wesen" in p.perks: cr.shield = true if "alle_flink" in p.perks: @@ -464,14 +437,45 @@ func _enter(p: Player, card: Cards.Card) -> Creature: return cr -## Setzt ein Wesen aufs Feld (Fallen, Effekte). -func summon(p: Player, card: Cards.Card) -> void: - if p.board.size() >= MAX_BOARD: - say("%s: kein Platz für %s." % [p.name, card.name]) - return - _enter(p, card) - say("%s beschwört %s." % [p.name, card.name]) +## Prüft eine Verschmelzung: zwei eigene Wesen, genug Mana. +func can_fuse(p: Player, a: Creature, b: Creature) -> Array: + if winner != "" or p != current(): + return [false, "Nicht dein Zug"] + if a == null or b == null or a == b: + return [false, "Zwei verschiedene Wesen wählen"] + if not (a in p.board) or not (b in p.board): + return [false, "Nur eigene Wesen"] + if p.energy < Cards.fusion_cost(a.card, b.card): + return [false, "Nicht genug Mana"] + return [true, ""] + + +## Verschmilzt zwei eigene Wesen zu einem. Es erbt Elemente und Schlüsselwörter beider +## Karten, seine Werte sind der abgerundete Durchschnitt plus 1. Kosten: Durchschnitt der +## Kartenkosten, aufgerundet. Eine Verschmelzung ist kein Ausspielen: sie löst keine +## Feldkombo aus und lädt das Feld nicht. +func fuse(p: Player, a: Creature, b: Creature) -> String: + var ok := can_fuse(p, a, b) + if not ok[0]: + return ok[1] + p.energy -= Cards.fusion_cost(a.card, b.card) + var card := Cards.fused(a.card, b.card, (a.attack + b.attack) / 2 + 1, (a.health + b.health) / 2 + 1) + var cr := Creature.new(card, p, next_uid) + next_uid += 1 + cr.shield = cr.shield or a.shield or b.shield + cr.frozen = a.frozen or b.frozen + cr.ready = a.ready and b.ready + cr.attacks_left = 0 if not cr.ready else mini(a.attacks_left, b.attacks_left) + var at := p.board.find(a) + p.board.erase(a) + p.board.erase(b) + p.board.insert(mini(at, p.board.size()), cr) + say("%s und %s verschmelzen zu %s (%d/%d)." % [a.name, b.name, cr.name, cr.attack, cr.health]) + emit({"type": "fuse", "player": players.find(p), "uid": cr.uid, "gone": [a.uid, b.uid]}) update_resonance() + cleanup() + check_end() + return "" ## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3. @@ -488,9 +492,10 @@ func update_resonance() -> void: if o != c and o.card.elements.any(func(e): return e in c.card.elements): bonus += 1 c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1) + c.field_bonus = 1 if field_element != "" and field_element in c.card.elements else 0 -func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void: +func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, elements: Array = []) -> void: var enemies := enemies_of(p) var enemy_creatures := [] for e in enemies: @@ -508,7 +513,7 @@ func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus targets = p.board.duplicate() elif fx.target == "self_hero": targets = [p] - elif fx.target == "self_creature" or fx.target == "trigger_source": + elif fx.target == "self_creature": targets = [source] if source != null else [] elif fx.target == "strongest_enemy_creature": var best: Creature = null @@ -527,13 +532,15 @@ func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus for t in targets: match fx.kind: "damage": - damage(t, fx.amount + bonus) + damage(t, fx.amount + (Cards.matchup(elements, t.card.elements) if t is Creature else 0)) "heal": t.life = mini(t.max_life, t.life + fx.amount) say("%s heilt sich um %d." % [t.name, fx.amount]) emit({"type": "heal", "target": ref_of(t), "amount": fx.amount}) - "draw": - draw(p, fx.amount) + "mana": + p.energy += fx.amount + say("%s erhält %d Mana." % [p.name, fx.amount]) + emit({"type": "mana", "player": players.find(p), "amount": fx.amount}) "freeze": t.frozen = true t.attacks_left = 0 @@ -558,8 +565,6 @@ func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus t.shield = true say("%s erhält Schild." % t.name) emit({"type": "buff", "target": ref_of(t), "text": "Schild"}) - "summon": - summon(p, Cards.by_id(fx.card_id)) # --- Kampf ------------------------------------------------------------------- @@ -589,19 +594,21 @@ func attack(cr: Creature, target) -> String: cr.attacks_left -= 1 say("%s greift %s an." % [cr.name, target.name]) emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)}) - var tgt_owner: Player = target.owner() if target is Creature else target - var cancelled := trigger_trap(tgt_owner, "attacked", cr) - if cancelled or not (cr in cr.owner().board) or (target is Creature and not (target in tgt_owner.board)): - check_end() - return "" - damage(target, cr.power()) - if target is Creature: - damage(cr, target.power()) + damage(target, cr.power() + matchup_bonus(cr, target)) + if target is Creature and target.power() > 0: # wer nichts austeilt, kontert auch nicht + damage(cr, target.power() + matchup_bonus(target, cr)) cleanup() check_end() return "" +## Elementvorteil beim Zuschlagen: gegen Helden gibt es keinen. +static func matchup_bonus(source: Creature, target) -> int: + if not (target is Creature): + return 0 + return Cards.matchup(source.card.elements, target.card.elements) + + func damage(target, amount: int) -> void: if amount <= 0: return @@ -622,9 +629,13 @@ func cleanup() -> void: var died := false for p in players: for c in p.board.duplicate(): - if c.health <= 0: + # Wer besiegt ist, nimmt seine Wesen mit vom Tisch. + if c.health <= 0 or not p.alive(): p.board.erase(c) - say("%s stirbt." % c.name) + if c.health > 0: + say("%s verschwindet mit %s." % [c.name, p.name]) + else: + say("%s stirbt." % c.name) emit({"type": "death", "target": ref_of(c)}) died = true if died: @@ -657,7 +668,7 @@ static func from_snapshot(d: Dictionary) -> Game: return NetState.from_snapshot(d) -# --- Deckregeln und Gegner --------------------------------------------------- +# --- Gegner und Kampfstart --------------------------------------------------- static func shuffle(arr: Array, r: RandomNumberGenerator) -> void: for i in range(arr.size() - 1, 0, -1): @@ -667,81 +678,27 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void: arr[j] = tmp -static func deck_elements(cards: Array) -> Array: - var out := [] - for c in cards: - for e in c.elements: - if not (e in out): - out.append(e) - return out - - -## Prüft ein Deck. owned: Dictionary id -> Anzahl (null = keine Besitzprüfung). -static func validate_deck(cards: Array, owned = null) -> Array: - if cards.size() != DECK_SIZE: - return [false, "Deck braucht genau %d Karten (%d)" % [DECK_SIZE, cards.size()]] - var counts := {} - for c in cards: - counts[c.id] = counts.get(c.id, 0) + 1 - for cid in counts: - if counts[cid] > MAX_COPIES: - return [false, "Höchstens %dx dieselbe Karte" % MAX_COPIES] - if owned != null and owned.get(cid, 0) < counts[cid]: - return [false, "Karte nicht oft genug in der Sammlung"] - if deck_elements(cards).size() > MAX_DECK_ELEMENTS: - return [false, "Höchstens %d Elemente im Deck" % MAX_DECK_ELEMENTS] - return [true, ""] - - -## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int, "target_rating": float, "tier": String} -# --- Deckstärke und Gegner (siehe DeckGen) -------------------------------------- - -static func deck_rating(cards: Array, stats: Dictionary = {}) -> float: - return DeckGen.deck_rating(cards, stats) - - -static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array: - return DeckGen.build_opponent_deck(r, elements, bias) - - -static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array: - return DeckGen.build_opponent_deck_rated(r, elements, target, tolerance) - - -static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE, target: float = 0.0, tier: String = "") -> Player: - return DeckGen.random_opponent(r, index, life, target, tier) - - -static func archetypes(tier: String = "") -> Array: - return DeckGen.archetypes(tier) - - -static func new_battle(deck: Array, n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game: +## 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") if r == null: r = make_rng() - var target: float = float(rules.get("target_rating", 0.0)) - var tier: String = str(rules.get("tier", "")) + var life: int = int(rules.get("opponent_life", START_LIFE)) var opps := [] for i in n_opp: - opps.append(random_opponent(r, i + 1, START_LIFE, target, tier)) - return new_battle_with(deck, opps, r, false, rules) + opps.append(DeckGen.random_opponent(i + 1, life)) + return new_battle_with(opps, r, false, rules) -static func new_battle_with(deck: Array, opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game: +static func new_battle_with(opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game: if r == null: r = make_rng() - var human := Player.new("Du", deck, true, START_LIFE) + 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 everyone: String = rules.get("everyone_perk", "") for p in g.players: - if everyone != "" and not (everyone in p.perks): - p.perks.append(everyone) - if rules.has("start_life"): - p.life = int(rules.start_life) - p.max_life = p.life - elif "leben_35" in p.perks and p.life == START_LIFE: + if "leben_35" in p.perks and p.life == START_LIFE: p.life = 35 p.max_life = 35 g.start(fixed_order) diff --git a/scripts/net.gd b/scripts/net.gd index 0f7b3ac..584f272 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -17,9 +17,9 @@ var peer: ENetMultiplayerPeer var is_host := false var connected := false var my_name := "Spieler" -var my_deck: Array = [] # Karten-IDs +var my_pick := {"looks": {}} # eigene Kartenoptik (Seltenheit), die der Gegner sieht var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie) -var decks := {} # peer_id -> Karten-IDs (Host) +var picks := {} # peer_id -> {"looks": {...}} (Host) var seats := {} # peer_id -> Spielerindex (Host) var my_seat := -1 var game: Game # Host @@ -56,7 +56,7 @@ func host(port: int = PORT) -> Error: is_host = true connected = true names = {1: my_name} - decks = {1: my_deck} + picks = {1: my_pick} spectators = {} status = "Warte auf Mitspieler …" lobby_changed.emit() @@ -83,7 +83,7 @@ func leave() -> void: is_host = false connected = false names = {} - decks = {} + picks = {} seats = {} waiting = {} my_seat = -1 @@ -111,7 +111,7 @@ func _fail(reason: String) -> void: func _on_connected() -> void: connected = true status = "Verbunden" - register.rpc_id(1, my_name, my_deck, spectator) + register.rpc_id(1, my_name, my_pick, spectator) # --- Host-Wechsel --------------------------------------------------------------- @@ -230,7 +230,7 @@ func _on_peer_disconnected(id: int) -> void: return var pname: String = names.get(id, "") names.erase(id) - decks.erase(id) + picks.erase(id) spectators.erase(id) if game != null and seats.has(id): var p: Game.Player = game.players[seats[id]] @@ -256,17 +256,19 @@ func tick() -> void: waiting.erase(pname) game.say("%s kehrt nicht zurück und scheidet aus." % p.name) game.damage(p, 999) + game.cleanup() game.check_end() + if game.winner == "" and game.current() == p: + game.end_turn() # sonst bliebe das Spiel beim Ausgeschiedenen hängen action_applied.emit() elif game.current() == p: - p.mulligan_done = true game.say("%s ist nicht da, der Zug wird übersprungen." % p.name) game.end_turn() action_applied.emit() @rpc("any_peer", "reliable") -func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void: +func register(name: String, pick: Dictionary, as_spectator: bool = false) -> void: if not is_host: return var id := multiplayer.get_remote_sender_id() @@ -293,7 +295,7 @@ func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void status = "%s wollte beitreten, das Spiel läuft schon" % name return names[id] = name - decks[id] = deck_ids + picks[id] = pick lobby_update.rpc(names, spectators) lobby_changed.emit() @@ -313,18 +315,14 @@ func start_battle(n_ai: int) -> void: var players := [] seats = {} for id in ids: - var deck_ids: Array = decks.get(id, []) - var deck := [] - for cid in deck_ids: + var pick: Dictionary = picks.get(id, {}) + var p := Game.Player.new(names[id], true, Game.START_LIFE) + for cid in pick.get("looks", {}): if Cards.has_id(cid): - deck.append(Cards.by_id(cid)) - if Game.validate_deck(deck)[0] == false: - var pool: Array = Cards.ELEMENTS.duplicate() - Game.shuffle(pool, r) - deck = Game.build_opponent_deck(r, [pool[0], pool[1]]) - players.append(Game.Player.new(names[id], deck, true, Game.START_LIFE)) + p.looks[cid] = int(pick.looks[cid]) + players.append(p) for i in n_ai: - players.append(Game.random_opponent(r, i + 1)) + players.append(DeckGen.random_opponent(i + 1)) game = Game.new(players[0], players.slice(1), r) game.start() for id in ids: @@ -398,12 +396,15 @@ func _apply(id: int, action: Dictionary) -> void: var t = resolve_ref(action.get("target")) if cr != null and t != null: game.attack(cr, t) - "mulligan": - var cards := [] - for i in action.get("hand", []): - if int(i) >= 0 and int(i) < p.hand.size(): - cards.append(p.hand[int(i)]) - game.mulligan(p, cards) + "buy": + game.buy(p, int(action.get("slot", -1))) + "reroll": + game.reroll(p) + "fuse": + var a := game.find_creature(int(action.get("uid", -1))) + var b := game.find_creature(int(action.get("other", -1))) + if a != null and b != null: + game.fuse(p, a, b) "end": game.end_turn() _: diff --git a/scripts/netstate.gd b/scripts/netstate.gd index 48fb4d2..964ef54 100644 --- a/scripts/netstate.gd +++ b/scripts/netstate.gd @@ -2,8 +2,17 @@ class_name NetState ## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln). -## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl. +## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände nur als Anzahl, fremde Läden gar nicht. ## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer). +## Karten reisen als ID; verschmolzene stehen nicht im Pool und gehen als Ganzes mit. +static func card_ref(c: Cards.Card): + return c.id if Cards.has_id(c.id) else c.to_dict() + + +static func card_of(ref) -> Cards.Card: + return Cards.Card.from_dict(ref) if ref is Dictionary else Cards.by_id(ref) + + static func snapshot(g: Game, viewer: int) -> Dictionary: var ps := [] for i in g.players.size(): @@ -11,25 +20,26 @@ static func snapshot(g: Game, viewer: int) -> Dictionary: var mine := i == viewer or viewer == -1 var board := [] for c in p.board: - board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance, + board.append({"uid": c.uid, "card": card_ref(c.card), + "attack": c.attack, "resonance": c.resonance, "field_bonus": c.field_bonus, "health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen, "ready": c.ready, "attacks_left": c.attacks_left}) ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life, - "energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus, - "order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done, - "hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(), - "deck": p.deck.map(func(c): return c.id) if viewer == -1 else p.deck.size(), - "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(), + "energy": p.energy, "max_energy": p.max_energy, "start_bonus": p.start_bonus, + "order": p.order, "perks": p.perks, "elements": p.elements, "looks": p.looks, "turns": p.turns, + "hand": p.hand.map(card_ref) if mine else p.hand.size(), + "shop": p.shop.map(func(c): return c.id if c != null else "") if mine else [], "board": board}) var evs := [] for ev in g.events: var e: Dictionary = ev.duplicate() if e.has("card"): - e["card"] = e.card.id + e["card"] = card_ref(e.card) evs.append(e) return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx, "winner": g.winner, "winner_player": g.players.find(g.winner_player) if g.winner_player != null else -1, - "next_uid": g.next_uid, "log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs} + "next_uid": g.next_uid, "field_element": g.field_element, "field_charge": g.field_charge, + "log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs} ## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt. @@ -37,38 +47,30 @@ static func from_snapshot(d: Dictionary) -> Game: var placeholder: Cards.Card = Cards.all()[0] var list := [] for pd in d.players: - var p := Game.Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title) + var p := Game.Player.new(pd.name, pd.is_human, int(pd.life), pd.title, Array(pd.get("elements", []))) p.max_life = int(pd.max_life) p.energy = int(pd.energy) p.max_energy = int(pd.max_energy) - p.fatigue = int(pd.fatigue) p.start_bonus = int(pd.start_bonus) p.order = int(pd.order) p.perks = Array(pd.perks) + for cid in pd.get("looks", {}): + p.looks[cid] = int(pd.looks[cid]) p.turns = int(pd.turns) - p.mulligan_done = bool(pd.mulligan_done) if pd.hand is Array: - for cid in pd.hand: - p.hand.append(Cards.by_id(cid)) + for ref in pd.hand: + p.hand.append(card_of(ref)) else: for i in int(pd.hand): p.hand.append(placeholder) - if pd.deck is Array: - for cid in pd.deck: - p.deck.append(Cards.by_id(cid)) - else: - for i in int(pd.deck): - p.deck.append(placeholder) - if pd.traps is Array: - for cid in pd.traps: - p.traps.append(Cards.by_id(cid)) - else: - for i in int(pd.traps): - p.traps.append(placeholder) + for cid in pd.get("shop", []): + p.shop.append(Cards.by_id(cid) if str(cid) != "" else null) for cd in pd.board: - var c := Game.Creature.new(Cards.by_id(cd.card), p, int(cd.uid)) + var card := card_of(cd.card) + var c := Game.Creature.new(card, p, int(cd.uid)) c.attack = int(cd.attack) c.resonance = int(cd.resonance) + c.field_bonus = int(cd.get("field_bonus", 0)) c.health = int(cd.health) c.max_health = int(cd.max_health) c.shield = bool(cd.shield) @@ -85,16 +87,19 @@ static func from_snapshot(d: Dictionary) -> Game: g.winner = d.winner g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null g.next_uid = int(d.next_uid) + g.field_element = str(d.get("field_element", "")) + for e in Cards.ELEMENTS: + g.field_charge[e] = int(d.get("field_charge", {}).get(e, 0)) for line in d.log: g.log.append(str(line)) for ev in d.events: var e: Dictionary = ev.duplicate() if e.has("card"): - e["card"] = Cards.by_id(e.card) - for key in ["player", "uid", "count", "amount"]: + e["card"] = card_of(e.card) + for key in ["player", "uid", "amount"]: if e.has(key): e[key] = int(e[key]) - for key in ["target", "attacker", "source"]: + for key in ["target", "attacker"]: if e.has(key): var r: Dictionary = e[key] if r.has("uid"): diff --git a/scripts/profile.gd b/scripts/profile.gd index a849d69..af5ab09 100644 --- a/scripts/profile.gd +++ b/scripts/profile.gd @@ -1,90 +1,39 @@ class_name Profile extends RefCounted -## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Kartenstatistik (für Deckstärke und Schwierigkeit), Tagesaufgabe, Einstellungen. +## Spielstand: Münzen, Staub, Sammlung, Kampagne, Einstellungen. +## Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die +## ist reine Optik: sie ändert weder Werte noch Text einer Karte. -const SAVE_VERSION := 5 -const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert +const SAVE_VERSION := 6 const SAVE_PATH := "user://spielstand.json" const BOOSTER_COST := 100 -const ELEMENT_BOOSTER_COST := 120 # nur Karten eines Elements (auch seine Kombos) -const CODE_PREFIX := "DINGER1:" # Deck-Code zum Teilen 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_LOSS := 50 -const DECK_SLOTS := 5 -# Schwierigkeit: Faktor auf die Zielstärke der Gegnerdecks, Belohnung und KI-Tiefe. +# Schwierigkeit: Leben der Gegner, KI und Münzen. const DIFFICULTIES := { - "leicht": {"name": "Leicht", "factor": 0.9, "reward": 0.75, "smart": false, "tier": "leicht"}, - "normal": {"name": "Normal", "factor": 1.0, "reward": 1.0, "smart": true, "tier": ""}, - "schwer": {"name": "Schwer", "factor": 1.2, "reward": 1.5, "smart": true, "tier": "schwer"}, + "leicht": {"name": "Leicht", "life": 25, "reward": 0.75, "smart": false}, + "normal": {"name": "Normal", "life": 30, "reward": 1.0, "smart": true}, + "schwer": {"name": "Schwer", "life": 35, "reward": 1.5, "smart": true}, } -# Sammlung: Zerlegen bringt Staub, Herstellen kostet Staub. +# Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub. const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600} -const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200} - -const DAILY_TASKS := [ - {"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80}, - {"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60}, - {"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40}, - {"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50}, - {"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50}, - {"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60}, - {"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50}, - {"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120}, - {"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90}, - {"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90}, - {"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90}, - {"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90}, -] - -static var today_override := "" # für Tests +const DUST_UPGRADE := {"selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200} var coins := 0 var dust := 0 -var collection := {} # id -> Anzahl -var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids} -var active_deck := 0 -var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}} -var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen -var daily := {"date": "", "task": "", "progress": 0, "done": false} +var collection := {} # id -> Seltenheitsstufe (Index in Cards.RARITIES), fehlend = gewöhnlich +var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false} -var notices: Array[String] = [] # Meldungen für die Oberfläche (Tagesaufgabe, Fortschritt) -var card_stats := {} # id -> {"played", "games", "wins"} -var booster_since_mythic := 0 -var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1) -var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0, "margins": []} - -## Das aktive Deck (Kartenliste des gewählten Slots). -var deck: Array: - get: - return decks[active_deck].cards - set(value): - decks[active_deck].cards = value - - -func _init() -> void: - for i in DECK_SLOTS: - decks.append({"name": "Deck %d" % (i + 1), "cards": []}) +var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt) +var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1) static func starter() -> Profile: var p := Profile.new() p.coins = START_COINS - for c in Cards.of_rarity("gewöhnlich"): - if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"): - p.collection[c.id] = 2 - # Zwei seltene Karten zum Start, damit es etwas zum Aufbauen gibt - p.collection["f_drache"] = 1 - p.collection["e_baum"] = 1 - var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche", - "e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"] - for cid in starter_ids: - p.deck.append(cid) - p.deck.append(cid) - p.deck[p.deck.find("f_asche")] = "f_drache" - p.deck[p.deck.find("e_troll")] = "e_baum" return p @@ -102,71 +51,34 @@ static func load_or_new(path: String = SAVE_PATH) -> Profile: return p -## Liest jede Spielstand-Version; fehlende Felder bekommen Standardwerte (Migration). +## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und +## Einstellungen, die alte Sammlung (Anzahl je Karte) hat keine Entsprechung mehr. static func from_dict(d: Dictionary) -> Profile: var p := Profile.new() - var version := int(d.get("version", 1)) p.coins = int(d.get("coins", 0)) p.dust = int(d.get("dust", 0)) - var coll = d.get("collection", {}) - for cid in coll: - if Cards.has_id(cid): - p.collection[cid] = int(coll[cid]) - if version >= 3: - var list = d.get("decks", []) - for i in mini(list.size(), DECK_SLOTS): - p.decks[i].name = str(list[i].get("name", "Deck %d" % (i + 1))) - p.decks[i].cards = [] - for cid in list[i].get("cards", []): - if Cards.has_id(cid): - p.decks[i].cards.append(cid) - p.active_deck = clampi(int(d.get("active_deck", 0)), 0, DECK_SLOTS - 1) - else: - for cid in d.get("deck", []): + p.campaign_stage = int(d.get("campaign_stage", 0)) + if int(d.get("version", 1)) >= SAVE_VERSION: + var coll = d.get("collection", {}) + for cid in coll: if Cards.has_id(cid): - p.decks[0].cards.append(cid) - if version >= 2: - var st = d.get("stats", {}) - p.stats.games = int(st.get("games", 0)) - p.stats.wins = int(st.get("wins", 0)) - p.stats.boosters = int(st.get("boosters", 0)) - for k in st.get("wins_by_opp", {}): - p.stats.wins_by_opp[str(k)] = int(st.wins_by_opp[k]) - p.campaign_stage = int(d.get("campaign_stage", 0)) - var dl = d.get("daily", {}) - p.daily = {"date": str(dl.get("date", "")), "task": str(dl.get("task", "")), - "progress": int(dl.get("progress", 0)), "done": bool(dl.get("done", false))} - var se = d.get("settings", {}) - p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)), - "ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)), - "language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")), - "big_font": bool(se.get("big_font", false))} - if not DIFFICULTIES.has(p.settings.difficulty): - p.settings.difficulty = "normal" - if version >= 4: - for cid in d.get("card_stats", {}): - if Cards.has_id(cid): - var st = d.card_stats[cid] - p.card_stats[cid] = {"played": int(st.get("played", 0)), "games": int(st.get("games", 0)), "wins": int(st.get("wins", 0))} - p.booster_since_mythic = int(d.get("booster_since_mythic", 0)) - if version >= 5: - p.perk = str(d.get("perk", "")) - if not Game.PERKS.has(p.perk): - p.perk = "" - var rc = d.get("records", {}) - p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)), - "fastest_win": int(rc.get("fastest_win", 0)), "loss_streak": int(rc.get("loss_streak", 0)), - "margins": Array(rc.get("margins", [])).map(func(m): return int(m))} + p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1) + var se = d.get("settings", {}) + p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)), + "ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)), + "language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")), + "big_font": bool(se.get("big_font", false))} + if not DIFFICULTIES.has(p.settings.difficulty): + p.settings.difficulty = "normal" + p.perk = str(d.get("perk", "")) + if not Game.PERKS.has(p.perk): + p.perk = "" return p func to_dict() -> Dictionary: return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, - "decks": decks, "active_deck": active_deck, - "stats": stats, "campaign_stage": campaign_stage, - "daily": daily, "settings": settings, "card_stats": card_stats, - "booster_since_mythic": booster_since_mythic, - "perk": perk, "records": records} + "campaign_stage": campaign_stage, "settings": settings, "perk": perk} ## Speichert und sichert den vorherigen Stand als .bak. @@ -185,363 +97,114 @@ func take_notices() -> Array[String]: return out -# --- Decks und Sammlung ----------------------------------------------------------- +# --- Sammlung: Seltenheit als Aussehen -------------------------------------------- -func deck_cards(slot: int = -1) -> Array: - var ids: Array = decks[slot if slot >= 0 else active_deck].cards - var out := [] - for cid in ids: - out.append(Cards.by_id(cid)) +func level(cid: String) -> int: + return int(collection.get(cid, 0)) + + +func rarity(cid: String) -> String: + return Cards.RARITIES[level(cid)] + + +## Karten-ID -> Stufe für den Kampf (nur aufgewertete Karten, siehe Game.Player.looks). +func looks() -> Dictionary: + var out := {} + for cid in collection: + if int(collection[cid]) > 0: + out[cid] = int(collection[cid]) return out -func deck_valid(slot: int = -1) -> Array: - return Game.validate_deck(deck_cards(slot), collection) - - -func deck_name(slot: int = -1) -> String: - return decks[slot if slot >= 0 else active_deck].name - - -func rename_deck(slot: int, name: String) -> String: - var n := name.strip_edges() - if n.is_empty() or n.length() > 20: - return "Name muss 1 bis 20 Zeichen haben" - decks[slot].name = n - return "" - - -## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve. -## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt. -func autofill_deck(slot: int) -> String: - var cards: Array = decks[slot].cards - var elems := Game.deck_elements(deck_cards(slot)) - if elems.is_empty(): - var count := {} - for c in owned_cards(): - for e in c.elements: - count[e] = count.get(e, 0) + 1 - var ranked: Array = count.keys() - ranked.sort_custom(func(a, b): return count[a] > count[b]) - elems = ranked.slice(0, Game.MAX_DECK_ELEMENTS) - var pool := owned_cards().filter(func(c): return c.elements.all(func(e): return e in elems)) - pool.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity)) - var counts := {} - for cid in cards: - counts[cid] = counts.get(cid, 0) + 1 - var progressed := true - while cards.size() < Game.DECK_SIZE and progressed: - progressed = false - for cost in range(1, 11): - if cards.size() >= Game.DECK_SIZE: - break - for c in pool: - if c.cost != cost: - continue - var have: int = counts.get(c.id, 0) - if have < mini(collection.get(c.id, 0), Game.MAX_COPIES): - cards.append(c.id) - counts[c.id] = have + 1 - progressed = true - break - var ok := Game.validate_deck(deck_cards(slot), collection) - return "" if ok[0] else ok[1] - - -func owned_cards() -> Array: - return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0) - - -func distinct_owned() -> int: +## Anzahl Karten über der gewöhnlichen Stufe. +func upgraded_count() -> int: var n := 0 for cid in collection: - if collection[cid] > 0: + if int(collection[cid]) > 0: n += 1 return n -# --- Zerlegen und Herstellen ------------------------------------------------------ - -## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung. -func dismantle(cid: String, n: int = 1) -> String: - var owned: int = collection.get(cid, 0) - if owned < n: - return "Nicht genug Kopien" - var needed := 0 - for d in decks: - needed = maxi(needed, d.cards.count(cid)) - if owned - n < needed: - return "Wird in einem Deck gebraucht" - collection[cid] = owned - n - dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n - return "" - - -## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung. -func craft(cid: String) -> String: - var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity] +## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung. +func upgrade(cid: String) -> String: + var lvl := level(cid) + if lvl >= Cards.RARITIES.size() - 1: + return "Schon auf der höchsten Stufe" + var cost: int = DUST_UPGRADE[Cards.RARITIES[lvl + 1]] if dust < cost: return "Nicht genug Staub (%d nötig)" % cost dust -= cost - collection[cid] = collection.get(cid, 0) + 1 - daily_event("craft") + collection[cid] = lvl + 1 return "" -## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück. -func dismantle_extras() -> int: - var before := dust - for cid in collection.keys(): - var extra: int = collection[cid] - Game.MAX_COPIES - if extra > 0: - dismantle(cid, extra) - return dust - before +## Gibt eine Stufe ab und bekommt Staub dafür. Gibt "" zurück oder eine Fehlermeldung. +func dismantle(cid: String) -> String: + var lvl := level(cid) + if lvl == 0: + return "Gewöhnliche Karten lassen sich nicht zerlegen" + dust += DUST_DISMANTLE[Cards.RARITIES[lvl]] + collection[cid] = lvl - 1 + return "" # --- Booster ---------------------------------------------------------------------- -## Kartenpool einer Seltenheit, bei Elementboostern nur Karten mit diesem Element. -static func booster_pool(rarity: String, element: String) -> Array: - var pool := Cards.of_rarity(rarity) - if element != "": - pool = pool.filter(func(c): return element in c.elements) - return pool - - -static func roll_booster(r: RandomNumberGenerator, element: String = "") -> Array: +## Fünf Karten mit gewürfelter Seltenheit. +static func roll_booster(r: RandomNumberGenerator) -> Array: var rarities := RARITY_WEIGHTS.keys() var weights := PackedFloat32Array(RARITY_WEIGHTS.values()) - var cards := [] - for i in BOOSTER_SIZE: - var rarity: String = rarities[r.rand_weighted(weights)] - if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \ - and cards.all(func(c): return c.rarity == "gewöhnlich"): - rarity = "selten" # mindestens eine seltene Karte pro Booster - var pool := booster_pool(rarity, element) - cards.append(pool[r.randi_range(0, pool.size() - 1)]) - return cards - - -## Deck-Code zum Teilen: "DINGER1:id*anzahl,…" (sortiert, daher vergleichbar). -static func deck_code(card_ids: Array) -> String: - var counts := {} - for cid in card_ids: - counts[cid] = counts.get(cid, 0) + 1 - var keys := counts.keys() - keys.sort() - return CODE_PREFIX + ",".join(keys.map(func(k): return "%s*%d" % [k, counts[k]])) - - -## Karten-IDs aus einem Deck-Code; leer bei ungültigem Code. -static func parse_deck_code(code: String) -> Array: - var s := code.strip_edges() - if not s.begins_with(CODE_PREFIX): - return [] + var pool := Cards.all() var out := [] - for part in s.substr(CODE_PREFIX.length()).split(",", false): - var kv: PackedStringArray = part.strip_edges().split("*") - if kv.size() != 2 or not Cards.has_id(kv[0]) or not kv[1].is_valid_int(): - return [] - for i in clampi(int(kv[1]), 0, Game.MAX_COPIES): - out.append(kv[0]) + for i in BOOSTER_SIZE: + var rar: String = rarities[r.rand_weighted(weights)] + if i == BOOSTER_SIZE - 1 and out.all(func(x): return x.rarity == "gewöhnlich"): + rar = "selten" # mindestens eine seltene Karte pro Booster + out.append({"card": pool[r.randi_range(0, pool.size() - 1)], "rarity": rar}) return out -## Öffnet einen Booster (element: Elementbooster). Gibt die Karten zurück, leer wenn die Münzen nicht reichen. -func open_booster(r: RandomNumberGenerator = null, element: String = "") -> Array: - var cost := ELEMENT_BOOSTER_COST if element != "" else BOOSTER_COST - if coins < cost: +## Öffnet einen Booster. Höhere Stufen werten die Karte auf, alles andere wird zu Staub. +## Gibt die Ergebnisse zurück, leer wenn die Münzen nicht reichen. +func open_booster(r: RandomNumberGenerator = null) -> Array: + if coins < BOOSTER_COST: return [] if r == null: r = Game.make_rng() - coins -= cost - var cards := roll_booster(r, element) - booster_since_mythic += 1 - var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch") - if booster_since_mythic >= PITY_BOOSTERS and not has_mythic: - var pool := booster_pool("mythisch", element) - cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)] - has_mythic = true - if has_mythic: - booster_since_mythic = 0 - for c in cards: - collection[c.id] = collection.get(c.id, 0) + 1 - stats.boosters += 1 - daily_event("booster") - return cards + coins -= BOOSTER_COST + var out := roll_booster(r) + for res in out: + var lvl: int = Cards.RARITIES.find(res.rarity) + if lvl > level(res.card.id): + collection[res.card.id] = lvl + res["upgraded"] = true + res["dust"] = 0 + else: + res["upgraded"] = false + res["dust"] = DUST_DISMANTLE[res.rarity] + dust += res.dust + return out -# --- Kampfergebnis, Tagesaufgabe ------------------------------------------- +# --- Kampf und Kampagne ----------------------------------------------------------- ## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück. ## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei. -## turns: Dauer der Partie für die Bestenliste. -func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [], - turns: int = 0, reward_factor: float = 1.0, remaining_life: int = -1) -> int: - stats.games += 1 - records.margins.append(maxi(remaining_life, 0) if won else 0) - while records.margins.size() > 5: - records.margins.pop_front() - if won: - records.streak += 1 - records.loss_streak = 0 - records.best_streak = maxi(records.best_streak, records.streak) - if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win): - records.fastest_win = turns - else: - records.streak = 0 - records.loss_streak += 1 - var tip := suggest_difficulty() - if tip != "" and stage < 0: - notices.append("Tipp: %s. Schwierigkeit %s?" % [ - "%d Siege in Folge" % records.streak if won else "%d Niederlagen in Folge" % records.loss_streak, DIFFICULTIES[tip].name]) - for cid in played_ids: - _stat(cid).played += 1 - var seen := {} - for c in deck_used: - if not seen.has(c.id): - seen[c.id] = true - _stat(c.id).games += 1 - if won: - _stat(c.id).wins += 1 +func record_battle(won: bool, n_opp: int, stage: int = -1, reward_factor: float = 1.0) -> int: var reward := REWARD_LOSS if won: - stats.wins += 1 - var key := str(n_opp) - stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1 reward = int(round(REWARD_WIN[n_opp] * reward_factor)) if stage >= 0 and stage == campaign_stage: campaign_stage += 1 reward = Campaign.stage(stage).reward - notices.append("Kampagne: %s besiegt!" % Campaign.stage(stage).name) + notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name) coins += reward - daily_event("game") - if won: - daily_event("win") - if n_opp >= 3: - daily_event("win_vs3") - for e in Game.deck_elements(deck_used): - daily_event("win_element:" + e) return reward -func _stat(cid: String) -> Dictionary: - if not card_stats.has(cid): - card_stats[cid] = {"played": 0, "games": 0, "wins": 0} - return card_stats[cid] - - func difficulty() -> Dictionary: return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal) -## Vorschlag: rauf nach drei deutlichen Siegen in Folge (im Schnitt mindestens 12 Leben übrig) oder fünf -## Siegen egal wie knapp; runter nach drei Niederlagen in Folge. "" = kein Vorschlag. -func suggest_difficulty() -> String: - var order := ["leicht", "normal", "schwer"] - var i := order.find(str(settings.get("difficulty", "normal"))) - if i < order.size() - 1 and records.streak >= 3: - var last: Array = records.margins.slice(maxi(0, records.margins.size() - 3)) - var avg := 0.0 - for m in last: - avg += float(m) / last.size() - if records.streak >= 5 or (records.margins.size() >= 3 and avg >= 12.0): - return order[i + 1] - if records.loss_streak >= 3 and i > 0: - return order[i - 1] - return "" - - -## Eigene Deckstärke, mit Kartenstatistik gewichtet. -func deck_rating(cards: Array = []) -> float: - var deck_used: Array = cards if not cards.is_empty() else deck_cards() - return Game.deck_rating(deck_used, card_stats) - - -## Zielstärke der Gegnerdecks: eigene Deckstärke mal Schwierigkeitsfaktor. -func target_rating(cards: Array = []) -> float: - return deck_rating(cards) * float(difficulty().factor) - - -## Tauscht die schwächste Karte des aktiven Decks gegen cid, wenn das Deck dadurch stärker und gültig bleibt. -## dry_run: nur prüfen. Gibt "" zurück oder den Grund, warum nicht. -func improve_deck_with(cid: String, dry_run: bool = false) -> String: - if collection.get(cid, 0) <= 0: - return "Karte nicht in der Sammlung" - var card := Cards.by_id(cid) - if deck.count(cid) >= mini(collection[cid], Game.MAX_COPIES): - return "Schon so oft im Deck wie möglich" - var elems := Game.deck_elements(deck_cards()) - for e in card.elements: - if not (e in elems): - elems.append(e) - if elems.size() > Game.MAX_DECK_ELEMENTS: - return "Passt nicht zu den Elementen des Decks" - var weakest := "" - var weakest_value := Cards.card_value(card) - for other in deck: - if other == cid: - continue - var v := Cards.card_value(Cards.by_id(other)) - if v < weakest_value: - weakest_value = v - weakest = other - if weakest == "": - return "Keine schwächere Karte im Deck" - if dry_run: - return "" - var trial: Array = deck.duplicate() - trial[trial.find(weakest)] = cid - var ok := Game.validate_deck(trial.map(func(id): return Cards.by_id(id)), collection) - if not ok[0]: - return ok[1] - deck = trial - return "" - - -## Karten aus new_ids, die das aktive Deck verbessern würden. -func deck_suggestions(new_ids: Array) -> Array: - var out := [] - for cid in new_ids: - if not (cid in out) and improve_deck_with(cid, true) == "": - out.append(cid) - return out - - func perk_unlocked() -> bool: return campaign_stage >= Campaign.STAGES.size() - - -static func today() -> String: - return today_override if today_override != "" else Time.get_date_string_from_system() - - -## Wählt die Tagesaufgabe neu, wenn ein neuer Tag begonnen hat. -func refresh_daily() -> void: - var t := today() - if daily.date != t: - var day_number := int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 86400) - var task: Dictionary = DAILY_TASKS[day_number % DAILY_TASKS.size()] - daily = {"date": t, "task": task.id, "progress": 0, "done": false} - - -func daily_task() -> Dictionary: - refresh_daily() - for t in DAILY_TASKS: - if t.id == daily.task: - return t - return DAILY_TASKS[0] - - -## Zählt ein Ereignis (siehe DAILY_TASKS) für die Tagesaufgabe. Gibt die Belohnung zurück. -func daily_event(kind: String, n: int = 1) -> int: - var task := daily_task() - if daily.done or task.kind != kind: - return 0 - daily.progress += n - if daily.progress >= task.goal: - daily.done = true - coins += task.reward - notices.append("Tagesaufgabe erfüllt: %s (+%d Münzen)" % [task.text, task.reward]) - return task.reward - return 0 diff --git a/scripts/ui/battle_fx.gd b/scripts/ui/battle_fx.gd index 7a96f85..e9ea61e 100644 --- a/scripts/ui/battle_fx.gd +++ b/scripts/ui/battle_fx.gd @@ -46,24 +46,16 @@ func play_events(evs: Array, centers: Dictionary) -> void: popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1) "play": sfx("play") - "spell": - sfx("play") - var caster: Game.Player = g.players[ev.player] - popup(ev.card.name, Style.GOLD, screen.target_center({"kind": "player", "index": ev.player}), 0.0) - if caster != screen.perspective: - screen.show_preview(ev.card, true) - screen.preview_timer = 1.6 - "trap_set": - sfx("click") - popup("Falle gelegt", Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0) - "trap": + "fuse": sfx("combo") - popup("Falle: %s!" % ev.card.name, Style.ORANGE, screen.target_center({"kind": "player", "index": ev.player}) + Vector2(0, -20), 0.0) - screen.show_preview(ev.card, true) - screen.preview_timer = 1.8 - "mulligan": - if ev.count > 0: - popup(("tauscht 1 Karte" if int(ev.count) == 1 else "tauscht %d Karten" % ev.count), Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0) + 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) + "buy": + sfx("click") # die Karte landet sichtbar auf der Hand, das reicht + "reroll": + sfx("click") func center_of(ref: Dictionary, centers: Dictionary) -> Vector2: @@ -73,9 +65,24 @@ func center_of(ref: Dictionary, centers: Dictionary) -> Vector2: return c +## Mehrere Meldungen an derselben Stelle rücken nach unten, sonst liegen sie übereinander. +func _free_spot(at: Vector2) -> Vector2: + for step in 6: + var spot := at + Vector2(0, step * 26) + var taken := false + for l in screen.fx_layer.get_children(): + if l is Label and l.get_meta("spot", Vector2(-999, -999)).distance_to(spot) < 20: + taken = true + break + if not taken: + return spot + return at + + func popup(text: String, color: Color, at: Vector2, delay: float) -> void: if not enabled or at.x < 0: return + at = _free_spot(at) var l := Label.new() l.text = text l.add_theme_font_size_override("font_size", 22) @@ -84,6 +91,7 @@ func popup(text: String, color: Color, at: Vector2, delay: float) -> void: l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9)) l.add_theme_constant_override("outline_size", 6) l.mouse_filter = Control.MOUSE_FILTER_IGNORE + l.set_meta("spot", at) screen.fx_layer.add_child(l) l.reset_size() l.position = at - l.size / 2 diff --git a/scripts/ui/battle_gamepad.gd b/scripts/ui/battle_gamepad.gd index 7662fa9..ff7e959 100644 --- a/scripts/ui/battle_gamepad.gd +++ b/scripts/ui/battle_gamepad.gd @@ -1,11 +1,12 @@ class_name BattleGamepad extends RefCounted -## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Hand, eigene Wesen und Ziele, -## A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, X macht rückgängig. +## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Laden, Hand, eigene Wesen +## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, +## X macht rückgängig. var screen # BattleScreen var active := false -var zone := "hand" +var zone := "shop" var index := 0 var targets: Array = [] @@ -26,7 +27,7 @@ func button(button: int) -> void: if screen.confirm.visible: screen.confirm.visible = false screen.cancel_all() - zone = "hand" + zone = "shop" JOY_BUTTON_Y: if screen.b_skip.visible: screen.skip_ai = true @@ -41,16 +42,35 @@ func button(button: int) -> void: move(1) JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN: if zone != "target": - zone = "own" if zone == "hand" else "hand" - index = 0 + _next_zone(1 if button == JOY_BUTTON_DPAD_DOWN else -1) screen.refresh() +## Wechselt den Bereich und überspringt leere (eine leere Hand ist keine Station). +## step 0: beim aktuellen Bereich bleiben, falls er etwas enthält. +func _next_zone(step: int) -> void: + var order := ["shop", "hand", "own"] + var at := order.find(zone) + index = 0 + if step == 0 and not items().is_empty(): + return + var dir := -1 if step < 0 else 1 + for i in order.size(): + at = posmod(at + dir, order.size()) + zone = order[at] + if not items().is_empty(): + return + + func items() -> Array: match zone: + "shop": + return screen.shop_views "hand": return screen.hand_views "own": + if screen.fusing: # zum Verschmelzen zählt jedes eigene Wesen, nicht nur angriffsbereite + return screen.own_views return screen.own_views.filter(func(v): return v.creature.can_attack()) "target": return targets @@ -64,9 +84,6 @@ func move(dir: int) -> void: func confirm() -> void: - if screen.mulligan.visible: - screen.do_mulligan([]) - return if screen.overlay.visible: screen.leave() return @@ -78,6 +95,8 @@ func confirm() -> void: index = clampi(index, 0, items.size() - 1) var item = items[index] match zone: + "shop": + screen.on_click("shop", int(item.get_meta("slot", 0))) "hand": screen.on_click("hand", item.card) if screen.pending != null: @@ -113,8 +132,8 @@ func confirm() -> void: for o in screen.hero_panels: if screen.hero_panels[o] == item: screen.on_click("hero", o) - zone = "hand" - index = 0 + zone = "own" + _next_zone(0) ## Pulsierender Rahmen als Zeiger. diff --git a/scripts/ui/battle_layout.gd b/scripts/ui/battle_layout.gd index 47f4e3f..2e619b8 100644 --- a/scripts/ui/battle_layout.gd +++ b/scripts/ui/battle_layout.gd @@ -30,11 +30,13 @@ static func other_slot(n_others: int, i: int, j: int) -> Rect2: return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1]) +## Laden: fünf Angebote über der Hand. +static func shop_rect(i: int) -> Rect2: + return Rect2(340 + i * 121, 487, 115, 112) + + 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) - -static func trap_rect(i: int) -> Rect2: - return Rect2(400 + i * 78, 490, 70, 98) diff --git a/scripts/ui/battle_mulligan.gd b/scripts/ui/battle_mulligan.gd deleted file mode 100644 index dc9e79a..0000000 --- a/scripts/ui/battle_mulligan.gd +++ /dev/null @@ -1,63 +0,0 @@ -class_name BattleMulligan -extends Control -## Mulligan-Maske: Karten anklicken, behalten oder tauschen. - -signal decided(indexes: Array) - -var layer: Control -var b_keep: Button -var b_swap: Button -var selected: Array = [] - - -func _init() -> void: - set_anchors_preset(Control.PRESET_FULL_RECT) - visible = false - var dark := ColorRect.new() - dark.color = Color(0, 0, 0, 0.67) - dark.set_anchors_preset(Control.PRESET_FULL_RECT) - dark.mouse_filter = Control.MOUSE_FILTER_STOP - add_child(dark) - Style.panel(self, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2) - Style.label(self, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true) - Style.label(self, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center") - layer = Control.new() - layer.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(layer) - b_keep = Style.button(self, "Behalten", Rect2(430, 515, 200, 44), func(): finish(false), Color("285a3c")) - b_swap = Style.button(self, "Tauschen", Rect2(650, 515, 200, 44), func(): finish(true)) - - -func open(hand: Array) -> void: - selected = [] - visible = true - Style.clear(layer) - for i in hand.size(): - var r := BattleLayout.hand_rect(hand.size(), i) - var v := CardView.new(hand[i], r.size) - v.position = Vector2(r.position.x, 322) - var idx := i - v.clicked.connect(func(_view): toggle(idx)) - layer.add_child(v) - _borders() - - -func toggle(idx: int) -> void: - if idx in selected: - selected.erase(idx) - else: - selected.append(idx) - _borders() - - -func _borders() -> void: - var views := layer.get_children() - for i in views.size(): - views[i].border = Style.YELLOW if i in selected else null - views[i].queue_redraw() - b_swap.text = ("Tauschen (%d)" % selected.size()) if not selected.is_empty() else "Tauschen" - - -func finish(swap: bool) -> void: - visible = false - decided.emit(selected.duplicate() if swap else []) diff --git a/scripts/ui/battle_mulligan.gd.uid b/scripts/ui/battle_mulligan.gd.uid deleted file mode 100644 index 52274a1..0000000 --- a/scripts/ui/battle_mulligan.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://gwdm38e83cbj diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index 9175eb9..68abf03 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -14,6 +14,8 @@ var perspective: Game.Player # der Mensch, dessen Hand unten liegt var fx: BattleFx var selected = null # Game.Creature var pending = null # Cards.Card, wartet auf Ziel +var fusing := false # Verschmelzen gewählt: zwei eigene Wesen anklicken +var fuse_first = null # Game.Creature, erstes Wesen der Verschmelzung var msg_t := 0.0 var ai_delay := 0.75 var ai_timer := 0.0 @@ -24,7 +26,6 @@ var reward := -1 var ended := false var zoom_player = null var preview_timer := 0.0 -var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik) var pad: BattleGamepad var gp_zone: String: get: @@ -42,9 +43,9 @@ var undo_t := 0.0 # Ansichten (auch für Tests) var tiles := {} var hand_views: Array = [] +var shop_views: Array = [] var own_views: Array = [] var enemy_views: Array = [] -var trap_views: Array = [] var hero_panels := {} # Player -> Panel var hero_labels := {} var flash_until := {} # Spielerindex -> Zeitpunkt (ms) @@ -52,43 +53,29 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms) var hero_layer: Control var tile_layer: Control var hand_layer: Control -var trap_layer: Control +var shop_layer: Control var zoom_layer: Control var fx_layer: Control var log_box: RichTextLabel var preview_holder: Control -var preview_hint: Label var bar_panel: Panel var name_label: Label var life_label: Label var energy_label: Label -var deck_label: Label +var income_label: Label var status_label: Label var msg_label: Label var b_end: Button +var b_fuse: Button +var b_reroll: Button var b_giveup: Button +var field_label: Label var overlay: Control var result_label: Label var reward_label: Label var notice_label: Label var confirm: Control var b_save_leave: Button -var mulligan: BattleMulligan -var mulligan_box: Control: - get: - return mulligan -var mulligan_layer: Control: - get: - return mulligan.layer -var b_keep: Button: - get: - return mulligan.b_keep -var b_swap: Button: - get: - return mulligan.b_swap -var mulligan_selected: Array: - get: - return mulligan.selected var cursor_layer: Control var wait_label: Label var b_skip: Button @@ -110,7 +97,6 @@ static func online(seat: int) -> BattleScreen: ## 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.played_ids = Array(data.get("played_ids", [])) s.saved_game = Game.from_snapshot(data.game) return s @@ -155,17 +141,19 @@ func _ready() -> void: game = saved_game elif stage >= 0: var r := Game.make_rng() - game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r, app.profile.target_rating()), r, false, own_rules()) + game = Game.new_battle_with(Campaign.opponents(stage), r, false, own_rules()) else: - game = Game.new_battle(app.profile.deck_cards(), n_opp, null, own_rules()) + game = Game.new_battle(n_opp, Game.make_rng(), own_rules()) smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile() _begin() -## Eigenes Merkmal des Spielers (nach Kapitel 1 wählbar) und Zielstärke der Gegner. +## Kartenoptik, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1). func own_rules() -> Dictionary: var p: Profile = app.profile - var rules := {"target_rating": p.target_rating(), "tier": str(p.difficulty().tier)} + var rules := {"looks": p.looks()} + if stage < 0: + rules["opponent_life"] = int(p.difficulty().life) if p.perk != "" and p.perk_unlocked(): rules["human_perks"] = [p.perk] return rules @@ -188,11 +176,11 @@ func _begin() -> void: func _build_static() -> void: - Style.panel(self, Rect2(10, 205, 320, 340)) + 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, 308) + 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) @@ -200,13 +188,12 @@ func _build_static() -> void: 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)) - preview_hint = Style.label(self, "Karte überfahren für Details", 14, Style.MUTED, Rect2(950, 345, 320, 24), "center") 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, "Du", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true) + 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) - deck_label = Style.label(self, "", 16, Style.MUTED, Rect2(780, 424, 150, 24), "right") + 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")) @@ -215,20 +202,21 @@ func _build_static() -> void: 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() tile_layer = _layer() - trap_layer = _layer() hand_layer = _layer() + shop_layer = _layer() preview_holder = _layer() zoom_layer = _layer() fx_layer = _layer() cursor_layer = _layer() - # Mulligan - mulligan = BattleMulligan.new() - mulligan.decided.connect(do_mulligan) - add_child(mulligan) # Spielende overlay = _overlay() result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true) @@ -239,7 +227,6 @@ func _build_static() -> void: 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.label(confirm, tr("Aufgeben beendet den Kampf ohne Münzen.") + "\n" + tr("Speichern hebt ihn für später auf."), 15, Style.MUTED, Rect2(340, 356, 600, 48), "center") 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) @@ -265,7 +252,7 @@ func resumable() -> bool: func save_battle() -> void: if not resumable() or game == null or game.winner != "": return - var d := {"version": 1, "n_opp": n_opp, "stage": stage, "played_ids": played_ids, "game": game.snapshot(-1)} + 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)) @@ -371,7 +358,7 @@ func my_turn() -> bool: func any_overlay() -> bool: - return confirm.visible or mulligan.visible or overlay.visible + return confirm.visible or overlay.visible func _input(event: InputEvent) -> void: @@ -389,7 +376,7 @@ func _input(event: InputEvent) -> void: KEY_ESCAPE: if confirm.visible: confirm.visible = false - elif selected != null or pending != null or zoom_player != null: + elif selected != null or pending != null or fusing or zoom_player != null: cancel_all() elif not any_overlay(): confirm.visible = true @@ -402,9 +389,7 @@ func _input(event: InputEvent) -> void: if b_undo.visible: undo() KEY_ENTER, KEY_KP_ENTER: - if mulligan.visible: - do_mulligan([]) - elif overlay.visible: + if overlay.visible: leave() _: if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay(): @@ -444,8 +429,6 @@ func _process(delta: float) -> void: return var cur := game.current() if cur.is_human: - if cur == perspective and not spectating() and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible: - show_mulligan() return if is_client(): return @@ -477,7 +460,7 @@ func _process(delta: float) -> void: func _start_ai(cur: Game.Player) -> void: - ai_copy = game.clone_for(cur) + ai_copy = game.clone() var copy := ai_copy var idx := game.current_idx var box := ai_box @@ -494,7 +477,7 @@ 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, app.profile.deck_cards(), stage, played_ids, game.turn, factor, game.human.life) + reward = app.profile.record_battle(won, n_opp, stage, factor) app.save() app.sfx.play("win" if won else "lose") app.clear_battle_save() @@ -513,19 +496,21 @@ func say(m: String) -> void: func cancel() -> void: selected = null pending = null + fusing = false + fuse_first = null func play(card: Cards.Card, target) -> void: if is_client(): net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null}) return - var before: Game = game.clone() if card.is_creature() and not is_online() else null + var before: Game = game.clone() if not is_online() else null var log_len := game.log.size() var err := game.play_card(perspective, card, target) if err != "": say(tr(err)) return - # Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte, Kombos, Fallen) + # 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 undo_log_len = log_len @@ -534,6 +519,52 @@ func play(card: Cards.Card, target) -> void: clear_undo() +## Verschmelzen an- und abwählen (zwei eigene Wesen, siehe Game.fuse). +func toggle_fuse() -> void: + var on := not fusing + cancel() + fusing = on + say(tr("Zwei eigene Wesen wählen") if on else tr("Abgebrochen")) + after_action() + + +## Kauft ein Angebot aus dem Laden auf die Hand. +func do_buy(slot: int) -> void: + clear_undo() + if is_client(): + net.send_action({"type": "buy", "slot": slot}) + return + var err := game.buy(perspective, slot) + if err != "": + say(tr(err)) + + +## Würfelt den Laden neu (kostet Mana). +func do_reroll() -> void: + if not my_turn() or any_overlay(): + return + clear_undo() + if is_client(): + net.send_action({"type": "reroll"}) + after_action() + return + var err := game.reroll(perspective) + if err != "": + say(tr(err)) + after_action() + + +func do_fuse(a, b) -> void: + cancel() + clear_undo() + if is_client(): + net.send_action({"type": "fuse", "uid": a.uid, "other": b.uid}) + return + var err := game.fuse(perspective, a, b) + if err != "": + say(tr(err)) + + func clear_undo() -> void: undo_state = null undo_t = 0.0 @@ -574,26 +605,23 @@ func end_turn() -> void: after_action() -func show_mulligan() -> void: - mulligan.open(perspective.hand) - - -func do_mulligan(idxs: Array) -> void: - mulligan.visible = false - if is_client(): - net.send_action({"type": "mulligan", "hand": idxs}) - return - var cards := [] - for i in idxs: - cards.append(perspective.hand[i]) - game.mulligan(perspective, cards) - after_action() - - func on_click(kind: String, obj) -> void: var g := game if not my_turn() or any_overlay(): return + if fusing: + if kind != "own": + cancel() + say(tr("Abgebrochen")) + elif fuse_first == null: + fuse_first = obj + say(tr("Zweites Wesen wählen")) + elif obj == fuse_first: + fuse_first = null + else: + do_fuse(fuse_first, obj) + after_action() + return if pending != null: var card = pending pending = null @@ -619,6 +647,8 @@ func on_click(kind: String, obj) -> void: after_action() return match kind: + "shop": + do_buy(int(obj)) "hand": var ok := g.can_play(perspective, obj) if not ok[0]: @@ -647,61 +677,17 @@ func on_tile_click(cr: Game.Creature) -> void: on_click("enemy", cr) -func show_preview(card, entered: bool) -> void: +func show_preview(card, entered: bool, owner_looks: Dictionary = {}) -> void: Style.clear(preview_holder) if entered and card != null: - var v := CardView.new(card, Vector2(210, 294)) + var v := CardView.new(card, Vector2(210, 294), false, Cards.RARITIES[int(owner_looks.get(card.id, 0))]) v.position = Vector2(1005, 215) v.mouse_filter = Control.MOUSE_FILTER_IGNORE preview_holder.add_child(v) - preview_hint.visible = not (entered and card != null) -## Vorschau einer Handkarte mit Hinweisen auf Resonanz und Feldkombos mit dem eigenen Feld. func show_hand_preview(card: Cards.Card, entered: bool) -> void: - Style.clear(preview_holder) - preview_hint.visible = not entered - if not entered: - return - var hints: Array[String] = combo_hints(card) - var v := CardView.new(card, Vector2(190, 266) if not hints.is_empty() else Vector2(210, 294)) - v.position = Vector2(1015, 212) if not hints.is_empty() else Vector2(1005, 215) - v.mouse_filter = Control.MOUSE_FILTER_IGNORE - preview_holder.add_child(v) - if not hints.is_empty(): - var box := RichTextLabel.new() - box.position = Vector2(958, 480) - box.size = Vector2(304, 46) - box.bbcode_enabled = true - box.scroll_active = false - box.fit_content = false - box.mouse_filter = Control.MOUSE_FILTER_IGNORE - box.add_theme_font_size_override("normal_font_size", 11) - box.add_theme_color_override("default_color", Style.GOLD) - box.text = "[center]" + "\n".join(hints.slice(0, 2)) + "[/center]" - preview_holder.add_child(box) - - -func combo_hints(card: Cards.Card) -> Array[String]: - var out: Array[String] = [] - if not card.is_creature(): - return out - var partners: Array[String] = [] - var combos := {} - for other in perspective.board: - if other.card.elements.any(func(e): return e in card.elements): - partners.append(other.card.title()) - for e1 in card.elements: - for e2 in other.card.elements: - if e1 != e2: - var name := Cards.combo_name([e1, e2]) - if not combos.has(name): - combos[name] = other.card.title() - if not partners.is_empty(): - out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)]) - for name in combos: - out.append("Feldkombo %s mit %s: %s" % [name, combos[name], Cards.FIELD_COMBO_TEXT[name]]) - return out + show_preview(card, entered, perspective.looks) # --- Netzwerk ------------------------------------------------------------------------------- @@ -751,8 +737,6 @@ func after_action() -> void: net.broadcast() var evs := game.events.duplicate() game.events.clear() - if counts_for_profile(): - _note_events(evs) var centers := {} for ev in evs: for key in ["target", "attacker", "source"]: @@ -763,22 +747,6 @@ func after_action() -> void: save_battle() -func _note_events(evs: Array) -> void: - var hi := game.players.find(game.human) - for ev in evs: - if ev.get("player", -1) != hi: - continue - match ev.type: - "play", "spell": - played_ids.append(ev.card.id) - if ev.type == "play": - app.profile.daily_event("creature_played") - "trap_set": - app.profile.daily_event("trap_set") - "combo": - app.profile.daily_event("combo") - - # --- Ansicht angleichen -------------------------------------------------------------------- func refresh() -> void: @@ -790,9 +758,9 @@ func refresh() -> void: var mine := my_turn() var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else [] hand_views = [] + shop_views = [] own_views = [] enemy_views = [] - trap_views = [] for o in others(): var panel: Panel = hero_panels[o] @@ -813,8 +781,8 @@ 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) - var traps_txt := (" F %d" if compact() else " Fallen %d") % o.traps.size() if not o.traps.is_empty() else "" - labels.stats.text = ("H %d D %d%s ◆ %d" if compact() else "Hand %d Deck %d%s ◆ %d") % [o.hand.size(), o.deck.size(), traps_txt, o.max_energy] + 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]) else: labels.life.text = "besiegt" labels.life.add_theme_color_override("font_color", Style.MUTED) @@ -840,7 +808,8 @@ func refresh() -> void: tile.slot = rect.position tiles[cr.uid] = tile tile_layer.add_child(tile) - tile.hover.connect(show_preview) + var looks: Dictionary = p.looks + tile.hover.connect(func(card, entered): show_preview(card, entered, looks)) var uid := cr.uid tile.clicked.connect(func(_v): var c := game.find_creature(uid) @@ -852,9 +821,12 @@ func refresh() -> void: fx.move_to(tile, rect.position) var b = null var w := 2 - if cr == selected: + if cr == selected or cr == fuse_first: b = Style.YELLOW w = 4 + elif fusing and p == h and mine: + b = Style.PURPLE + w = 3 elif cr in valid or (selected != null and g.can_attack(selected, cr)[0]): b = Style.ORANGE w = 3 @@ -874,15 +846,23 @@ func refresh() -> void: tiles.erase(uid) fx.fade_out(t) - Style.clear(trap_layer) - if not h.traps.is_empty(): - Style.label(trap_layer, "Fallen", 13, Style.MUTED, Rect2(335, 492, 60, 20)) - for i in h.traps.size(): - var tv := CardView.new(h.traps[i], BattleLayout.trap_rect(i).size) - tv.position = BattleLayout.trap_rect(i).position - tv.hover.connect(show_preview) - trap_layer.add_child(tv) - trap_views.append(tv) + Style.clear(shop_layer) + if not spectating(): + for i in h.shop.size(): + var offer = h.shop[i] + var sr := BattleLayout.shop_rect(i) + if offer == null: + Style.panel(shop_layer, sr, Style.darker(Style.PANEL, 0.8), 8, Style.PANEL_LIGHT, 2) + continue + var sv := CardView.new(offer, sr.size, false, Cards.RARITIES[int(h.looks.get(offer.id, 0))]) + sv.position = sr.position + sv.dim = not mine or h.energy < offer.cost or h.hand.size() >= Game.MAX_HAND + sv.hover.connect(show_hand_preview) + 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) Style.clear(hand_layer) if spectating(): @@ -892,7 +872,7 @@ func refresh() -> void: var card: Cards.Card = h.hand[i] var playable: bool = mine and g.can_play(h, card)[0] var r := BattleLayout.hand_rect(h.hand.size(), i) - var v := CardView.new(card, r.size) + var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))]) v.position = r.position if card == pending: v.border = Style.YELLOW @@ -910,8 +890,8 @@ func refresh() -> void: bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0)) name_label.text = h.name life_label.text = "♥ %d" % maxi(h.life, 0) - energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy] - deck_label.text = "Deck %d" % h.deck.size() + energy_label.text = "◆ %d" % h.energy + income_label.text = tr("+%d je Zug") % h.max_energy status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name]) if spectating(): status_label.text = tr("Zuschauer") + " · " + status_label.text @@ -920,13 +900,29 @@ func refresh() -> void: b_skip.visible = ai_running b_end.visible = not ai_running and not spectating() 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.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]) var lines: Array[String] = [] var start := maxi(0, g.log.size() - 60) for i in range(start, g.log.size()): var line := g.log[i] if line.begins_with("---"): lines.append("[color=#6eaaf0]%s[/color]" % line) - elif line.begins_with("Feldkombo") or line.begins_with("Falle von"): + elif line.begins_with("Das Feld wird"): + lines.append("[color=#8c78dc]%s[/color]" % line) + elif line.begins_with("Feldkombo"): lines.append("[color=#f5c850]%s[/color]" % line) else: lines.append(line) @@ -954,7 +950,6 @@ func refresh() -> void: reward_label.text = "Gemeinsame Partie, keine Münzen" overlay.visible = true confirm.visible = false - mulligan.visible = false func build_zoom(valid: Array) -> void: @@ -963,7 +958,7 @@ func build_zoom(valid: Array) -> void: 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 Deck %d Fallen %d ◆ %d" % [o.name, o.title, o.life, o.hand.size(), o.deck.size(), o.traps.size(), o.max_energy], + 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] diff --git a/scripts/ui/booster_screen.gd b/scripts/ui/booster_screen.gd index 31c32c8..f14e975 100644 --- a/scripts/ui/booster_screen.gd +++ b/scripts/ui/booster_screen.gd @@ -5,17 +5,13 @@ const CARD_W := 200 const CARD_H := 280 var app -var cards: Array = [] +var cards: Array = [] # {"card", "rarity", "upgraded", "dust"} je Karte var revealed := {} -var new_ids := {} var coins_label: Label var msg_label: Label var b_buy: Button var b_all: Button -var b_improve: Button -var suggestion := "" var slots: Control -var element_buttons: Array = [] class CardBack extends Control: @@ -46,28 +42,19 @@ class CardBack extends Control: func slot_rect(i: int) -> Rect2: var gap := 20 - var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2 + var x0 := (1280 - Profile.BOOSTER_SIZE * CARD_W - (Profile.BOOSTER_SIZE - 1) * gap) / 2 return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H) 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.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.", - 16, Style.MUTED, Rect2(0, 115, 1280, 24), "center") - Style.label(self, "Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos" % Profile.ELEMENT_BOOSTER_COST, - 14, Style.MUTED, Rect2(0, 586, 1280, 22), "center") - for i in Cards.ELEMENTS.size(): - var e: String = Cards.ELEMENTS[i] - element_buttons.append(Style.button(self, e, Rect2(322 + i * 162, 612, 150, 38), buy.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))) 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) - b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c")) - b_improve.visible = false Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new())) refresh() @@ -77,10 +64,9 @@ func _input(event: InputEvent) -> void: app.show_screen(MenuScreen.new()) -func buy(element: String = "") -> void: +func buy() -> void: var p: Profile = app.profile - var before: Dictionary = p.collection.duplicate() - var got: Array = p.open_booster(null, element) + var got: Array = p.open_booster() if got.is_empty(): msg_label.text = "Nicht genug Münzen" msg_label.add_theme_color_override("font_color", Style.RED) @@ -89,13 +75,7 @@ func buy(element: String = "") -> void: app.sfx.play("play") cards = got revealed = {} - new_ids = {} - for c in cards: - if before.get(c.id, 0) == 0: - new_ids[c.id] = true - var notes: Array[String] = p.take_notices() - msg_label.text = " · ".join(notes) - msg_label.add_theme_color_override("font_color", Style.GREEN) + msg_label.text = "" refresh() @@ -106,59 +86,32 @@ func reveal(i: int) -> void: func reveal_all() -> void: - for i in 5: + for i in Profile.BOOSTER_SIZE: revealed[i] = true refresh() -## Nimmt die vorgeschlagene Karte ins aktive Deck (ersetzt die schwächste). -func improve() -> void: - if suggestion == "": - return - var err: String = app.profile.improve_deck_with(suggestion) - if err == "": - app.save() - msg_label.text = tr("%s ist jetzt im Deck %s") % [Cards.by_id(suggestion).title(), app.profile.deck_name()] - msg_label.add_theme_color_override("font_color", Style.GREEN) - suggestion = "" - else: - msg_label.text = err - msg_label.add_theme_color_override("font_color", Style.RED) - refresh() - - func refresh() -> void: var p: Profile = app.profile coins_label.text = "Münzen: %d" % p.coins b_buy.disabled = p.coins < Profile.BOOSTER_COST - for b in element_buttons: - b.disabled = p.coins < Profile.ELEMENT_BOOSTER_COST - b_all.disabled = cards.is_empty() or revealed.size() >= 5 - # Deck-Vorschlag, sobald alle Karten offen liegen - suggestion = "" - if not cards.is_empty() and revealed.size() >= 5: - var ids: Array = cards.map(func(c): return c.id) - var better: Array = p.deck_suggestions(ids) - if not better.is_empty(): - suggestion = better[0] - b_improve.visible = suggestion != "" - if suggestion != "": - b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).title(), p.deck_name()] + b_all.disabled = cards.is_empty() or revealed.size() >= Profile.BOOSTER_SIZE Style.clear(slots) - for i in 5: + for i in Profile.BOOSTER_SIZE: var r := slot_rect(i) if cards.is_empty(): Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2) elif revealed.has(i): - var c: Cards.Card = cards[i] - var v := CardView.new(c, r.size) + var res: Dictionary = cards[i] + var c: Cards.Card = res.card + var v := CardView.new(c, r.size, false, res.rarity) v.position = r.position slots.add_child(v) - Style.label(slots, tr(c.rarity), 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true) - if new_ids.has(c.id): - Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true) + 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) else: - Style.label(slots, "jetzt %dx" % p.collection[c.id], 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center") + Style.label(slots, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center") else: var back := CardBack.new(r.size) back.position = r.position diff --git a/scripts/ui/campaign_screen.gd b/scripts/ui/campaign_screen.gd index 439b44a..27f0c53 100644 --- a/scripts/ui/campaign_screen.gd +++ b/scripts/ui/campaign_screen.gd @@ -70,7 +70,7 @@ func build() -> void: var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass) b.disabled = true if p.campaign_stage >= Campaign.total(): - Style.clabel(col, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true) + 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())) diff --git a/scripts/ui/card_grid.gd b/scripts/ui/card_grid.gd index d849cda..83445d6 100644 --- a/scripts/ui/card_grid.gd +++ b/scripts/ui/card_grid.gd @@ -3,7 +3,6 @@ extends ScrollContainer ## Scrollbares Kartenraster mit Beschriftung unter jeder Karte. signal card_clicked(card: Cards.Card) -signal card_hover(card: Cards.Card, entered: bool) const COLS := 7 const CW := 100 @@ -25,17 +24,16 @@ func _init(rect: Rect2) -> void: add_child(grid) -## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool -func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void: +## 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: Style.clear(grid) for c in cards: var cell := Control.new() cell.custom_minimum_size = Vector2(CW, CH + 16) cell.mouse_filter = Control.MOUSE_FILTER_IGNORE - var v := CardView.new(c, Vector2(CW, CH), true) + 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) v.clicked.connect(func(_view): card_clicked.emit(c)) - v.hover.connect(func(card, entered): card_hover.emit(card, entered)) 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") diff --git a/scripts/ui/card_view.gd b/scripts/ui/card_view.gd index c0df950..1249e73 100644 --- a/scripts/ui/card_view.gd +++ b/scripts/ui/card_view.gd @@ -7,12 +7,14 @@ 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 dim := false ## 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) -> void: +func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "gewöhnlich") -> void: card = c + rarity = rar custom_minimum_size = sz size = sz mouse_filter = Control.MOUSE_FILTER_STOP @@ -45,7 +47,7 @@ func _draw() -> void: var f_text := 14 if big else (11 if mid else 10) var f_stat := 24 if big else (18 if mid else 15) Style.fill_elements(self, rect, card.elements, 8) - var bcol: Color = border if border != null else Cards.RARITY_COLORS[card.rarity] + 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 @@ -57,17 +59,17 @@ func _draw() -> void: 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()) + " · " + tr(card.kind)) if card.is_combo() else tr(card.kind) + 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 box_top: float = maxf(size.y * 0.30, box_bottom - maxf(needed, size.y * 0.42)) + 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_top: float = 2 * r + 4 + (f_text + 6 if mid else 0) 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) @@ -77,9 +79,8 @@ func _draw() -> void: 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 - if card.is_creature(): - 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) + 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 dim: draw_rect(rect, Color(0, 0, 0, 0.47)) if has_focus(): diff --git a/scripts/ui/collection_screen.gd b/scripts/ui/collection_screen.gd index cf4ff30..75d0ed8 100644 --- a/scripts/ui/collection_screen.gd +++ b/scripts/ui/collection_screen.gd @@ -1,7 +1,7 @@ class_name CollectionScreen extends Control -## Sammlung: alle Karten mit Besitzstand; ausgewählte Karten lassen sich -## zerlegen (Staub) oder herstellen. +## Sammlung: alle Karten gehören dir. Gesammelt wird die Seltenheitsstufe — reine Optik. +## Aufwerten kostet Staub, eine Stufe abgeben bringt Staub. var app var filter := "" @@ -14,14 +14,11 @@ var msg_label: Label var preview_holder: Control var info_label: Label var b_dismantle: Button -var b_craft: Button -var b_extras: Button +var b_upgrade: Button func _ready() -> void: Style.label(self, "Sammlung", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true) - Style.label(self, "Karte anklicken, dann zerlegen oder herstellen. Überschuss: alles über %d Kopien." % Game.MAX_COPIES, - 14, Style.MUTED, Rect2(240, 18, 600, 24)) Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) for i in Cards.ELEMENTS.size(): var e: String = Cards.ELEMENTS[i] @@ -44,10 +41,9 @@ func _ready() -> void: 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_dismantle = Style.button(self, "", Rect2(860, 470, 390, 44), dismantle, Color("5a3c28")) - b_craft = Style.button(self, "", Rect2(860, 524, 390, 44), craft, Color("285a3c")) - b_extras = Style.button(self, "", Rect2(860, 600, 390, 44), dismantle_extras) - msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 660, 390, 48), "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())) refresh() @@ -83,71 +79,57 @@ func say(m: String, ok: bool) -> void: msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED) +func upgrade() -> void: + if selected == null: + return + var err: String = app.profile.upgrade(selected.id) + if err == "": + app.sfx.play("play") + app.save() + say(tr("%s ist jetzt %s") % [selected.title(), tr(app.profile.rarity(selected.id))], true) + else: + say(err, false) + refresh() + + func dismantle() -> void: if selected == null: return + var lvl: int = app.profile.level(selected.id) var err: String = app.profile.dismantle(selected.id) if err == "": app.sfx.play("click") app.save() - say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[selected.rarity]], true) + say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]], true) else: say(err, false) refresh() -func craft() -> void: - if selected == null: - return - var err: String = app.profile.craft(selected.id) - if err == "": - app.sfx.play("play") - app.save() - var notes: Array[String] = app.profile.take_notices() - say(tr("%s hergestellt") % selected.title() + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true) - else: - say(err, false) - refresh() - - -func dismantle_extras() -> void: - var got: int = app.profile.dismantle_extras() - if got > 0: - app.save() - say("Überschuss zerlegt: +%d Staub" % got, true) - else: - say("Kein Überschuss vorhanden", false) - refresh() - - func refresh() -> void: var p: Profile = app.profile dust_label.text = "Staub: %d" % p.dust - grid.fill(cards(), - func(c): return "%dx" % p.collection.get(c.id, 0) if p.collection.get(c.id, 0) > 0 else "fehlt", - func(c): return p.collection.get(c.id, 0) == 0) + grid.fill(cards(), func(c): return tr(p.rarity(c.id)), func(_c): return false, func(c): return p.rarity(c.id)) Style.clear(preview_holder) if selected == null: info_label.text = "Keine Karte gewählt" + b_upgrade.text = "Aufwerten" b_dismantle.text = "Zerlegen" - b_craft.text = "Herstellen" + b_upgrade.disabled = true b_dismantle.disabled = true - b_craft.disabled = true + return + var v := CardView.new(selected, Vector2(200, 280), false, p.rarity(selected.id)) + v.position = Vector2(955, 100) + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + preview_holder.add_child(v) + var lvl := p.level(selected.id) + info_label.text = "%s · %s" % [tr(selected.combo()), tr(p.rarity(selected.id))] + if lvl >= Cards.RARITIES.size() - 1: + b_upgrade.text = "Höchste Stufe" + b_upgrade.disabled = true else: - var v := CardView.new(selected, Vector2(200, 280)) - v.position = Vector2(955, 100) - v.mouse_filter = Control.MOUSE_FILTER_IGNORE - preview_holder.add_child(v) - var owned: int = p.collection.get(selected.id, 0) - var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0}) - info_label.text = "%s · %s · Wert %.1f\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [ - selected.combo(), selected.rarity, Cards.card_value(selected), owned, st.played, st.games, st.wins] - b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity] - b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity] - b_dismantle.disabled = owned == 0 - b_craft.disabled = p.dust < Profile.DUST_CRAFT[selected.rarity] - var extras := 0 - for cid in p.collection: - extras += maxi(0, p.collection[cid] - Game.MAX_COPIES) - b_extras.text = "Überschuss zerlegen (%d Karten)" % extras - b_extras.disabled = extras == 0 + var next: String = Cards.RARITIES[lvl + 1] + b_upgrade.text = "Aufwerten zu %s (%d Staub)" % [tr(next), Profile.DUST_UPGRADE[next]] + b_upgrade.disabled = p.dust < Profile.DUST_UPGRADE[next] + b_dismantle.text = "Zerlegen" if lvl == 0 else "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]] + b_dismantle.disabled = lvl == 0 diff --git a/scripts/ui/creature_view.gd b/scripts/ui/creature_view.gd index 17069b3..c3201f0 100644 --- a/scripts/ui/creature_view.gd +++ b/scripts/ui/creature_view.gd @@ -36,7 +36,9 @@ func _draw() -> void: var small := size.x < 100 var mini := size.x < 60 Style.fill_elements(self, rect, creature.card.elements, 6) - var bcol: Color = border if border != null else Cards.RARITY_COLORS[creature.card.rarity] + 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 flash: bcol = Style.RED Style.border(self, rect, bcol, 4 if flash else width, 6) @@ -57,12 +59,14 @@ func _draw() -> void: 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 > 0 else Style.YELLOW + 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) @@ -83,8 +87,10 @@ func _draw_mini() -> void: letters.append("E") if creature.resonance > 0: letters.append("R") + if creature.field_bonus > 0: + letters.append("F") Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true) - var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW + 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 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) diff --git a/scripts/ui/deck_screen.gd b/scripts/ui/deck_screen.gd deleted file mode 100644 index e6cd017..0000000 --- a/scripts/ui/deck_screen.gd +++ /dev/null @@ -1,422 +0,0 @@ -class_name DeckScreen -extends Control -## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten. - -const SORTS := ["Kosten", "Name", "Element", "Seltenheit"] - -var app -var slot := 0 -var deck: Array = [] -var filter := "" -var search := "" -var sort_mode := 0 -var show_missing := false -var msg_label: Label -var grid: CardGrid -var deck_list: Control -var deck_label: Label -var elems_label: Label -var valid_label: Label -var active_label: Label -var curve: Control -var preview: CardView -var search_box: LineEdit -var sort_box: OptionButton -var missing_box: CheckButton -var slot_buttons: Array = [] -var b_activate: Button -var b_autofill: Button -var name_box: LineEdit -var confirm: Control -var code_dialog: Control -var code_box: LineEdit - - -class DeckRow extends Control: - signal clicked(card) - signal hover(card, entered: bool) - var card: Cards.Card - var count: int - var lit := false - - func _init(c: Cards.Card, n: int, sz: Vector2) -> void: - card = c - count = n - size = sz - custom_minimum_size = sz - mouse_filter = Control.MOUSE_FILTER_STOP - focus_mode = Control.FOCUS_ALL - var light := func(on: bool): - lit = on - queue_redraw() - hover.emit(card, on) - mouse_entered.connect(light.bind(true)) - mouse_exited.connect(light.bind(false)) - focus_entered.connect(light.bind(true)) - focus_exited.connect(light.bind(false)) - - func _gui_input(event: InputEvent) -> void: - if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: - accept_event() - clicked.emit(card) - elif has_focus() and event.is_action_pressed("ui_accept"): - accept_event() - clicked.emit(card) - - func _draw() -> void: - if lit: - draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size)) - for k in card.elements.size(): - draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]]) - Style.text(self, "%dx %s" % [count, card.title()], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft") - Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true) - - -class EnergyCurve extends Control: - var cards: Array = [] - - func _draw() -> void: - Style.text(self, "Energiekurve", 12, Style.MUTED, Vector2(0, 0)) - var counts := {} - for c in cards: - var k: int = mini(c.cost, 8) - counts[k] = counts.get(k, 0) + 1 - for i in range(1, 9): - var n: int = counts.get(i, 0) - var x := (i - 1) * 48 - draw_rect(Rect2(x, 104 - n * 5, 36, n * 5), Style.BLUE) - if n > 0: - Style.text(self, str(n), 11, Style.TEXT, Vector2(x + 18, 102 - n * 5), "midbottom") - Style.text(self, str(i) + ("+" if i == 8 else ""), 12, Style.MUTED, Vector2(x + 18, 108), "midtop") - - -func _ready() -> void: - slot = app.profile.active_deck - deck = app.profile.decks[slot].cards.duplicate() - Style.label(self, "Deck bauen", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true) - Style.label(self, "Klick: Karte ins Deck. Klick in der Liste: Karte entfernen. Mausrad: scrollen.", - 14, Style.MUTED, Rect2(300, 18, 700, 24)) - Style.button(self, "Alle", Rect2(20, 55, 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)) - fb.add_theme_font_size_override("font_size", 14) - search_box = LineEdit.new() - search_box.position = Vector2(424, 55) - search_box.size = Vector2(136, 34) - search_box.placeholder_text = "Suche" - search_box.text_changed.connect(func(t: String): - search = t - refresh()) - add_child(search_box) - sort_box = OptionButton.new() - sort_box.position = Vector2(570, 55) - sort_box.size = Vector2(130, 34) - for s in SORTS: - sort_box.add_item(s) - sort_box.item_selected.connect(func(i: int): - sort_mode = i - refresh()) - add_child(sort_box) - missing_box = CheckButton.new() - missing_box.position = Vector2(710, 58) - missing_box.text = "Fehlende" - missing_box.toggled.connect(func(on: bool): - show_missing = on - refresh()) - add_child(missing_box) - grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690)) - grid.card_clicked.connect(add) - grid.card_hover.connect(show_preview) - add_child(grid) - # Rechte Seite: Slots, Deckliste, Kurve - Style.panel(self, Rect2(845, 50, 420, 680)) - for i in Profile.DECK_SLOTS: - slot_buttons.append(Style.button(self, str(i + 1), Rect2(860 + i * 46, 58, 40, 32), select_slot.bind(i))) - b_activate = Style.button(self, "Aktiv setzen", Rect2(1100, 58, 150, 32), activate) - name_box = LineEdit.new() - name_box.position = Vector2(860, 98) - name_box.size = Vector2(200, 30) - name_box.max_length = 20 - name_box.text_submitted.connect(func(t: String): rename(t)) - name_box.focus_exited.connect(func(): rename(name_box.text)) - add_child(name_box) - deck_label = Style.label(self, "", 22, Style.TEXT, Rect2(1070, 98, 120, 30), "left", true) - active_label = Style.label(self, "", 14, Style.GREEN, Rect2(1180, 104, 75, 24), "right") - b_autofill = Style.button(self, "Auto-Ausfüllen", Rect2(1100, 154, 150, 26), autofill) - b_autofill.add_theme_font_size_override("font_size", 13) - elems_label = Style.label(self, "", 16, Style.MUTED, Rect2(860, 130, 390, 24)) - valid_label = Style.label(self, "", 14, Style.GREEN, Rect2(860, 156, 235, 22)) - deck_list = Control.new() - deck_list.position = Vector2(860, 182) - deck_list.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(deck_list) - curve = EnergyCurve.new() - curve.position = Vector2(860, 604) - curve.size = Vector2(400, 125) - curve.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(curve) - msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24)) - Style.button(self, "Speichern", Rect2(860, 740, 140, 44), save_deck, Color("285a3c")) - Style.button(self, "Leeren", Rect2(1008, 740, 70, 44), clear_deck) - Style.button(self, "Code", Rect2(1086, 740, 84, 44), open_code) - Style.button(self, "Zurück", Rect2(1178, 740, 82, 44), leave) - # Deck-Code zum Teilen: kopieren, einfügen, übernehmen - code_dialog = Style.overlay(self) - Style.panel(code_dialog, Rect2(240, 270, 800, 240), Style.PANEL, 10, Style.GOLD, 2) - Style.label(code_dialog, "Deck-Code", 26, Style.TEXT, Rect2(240, 290, 800, 40), "center", true) - Style.label(code_dialog, "Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.", 15, Style.MUTED, Rect2(240, 334, 800, 24), "center") - code_box = LineEdit.new() - code_box.position = Vector2(270, 368) - code_box.size = Vector2(740, 36) - code_dialog.add_child(code_box) - Style.button(code_dialog, "In Zwischenablage", Rect2(270, 430, 180, 44), func(): DisplayServer.clipboard_set(code_box.text)) - Style.button(code_dialog, "Aus Zwischenablage", Rect2(460, 430, 180, 44), func(): code_box.text = DisplayServer.clipboard_get()) - Style.button(code_dialog, "Übernehmen", Rect2(650, 430, 170, 44), func(): apply_code(code_box.text), Color("285a3c")) - Style.button(code_dialog, "Schließen", Rect2(830, 430, 180, 44), func(): code_dialog.visible = false) - confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.", - "Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD) - preview = CardView.new(Cards.all()[0], Vector2(200, 280)) - preview.mouse_filter = Control.MOUSE_FILTER_IGNORE - preview.visible = false - add_child(preview) - refresh() - - -func dirty() -> bool: - return deck != app.profile.decks[slot].cards - - -func leave() -> void: - if dirty(): - confirm.visible = true - else: - app.show_screen(MenuScreen.new()) - - -func _input(event: InputEvent) -> void: - if event.is_action_pressed("ui_cancel") and not search_box.has_focus() and not name_box.has_focus() and not code_box.has_focus(): - if confirm.visible: - confirm.visible = false - elif code_dialog.visible: - code_dialog.visible = false - else: - leave() - - -func _process(_delta: float) -> void: - if preview.visible: - var pos := get_local_mouse_position() + Vector2(16, 16) - pos.x = clampf(pos.x, 0, 1280 - preview.size.x) - pos.y = clampf(pos.y, 0, 800 - preview.size.y) - preview.position = pos - - -func set_filter(e: String) -> void: - filter = e - refresh() - - -func select_slot(i: int) -> void: - slot = i - deck = app.profile.decks[slot].cards.duplicate() - say("", true) - refresh() - - -func rename(t: String) -> void: - if t.strip_edges() == app.profile.deck_name(slot): - return - var err: String = app.profile.rename_deck(slot, t) - if err == "": - app.save() - say("Umbenannt in %s" % app.profile.deck_name(slot), true) - else: - say(err, false) - refresh() - - -func autofill() -> void: - app.profile.decks[slot].cards = deck.duplicate() - var err: String = app.profile.autofill_deck(slot) - deck = app.profile.decks[slot].cards.duplicate() - if err == "": - app.save() - say("Aufgefüllt und gespeichert", true) - else: - say(err, false) - refresh() - - -func activate() -> void: - var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection) - if not ok[0]: - say("Erst ein spielbares Deck speichern", false) - return - app.profile.decks[slot].cards = deck.duplicate() - app.profile.active_deck = slot - app.save() - say("%s ist jetzt das aktive Deck" % app.profile.deck_name(slot), true) - refresh() - - -## Karten der Sammlung (oder alle, wenn Fehlende gezeigt werden), gefiltert und sortiert. -func cards() -> Array: - var out: Array = Cards.all().duplicate() if show_missing else app.profile.owned_cards() - if filter != "": - out = out.filter(func(c): return filter in c.elements) - 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()) - match sort_mode: - 0: - out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name)) - 1: - out.sort_custom(func(a, b): return a.name < b.name) - 3: - out.sort_custom(func(a, b): - var ra := Cards.RARITIES.find(a.rarity) - var rb := Cards.RARITIES.find(b.rarity) - return ra > rb or (ra == rb and a.cost < b.cost)) - return out - - -func say(m: String, ok: bool) -> void: - msg_label.text = m - msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED) - - -func add(card: Cards.Card) -> void: - var owned: int = app.profile.collection.get(card.id, 0) - var have := deck.count(card.id) - if owned == 0: - say("%s fehlt in deiner Sammlung" % card.title(), false) - elif deck.size() >= Game.DECK_SIZE: - say("Das Deck ist voll (%d)" % Game.DECK_SIZE, false) - elif have >= mini(owned, Game.MAX_COPIES): - say("Nicht mehr Kopien möglich (max. %d)" % mini(owned, Game.MAX_COPIES), false) - else: - var elems := Game.deck_elements(deck_cards()) - for e in card.elements: - if not (e in elems): - elems.append(e) - if elems.size() > Game.MAX_DECK_ELEMENTS: - say("Höchstens %d Elemente im Deck" % Game.MAX_DECK_ELEMENTS, false) - else: - deck.append(card.id) - app.sfx.play("click") - say("", true) - refresh() - - -func remove(card: Cards.Card) -> void: - deck.erase(card.id) - say("", true) - refresh() - - -func clear_deck() -> void: - deck = [] - refresh() - - -func save_deck() -> void: - var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection) - if ok[0]: - app.profile.decks[slot].cards = deck.duplicate() - app.save() - say("%s gespeichert" % app.profile.deck_name(slot), true) - else: - say(ok[1], false) - refresh() - - -func open_code() -> void: - code_box.text = Profile.deck_code(deck) - code_dialog.visible = true - - -## Übernimmt einen Deck-Code; Karten, die in der Sammlung fehlen, werden gemeldet und weggelassen. -func apply_code(code: String) -> void: - var ids: Array = Profile.parse_deck_code(code) - if ids.is_empty(): - say("Ungültiger Code", false) - return - var p: Profile = app.profile - var missing := {} - deck = [] - for cid in ids: - var owned: int = p.collection.get(cid, 0) - if deck.count(cid) < owned and deck.size() < Game.DECK_SIZE: - deck.append(cid) - else: - missing[cid] = true - code_dialog.visible = false - if missing.is_empty(): - say("Code übernommen", true) - else: - say(tr("Code übernommen, es fehlen: ") + ", ".join(missing.keys().map(func(cid): return Cards.by_id(cid).title())), false) - refresh() - - -func deck_cards() -> Array: - var out := [] - for cid in deck: - out.append(Cards.by_id(cid)) - return out - - -func show_preview(card: Cards.Card, entered: bool) -> void: - if entered: - preview.card = card - preview.visible = true - preview.queue_redraw() - elif preview.card == card: - preview.visible = false - - -func refresh() -> void: - var p: Profile = app.profile - grid.fill(cards(), - func(c): - var owned: int = p.collection.get(c.id, 0) - return "fehlt" if owned == 0 else "%d/%d im Deck" % [deck.count(c.id), owned], - func(c): - var owned: int = p.collection.get(c.id, 0) - return owned == 0 or deck.count(c.id) >= mini(owned, Game.MAX_COPIES)) - for i in slot_buttons.size(): - var b: Button = slot_buttons[i] - var col: Color = Style.GOLD if i == slot else (Style.GREEN if p.deck_valid(i)[0] else Style.MUTED) - b.add_theme_stylebox_override("normal", Style.box(Style.PANEL_LIGHT if i != slot else Style.darker(Style.GOLD, 0.45), 6, col, 2)) - b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Style.PANEL_LIGHT), 6, col, 2)) - active_label.text = "aktiv" if slot == p.active_deck else "" - # Deckliste - Style.clear(deck_list) - var counts := {} - for cid in deck: - counts[cid] = counts.get(cid, 0) + 1 - var listed: Array = counts.keys().map(func(cid): return Cards.by_id(cid)) - listed.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name)) - for i in listed.size(): - var c: Cards.Card = listed[i] - var row := DeckRow.new(c, counts[c.id], Vector2(400, 20)) - row.position = Vector2(0, i * 21) - row.clicked.connect(remove) - row.hover.connect(show_preview) - deck_list.add_child(row) - var dc := deck_cards() - if not name_box.has_focus(): - name_box.text = p.deck_name(slot) - deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE] - deck_label.tooltip_text = tr("Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten") - deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT) - var elems := Game.deck_elements(dc) - elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b)) - elems_label.text = tr("Elemente: ") + (", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else "–") - if not dc.is_empty(): - elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc) - var ok: Array = Game.validate_deck(dc, p.collection) - valid_label.text = "spielbar" if ok[0] else ok[1] - valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE) - curve.cards = dc - curve.queue_redraw() diff --git a/scripts/ui/deck_screen.gd.uid b/scripts/ui/deck_screen.gd.uid deleted file mode 100644 index 972d8f1..0000000 --- a/scripts/ui/deck_screen.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://yg8pbj56mib5 diff --git a/scripts/ui/lobby_screen.gd b/scripts/ui/lobby_screen.gd index 44823ae..4ebbd95 100644 --- a/scripts/ui/lobby_screen.gd +++ b/scripts/ui/lobby_screen.gd @@ -19,7 +19,7 @@ var addr_label: Label func _ready() -> void: net = app.net net.my_name = "Spieler" - net.my_deck = app.profile.deck.duplicate() + 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) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index e1a0fe3..9f08984 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -23,7 +23,6 @@ func _ready() -> void: net.name = "Net" get_tree().root.add_child.call_deferred(net) profile = Profile.load_or_new(save_path) - profile.refresh_daily() apply_settings() show_screen(MenuScreen.new()) @@ -36,10 +35,9 @@ func apply_settings() -> void: theme = Style.make_theme() -## Neues Profil: Sammlung, Decks und Fortschritt werden verworfen. +## Neues Profil: Sammlung und Fortschritt werden verworfen. func reset_profile() -> void: profile = Profile.starter() - profile.refresh_daily() clear_battle_save() save() apply_settings() diff --git a/scripts/ui/menu_screen.gd b/scripts/ui/menu_screen.gd index ea808ca..e61f339 100644 --- a/scripts/ui/menu_screen.gd +++ b/scripts/ui/menu_screen.gd @@ -2,27 +2,19 @@ class_name MenuScreen extends Control var app -var b_single: Button -var b_campaign: Button -var b_online: Button var b_resume: Button var coins_label: Label var coll_label: Label -var deck_label: Label -var daily_label: Label -var warn_label: Label var notice_label: Label func _ready() -> void: - var col := Style.column(self, 640, 24, 5) + 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) - deck_label = Style.clabel(col, "", 16, Style.MUTED) - daily_label = Style.clabel(col, "", 16, Style.BLUE) Style.spacer(col, 8) var grid := GridContainer.new() grid.columns = 2 @@ -34,16 +26,14 @@ func _ready() -> void: var sz := Vector2(270, 44) if FileAccess.file_exists(app.battle_save_path()): b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628")) - b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c")) - b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c")) - b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c")) + 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, "Deck bauen", sz, func(): app.show_screen(DeckScreen.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) - warn_label = Style.clabel(col, "", 16, Style.RED) notice_label = Style.clabel(col, "", 16, Style.GREEN) notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD refresh() @@ -62,24 +52,9 @@ func _resume() -> void: func refresh() -> void: var p: Profile = app.profile - var ok: Array = p.deck_valid() - b_single.disabled = not ok[0] - b_campaign.disabled = not ok[0] - b_online.disabled = not ok[0] - var total := 0 - for cid in p.collection: - total += p.collection[cid] coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust] - coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [ - p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()] - var elems := Game.deck_elements(p.deck_cards()) - deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else tr("leer")] - var task := p.daily_task() - if p.daily.done: - daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text - else: - daily_label.text = tr("Tagesaufgabe: %s (%d/%d) · +%d Münzen") % [task.text, p.daily.progress, task.goal, task.reward] - warn_label.text = "" if ok[0] else tr("Aktives Deck nicht spielbar: ") + tr(ok[1]) + coll_label.text = tr("Aufgewertet: %d von %d Karten · Kampagne: %d/%d") % [ + p.upgraded_count(), Cards.all().size(), p.campaign_stage, Campaign.total()] var notes := p.take_notices() notice_label.text = " · ".join(notes) if not notes.is_empty(): diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd index 0ae4553..b464963 100644 --- a/scripts/ui/opponent_select_screen.gd +++ b/scripts/ui/opponent_select_screen.gd @@ -1,10 +1,9 @@ class_name OpponentSelectScreen extends Control -## Einzelspieler: Schwierigkeit wählen und die Gegnerzahl schieben. Die Gegnerdecks -## werden erst beim Start zufällig gezogen (siehe BattleScreen und DeckGen). +## Einzelspieler: Schwierigkeit und Gegnerzahl wählen. Die Karten kommen im Kampf +## aus dem Laden (siehe DeckGen.shop_cards). var app -var rating_label: Label var diff_buttons := {} var count_slider: HSlider var count_label: Label @@ -12,17 +11,16 @@ var info_label: Label func _ready() -> void: - var col := Style.column(self, 1000, 60, 14) + var col := Style.column(self, 1000, 50, 12) Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true) - Style.spacer(col, 10) + Style.spacer(col, 8) Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true) var dr := Style.row(col, 10) dr.alignment = BoxContainer.ALIGNMENT_CENTER - for key in ["leicht", "normal", "schwer"]: + 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)) - rating_label = Style.clabel(col, "", 15, Style.MUTED) - Style.spacer(col, 24) + 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 @@ -39,7 +37,7 @@ func _ready() -> void: 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, 30) + 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")) @@ -64,16 +62,18 @@ func start() -> void: func refresh() -> void: var p: Profile = app.profile for key in diff_buttons: - var b: Button = diff_buttons[key] - var active: bool = key == p.settings.difficulty - b.add_theme_stylebox_override("normal", Style.box(Color("285a3c") if active else Style.PANEL_LIGHT, 8, Style.GOLD if active else Style.MUTED, 2)) - b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Color("285a3c") if active else Style.PANEL_LIGHT), 8, Style.GOLD if active else Style.MUTED, 2)) - rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f") % [p.deck_rating(), p.target_rating()] + _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] +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 cae8aaf..45e59e5 100644 --- a/scripts/ui/settings_screen.gd +++ b/scripts/ui/settings_screen.gd @@ -76,13 +76,12 @@ func _ready() -> void: grid.add_child(language) Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) Style.spacer(col, 20) - Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED) Style.spacer(col, 30) 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, Decks, Münzen und Fortschritt gehen verloren.", + confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.", "Ja, zurücksetzen", _reset, "Abbrechen", Style.RED) _labels() diff --git a/scripts/ui/sfx.gd b/scripts/ui/sfx.gd index 59833c9..0a17cc2 100644 --- a/scripts/ui/sfx.gd +++ b/scripts/ui/sfx.gd @@ -2,8 +2,6 @@ class_name Sfx extends Node ## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig). -static var inst: Sfx - var streams := {} var players: Array[AudioStreamPlayer] = [] var volume := 0.8 @@ -14,7 +12,6 @@ var music_box := {} func _ready() -> void: - inst = self streams["click"] = _tone(880, 0.05, "sine") streams["play"] = _tone(440, 0.14, "sine", 660) streams["attack"] = _tone(220, 0.16, "square", 90) diff --git a/scripts/ui/style.gd b/scripts/ui/style.gd index 42f4d45..9c92c02 100644 --- a/scripts/ui/style.gd +++ b/scripts/ui/style.gd @@ -2,12 +2,8 @@ class_name Style ## Farben, Schriften, Theme und Zeichenhilfen. const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf") -static var font_scale := 1.0 # Einstellung „Große Schrift“ - - -static func fs(size: int) -> int: - return int(round(size * font_scale)) const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf") +static var font_scale := 1.0 # Einstellung „Große Schrift“ const BG := Color("1a1c24") const PANEL := Color("282b36") @@ -20,6 +16,12 @@ const RED := Color("e65555") const BLUE := Color("6eaaf0") const YELLOW := Color("ffe65a") const ORANGE := Color("ff963c") +const PURPLE := Color("8c78dc") + + +## Schriftgröße mit der Einstellung „Große Schrift“. +static func fs(size: int) -> int: + return int(round(size * font_scale)) static func darker(c: Color, f: float = 0.65) -> Color: @@ -244,65 +246,42 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius ci.draw_style_box(sb, rect) +## 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: - var a: Color = Cards.ELEMENT_COLORS[elements[0]] if elements.size() == 1: - ci.draw_style_box(box(a, radius), rect) - elif elements[0] == elements[1]: - # Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen - ci.draw_style_box(box(a, radius), rect) - border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3)) - else: - var b: Color = Cards.ELEMENT_COLORS[elements[1]] - var half := floorf(rect.size.x / 2) - var left := StyleBoxFlat.new() - left.bg_color = a - left.corner_radius_top_left = radius - left.corner_radius_bottom_left = radius - var right := StyleBoxFlat.new() - right.bg_color = b - right.corner_radius_top_right = radius - right.corner_radius_bottom_right = radius - ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y))) - ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y))) + ci.draw_style_box(box(Cards.ELEMENT_COLORS[elements[0]], radius), rect) + return + var n := elements.size() + var w := rect.size.x / n + for i in n: + var sb := StyleBoxFlat.new() + sb.bg_color = Cards.ELEMENT_COLORS[elements[i]] + if i == 0: + sb.corner_radius_top_left = radius + sb.corner_radius_bottom_left = radius + if i == n - 1: + sb.corner_radius_top_right = radius + sb.corner_radius_bottom_right = radius + ci.draw_style_box(sb, Rect2(rect.position + Vector2(floorf(i * w), 0), Vector2(ceilf(w), rect.size.y))) -# --- Kartensymbole (prozedural, je Element und Kartenart) -------------------------- +# --- Kartensymbole (prozedural, je Element) -------------------------- static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void: var variant := absi(hash(card.id)) % 3 var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38) col.a = 0.95 - if card.is_trap(): - # Falle: Kreuz im Kreis - ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08)) - var d := size * 0.28 - ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1)) - ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1)) - return - if card.kind == "Zauber": - _star(ci, center, size * 0.55, Color(col, 0.35)) var elems: Array = card.elements - if elems.size() >= 2 and elems[0] != elems[1]: - _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 - _element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2) - else: + if elems.size() == 1: _element_glyph(ci, elems[0], center, size, variant, col) - if elems.size() >= 2: - ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2) - if elems.size() >= 3: - ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2) - - -static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void: - var pts := PackedVector2Array() - for i in 8: - var a := TAU * i / 8 - PI / 2 - var rr := r if i % 2 == 0 else r * 0.45 - pts.append(c + Vector2(cos(a), sin(a)) * rr) - ci.draw_colored_polygon(pts, col) + return + # 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 + _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) static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void: diff --git a/tests/frame_probe.gd b/tests/frame_probe.gd index 6876c69..df1c30d 100644 --- a/tests/frame_probe.gd +++ b/tests/frame_probe.gd @@ -19,14 +19,11 @@ func _ready() -> void: await get_tree().process_frame await get_tree().process_frame var h: Game.Player = scr.game.human - while h.hand.size() < 8 and not h.deck.is_empty(): - scr.game.draw(h) + scr.game.buy(h, 0) 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)) - scr.mulligan.visible = false - h.mulligan_done = true scr.ai_delay = 0.2 measuring = true var start := Time.get_ticks_msec() diff --git a/tests/matchup.gd b/tests/matchup.gd deleted file mode 100644 index 19058f3..0000000 --- a/tests/matchup.gd +++ /dev/null @@ -1,35 +0,0 @@ -extends SceneTree -## Misst Deckarchetypen im Duell (beide gierig): wer beginnt, Mono gegen Paar. - -func deck(r: RandomNumberGenerator, mono: bool) -> Array: - var pool: Array = Cards.ELEMENTS.duplicate() - Game.shuffle(pool, r) - return Game.build_opponent_deck(r, [pool[0]] if mono else [pool[0], pool[1]]) - - -func duel(r: RandomNumberGenerator, first_mono: bool, second_mono: bool) -> bool: - var second := Game.Player.new("Gegner 1", deck(r, second_mono), false, Game.START_LIFE, "x") - var g := Game.new_battle_with(deck(r, first_mono), [second], r) - var guard := 0 - while g.winner == "" and guard < 300: - AI.take_turn(g, g.current(), true) - guard += 1 - return g.winner == "human" - - -func run(label: String, a_mono: bool, b_mono: bool, games: int, r: RandomNumberGenerator) -> void: - var wins := 0 - for i in games: - if duel(r, a_mono, b_mono): - wins += 1 - print("%-28s Startspieler gewinnt %3d%%" % [label, wins * 100 / games]) - - -func _initialize() -> void: - var r := RandomNumberGenerator.new() - r.seed = 5 - run("Paar gegen Paar", false, false, 200, r) - run("Mono gegen Mono", true, true, 200, r) - run("Paar (startet) gegen Mono", false, true, 200, r) - run("Mono (startet) gegen Paar", true, false, 200, r) - quit() diff --git a/tests/matchup.gd.uid b/tests/matchup.gd.uid deleted file mode 100644 index 828baba..0000000 --- a/tests/matchup.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bu5hjcfuinhy8 diff --git a/tests/net_bot.gd b/tests/net_bot.gd index 21a14f6..afee194 100644 --- a/tests/net_bot.gd +++ b/tests/net_bot.gd @@ -25,7 +25,7 @@ func _ready() -> void: net.name = "Net" get_tree().root.add_child.call_deferred(net) net.my_name = bot_name - net.my_deck = Profile.starter().deck.duplicate() + net.my_pick = {"elements": ["Feuer", "Erde"], "looks": {}} net.started.connect(func(s): seat = s if hosting: @@ -73,16 +73,18 @@ func _act_host() -> void: _decide(game.players[seat]) -## Einfache Züge mit kurzer Bedenkzeit: Mulligan überspringen, erstes spielbares Wesen ohne Ziel, -## dann angreifen, sonst Zug beenden. +## Einfache Züge mit kurzer Bedenkzeit: erst das billigste bezahlbare Angebot kaufen, dann das +## erste spielbare Wesen ohne Ziel, dann angreifen, sonst Zug beenden. func _decide(me: Game.Player) -> void: var g := game await get_tree().create_timer(0.25).timeout if game != g or (hosting and (net.game == null or net.game.current_idx != seat)): return # inzwischen ein neuer Zustand - if not me.mulligan_done and me.turns == 1: - net.send_action({"type": "mulligan", "hand": []}) - return + for i in me.shop.size(): + var offer = me.shop[i] + if offer != null and me.energy >= offer.cost and me.hand.size() < Game.MAX_HAND: + net.send_action({"type": "buy", "slot": i}) + return for i in me.hand.size(): var card: Cards.Card = me.hand[i] if game.can_play(me, card)[0] and card.target_type() == "": diff --git a/tests/perf_probe.gd b/tests/perf_probe.gd index 15ac8c9..ec75941 100644 --- a/tests/perf_probe.gd +++ b/tests/perf_probe.gd @@ -14,18 +14,16 @@ func _initialize() -> void: print("Kartenpool laden: %.1f ms" % t) var r := RandomNumberGenerator.new() r.seed = 3 - var starter := Profile.starter().deck_cards() - print("Gegnerdeck (Zielstärke): %.1f ms" % ms(func(): DeckGen.build_opponent_deck_rated(r, ["Feuer", "Erde"], 1.5), 10)) + 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: - Game.random_opponent(r, i + 1, Game.START_LIFE, 1.3, ""))) + DeckGen.random_opponent(i + 1))) for n in [1, 3, 5]: - var g := Game.new_battle(starter, n, r, {"target_rating": 1.5}) + var g := Game.new_battle(n, r) # ein volles Brett für alle, damit die Entscheidung teuer wird var uid := 5000 for p in g.players: - p.mulligan_done = true for k in 4: var c := Game.Creature.new(Cards.by_id("f_wolf"), p, uid) uid += 1 @@ -36,15 +34,14 @@ func _initialize() -> void: h.energy = 10 h.max_energy = 10 for k in 6: - h.hand.append(Cards.by_id("f_funke")) + h.hand.append(Cards.by_id("f_ritter")) AI.time_budget_ms = 100000 var t_full := ms(func(): AI.choose_action(g, h)) AI.time_budget_ms = 150 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(starter, 5, r).clone(), 20)) + print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(5, r).clone(), 20)) print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music())) - print("Deckstärke berechnen: %.3f ms" % ms(func(): Game.deck_rating(starter), 100)) - print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(starter, 5, r).snapshot(0), 10)) + print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(5, r).snapshot(0), 10)) quit() diff --git a/tests/simulate.gd b/tests/simulate.gd index 85b7b88..2d28eec 100644 --- a/tests/simulate.gd +++ b/tests/simulate.gd @@ -8,31 +8,26 @@ func _initialize() -> void: var games := 100 var r := RandomNumberGenerator.new() r.seed = 0 - var starter := Profile.starter().deck_cards() - var random_deck := func(rr: RandomNumberGenerator) -> Array: - var pool: Array = Cards.ELEMENTS.duplicate() - Game.shuffle(pool, rr) - return Game.build_opponent_deck(rr, [pool[0], pool[1]]) var t0 := Time.get_ticks_msec() print("Alle gierig (Erwartung etwa 1/(Gegner+1)):") for n in range(1, Game.MAX_OPPONENTS + 1): - _run("Zufallsdeck", random_deck, n, games, r, true, true) + _run(n, games, r, true, true) print("Du mit Vorausschau, Gegner gierig:") for n in [1, 2, 3]: - _run("Zufallsdeck", random_deck, n, games / 2, r, false, true) + _run(n, games / 2, r, false, true) print("Alle mit Vorausschau:") - _run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false) - _run("Zufallsdeck", random_deck, 2, games / 4, r, false, false) + _run(1, games / 2, r, false, false) + _run(2, games / 4, r, false, false) print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0)) quit() -func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNumberGenerator, +func _run(n_opp: int, 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(deck_fn.call(r), n_opp, r) + var g := Game.new_battle(n_opp, r) while g.winner == "": var p := g.current() AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy) @@ -40,4 +35,4 @@ func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNum if g.winner == "human": wins += 1 turns += g.turn - print("%-26s vs %d: Siege %3d%% Ø Züge %.1f" % [label, n_opp, wins * 100 / games, float(turns) / games]) + print("gegen %d: Siege %3d%% Ø Züge %.1f" % [n_opp, wins * 100 / games, float(turns) / games]) diff --git a/tests/strength_probe.gd b/tests/strength_probe.gd deleted file mode 100644 index 129bc70..0000000 --- a/tests/strength_probe.gd +++ /dev/null @@ -1,41 +0,0 @@ -extends SceneTree -## Archetyp-Stärke: jede Elementkombination gegen zufällige andere, gleiche Zielstärke, beide KI. - - -func archetypes() -> Array: - var out := [] - for e in Cards.ELEMENTS: - out.append([e]) - for i in Cards.ELEMENTS.size(): - for j in range(i + 1, Cards.ELEMENTS.size()): - out.append([Cards.ELEMENTS[i], Cards.ELEMENTS[j]]) - return out - - -func _initialize() -> void: - var r := RandomNumberGenerator.new() - r.seed = 21 - var arts := archetypes() - var n := int(OS.get_environment("N")) if OS.get_environment("N") != "" else 60 - var results := {} - for a in arts: - var wins := 0 - for i in n: - var b: Array = arts[r.randi_range(0, arts.size() - 1)] - var deck_a := Game.build_opponent_deck_rated(r, a, 1.6) - var deck_b := Game.build_opponent_deck_rated(r, b, 1.6) - var opp := Game.Player.new("Gegner 1", deck_b, false, Game.START_LIFE, "x") - var g := Game.new_battle_with(deck_a, [opp], r) - var guard := 0 - while g.winner == "" and guard < 300: - AI.take_turn(g, g.current()) - guard += 1 - if g.winner == "human": - wins += 1 - results["+".join(a)] = wins * 100 / n - print("%-14s %3d%%" % ["+".join(a), wins * 100 / n]) - var f := FileAccess.open(DeckGen.ARCHETYPE_PATH, FileAccess.WRITE) - f.store_string(JSON.stringify(results, "\t")) - f.close() - print("nach %s geschrieben" % DeckGen.ARCHETYPE_PATH) - quit() diff --git a/tests/strength_probe.gd.uid b/tests/strength_probe.gd.uid deleted file mode 100644 index 4c2457c..0000000 --- a/tests/strength_probe.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d4m21t7od3kdm diff --git a/tests/test_runner.gd b/tests/test_runner.gd index 1efb1db..e23f8cb 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -19,13 +19,10 @@ func C(id: String) -> Cards.Card: func setup(human_hand: Array = [], opp_hand: Array = [], n_opp: int = 1) -> Array: - var deck := [] - for i in 10: - deck.append(C("f_kobold")) - var human := Game.Player.new("Du", deck, true, 30) + var human := Game.Player.new("Spieler", true, 30) var opps := [] for i in n_opp: - opps.append(Game.Player.new("Gegner %d" % (i + 1), deck, false, 15)) + opps.append(Game.Player.new("Gegner %d" % (i + 1), false, 15)) var r := RandomNumberGenerator.new() r.seed = 0 var g := Game.new(human, opps, r) @@ -58,7 +55,6 @@ func _initialize() -> void: TranslationServer.set_locale("de") # das Spiel setzt die Sprache aus den Einstellungen; hier fehlt der Einstieg test_kartenpool() test_kampf_speichern() - test_elementbooster_und_deckcode() test_englische_karten() test_kombos() test_wesen_spielen() @@ -69,50 +65,41 @@ func _initialize() -> void: test_schild() test_doppelschlag() test_einfrieren() - test_zauber_ziel() + test_effekt_ziel() test_flaechenschaden() test_zurueck_auf_hand() test_buff_heilung() test_limits() - test_leeres_deck() + test_grenzfaelle() + test_grenzfaelle_2() + test_laden() + test_mana_effekt() test_gleiche_regeln() test_zugreihenfolge() test_tote_gegner() test_jeder_gegen_jeden() test_gegnerzahl() test_sieg_niederlage() - test_deckregeln() - test_gegnerdeck() test_booster() test_spielstand() test_resonanz() + test_elementvorteil() + test_feld() + test_verschmelzen() test_feldkombos() - test_schild_effekt_und_dreifach() + test_schild_effekt_und_sonne() test_kopie() test_ki_lethal() test_ki_besser_als_gierig() test_ki_spiele_enden() test_spielstand_migration() - test_tagesaufgabe() test_kampagne() - test_fallen() - test_ki_sieht_fallen_nicht() test_zufallssamen() - test_deck_slots_und_werkstatt() test_spielstand_sicherung() - test_mulligan() test_merkmale() test_ki_zwei_schritte() test_snapshot() - test_statistik_pity_tagesaufgaben() - test_deck_umbenennen_und_auffuellen() - test_neue_fallen() test_kapitel_zwei_und_merkmalwahl() - test_bestenliste() - test_deckstaerke_und_schwierigkeit() - test_starter_gegen_passende_gegner() - test_kurve_statistik_vorschlag_verbesserung() - test_wasser_antworten() test_ki_zeitbudget() test_uebersetzung_vollstaendig() print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed]) @@ -120,25 +107,22 @@ func _initialize() -> void: func test_kartenpool() -> void: - check(Cards.all().size() == 118, "118 Karten") - for key in Cards.COMBOS: - var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key) - check(found, "Kombo vorhanden: " + key) + check(Cards.all().size() == 77, "77 Karten") + check(Cards.all().all(func(c): return c.elements.size() == 1 and c.elements[0] in Cards.ELEMENTS), "jede Karte hat genau ein Element") for e in Cards.ELEMENTS: check(Cards.all().any(func(c): return c.elements == [e]), "Element vorhanden: " + e) for c in Cards.all(): - check(c.rarity in Cards.RARITIES, "Seltenheit " + c.id) - check(not c.is_creature() or c.health > 0, "Leben " + c.id) + check(c.health > 0 and c.cost > 0, "Werte " + c.id) check(C("el_koenig").text() == "Wächter, Flink, Schild. Beim Ausspielen: Alle befreundeten Wesen erhalten +1/+1.", "Kartentext") - check(C("f_funke").text() == "2 Schaden an ein Ziel.", "Zaubertext") - check(C("ww_eiszeit").text() == "Friere alle gegnerischen Wesen ein, Ziehe 1 Karte.", "Eiszeit-Text") + check(C("f_ritter").text() == "Flink. Beim Ausspielen: 1 Schaden an ein Ziel.", "Kartentext mit Effekt") + check(C("www_ozean").text() == "Wächter. Beim Ausspielen: Friere alle gegnerischen Wesen ein, Erhalte 3 Mana.", "Ozean-Text") func test_kombos() -> void: check(Cards.combo_name(["Feuer", "Feuer"]) == "Inferno", "Feuer+Feuer = Inferno") check(Cards.combo_name(["Luft", "Feuer"]) == "Blitz", "Luft+Feuer = Blitz") - check(C("ff_phoenix").is_combo() and C("ff_phoenix").combo() == "Inferno", "Phönix ist Inferno") - check(not C("f_funke").is_combo(), "Funke keine Kombo") + check(not C("ff_phoenix").is_combo() and Cards.fusion_name(["Feuer"]) == "Inferno", "Kombos gibt es nur beim Verschmelzen") + check(Cards.combo_name(["Feuer", "Wasser", "Erde"]) == "Chimäre", "drei Elemente sind eine Chimäre") func test_wesen_spielen() -> void: @@ -146,7 +130,7 @@ func test_wesen_spielen() -> void: var g: Game = s[0] var h: Game.Player = s[1] check(g.play_card(h, C("f_wolf")) == "", "Wolf spielen") - check(h.energy == 8 and h.board.size() == 1, "Energie bezahlt") + check(h.energy == 10 and h.board.size() == 1, "Ausspielen kostet nichts") check(not h.board[0].can_attack(), "Erschöpfung") g.end_turn() g.end_turn() @@ -223,13 +207,13 @@ func test_doppelschlag() -> void: func test_einfrieren() -> void: - var s := setup([], ["w_eis"]) + var s := setup([], ["w_frost"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2][0] var a := put(h, "f_wolf") g.end_turn() - check(g.play_card(o, C("w_eis"), a) == "", "Eis spielen") + check(g.play_card(o, C("w_frost"), a) == "", "Frostmagier spielen") check(a.frozen, "eingefroren") g.end_turn() check(not a.frozen and not a.can_attack(), "taut auf, setzt aus") @@ -238,15 +222,14 @@ func test_einfrieren() -> void: check(a.can_attack(), "wieder bereit") -func test_zauber_ziel() -> void: - var s := setup(["w_eis", "f_funke"]) +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] - check(not g.can_play(h, C("w_eis"))[0], "Eis ohne Ziel") - check(g.can_play(h, C("f_funke"))[0], "Funke mit Held als Ziel") - g.play_card(h, C("f_funke"), o) - check(o.life == 13, "2 Schaden") + 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: @@ -263,23 +246,24 @@ func test_flaechenschaden() -> void: func test_zurueck_auf_hand() -> void: - var s := setup(["l_stoss"]) + var s := setup(["wl_nebel"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2][0] var d := put(o, "e_troll") - g.play_card(h, C("l_stoss"), d) + g.play_card(h, C("wl_nebel"), d) check(o.board.is_empty() and C("e_troll") in o.hand, "zurück auf Hand") func test_buff_heilung() -> void: - var s := setup(["e_haerten", "e_baum"]) + var s := setup(["el_koenig", "e_baum"]) var g: Game = s[0] var h: Game.Player = s[1] var a := put(h, "f_wolf") h.life = 20 - g.play_card(h, C("e_haerten"), a) - check(a.attack == 5 and a.health == 4, "+2/+2") + h.energy = 20 + g.play_card(h, C("el_koenig")) + check(a.attack == 4 and a.health == 3, "der König verstärkt alle eigenen Wesen um +1/+1") g.play_card(h, C("e_baum")) check(h.life == 25, "Heilung") @@ -294,37 +278,163 @@ func test_limits() -> void: h.hand = [] for i in Game.MAX_HAND: h.hand.append(C("f_kobold")) - g.draw(h) - check(h.hand.size() == Game.MAX_HAND, "Hand voll verbrennt") + h.energy = 10 + h.shop = [C("f_kobold")] + check(g.buy(h, 0) == "Hand voll" and h.hand.size() == Game.MAX_HAND, "volle Hand kauft nicht") -func test_leeres_deck() -> void: +## Grenzfälle, die schon einmal falsch waren. +func test_grenzfaelle() -> void: + var s := setup(["f_wolf"], [], 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] + # 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") + # Besiegte Spieler nehmen ihre Wesen mit + var dead := put(o1, "e_troll") + 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(dead != null, "Wesen war vorher da") + # Verschmolzene Karte auf der Hand übersteht den Spielstand + var a := put(h, "f_kobold") + var b := put(h, "w_geist") + h.energy = 10 + check(g.fuse(h, a, b) == "", "verschmolzen") + var fused: Game.Creature = h.board[h.board.size() - 1] + h.hand.append(fused.card) + var back := NetState.from_snapshot(JSON.parse_string(JSON.stringify(g.snapshot(-1)))) + check(back.players[0].hand.any(func(c): return c.name == fused.card.name), "verschmolzene Karte übersteht die Hand im Spielstand") + + +## 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 g: Game = s[0] + var h: Game.Player = s[1] + var o1: Game.Player = s[2][0] + # 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 + g.attack(reiter, wall) + check(reiter.health == 3 and wall.health == 4 - 3 + 1, "Steinwall kontert nicht, nimmt aber 1 weniger") + # Drei Elemente ergeben eine Chimäre: erst Feuer und Erde zu Lava, dann Lava und Wasser + var fire := put(h, "f_wolf") + var earth := put(h, "e_kiesel") + h.energy = 10 + check(g.fuse(h, fire, earth) == "", "Wolf und Kiesel verschmelzen") + var lava: Game.Creature = h.board[h.board.size() - 1] + check(lava.name == "Lava" and lava.card.elements == ["Feuer", "Erde"], "zwei Elemente heißen nach ihrer Kombo") + var water := put(h, "w_geist") + check(g.fuse(h, lava, water) == "", "Lava und Flussgeist verschmelzen") + var chimera: Game.Creature = h.board[h.board.size() - 1] + check(chimera.name == "Chimäre" and chimera.card.elements.size() == 3 and chimera.card.combo() == "Chimäre", "drei Elemente heißen Chimäre") + # Ein "play"-Ereignis mit verschmolzener Karte übersteht den Spielstand + h.hand.append(chimera.card) + h.board.erase(chimera) + g.events.clear() + check(g.play_card(h, chimera.card) == "", "Chimäre von der Hand gespielt") + var back := NetState.from_snapshot(JSON.parse_string(JSON.stringify(g.snapshot(-1)))) + check(back.events.any(func(e): return e.type == "play" and e.card.name == "Chimäre"), "Ereignis mit verschmolzener Karte übertragen") + # Netzwerk: wer während des eigenen Zugs ausscheidet, blockiert das Spiel nicht + var net := Net.new() + net.is_host = true + 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 + 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") + net.free() + + +## Laden: kaufen kostet Mana, ausspielen nichts, nicht Ausgegebenes bleibt liegen. +func test_laden() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] - h.deck = [] - g.draw(h, 3) - check(h.life == 30 - 6, "Ermüdung 1+2+3") + h.max_energy = 3 + g.roll_shop(h) + check(h.shop.size() == Game.SHOP_SIZE, "fünf Angebote") + check(h.shop.all(func(c): return c.cost <= h.max_energy + 1), "nur Karten bis Einkommen plus eins") + for i in 20: # der erste Zug darf nie tot sein + var poor := Game.Player.new("Arm", false, 30) + poor.max_energy = 1 + g.roll_shop(poor) + check(poor.shop.any(func(c): return c.cost <= 1), "immer ein bezahlbares Angebot") + h.energy = 20 + var card: Cards.Card = h.shop[0] + var before: int = h.energy + check(g.buy(h, 0) == "" and h.hand == [card], "gekauft, liegt auf der Hand") + check(h.energy == before - card.cost and h.shop[0] == null, "Mana bezahlt, Platz leer") + check(g.buy(h, 0) != "" and g.buy(h, 99) != "", "leerer und unbekannter Platz") + before = h.energy + var gain: int = 0 # manche Wesen geben beim Ausspielen Mana + for fx in card.effects: + if fx.kind == "mana": + gain += fx.amount + check(g.play_card(h, card) == "" and h.energy == before + gain, "Ausspielen kostet nichts") + before = h.energy + check(g.reroll(h) == "" and h.energy == before - Game.REROLL_COST and h.shop.size() == Game.SHOP_SIZE, "neu gewürfelt") + h.energy = 0 + check(g.reroll(h) != "" and g.buy(h, 0) != "", "ohne Mana geht nichts") + h.energy = 100 + for i in Game.MAX_HAND: + h.hand.append(C("f_kobold")) + check(g.buy(h, 1) != "", "volle Hand kauft nicht") + # Teure Karten tauchen erst mit dem Einkommen auf + h.max_energy = Game.MAX_ENERGY + g.roll_shop(h) + check(DeckGen.pool(1).all(func(c): return c.cost <= 2) and DeckGen.pool(Game.MAX_ENERGY).size() == Cards.all().size(), + "Angebot wächst mit dem Einkommen") + # Nicht ausgegebenes Mana bleibt liegen + var s2 := setup() + var g2: Game = s2[0] + var h2: Game.Player = s2[1] + h2.energy = 0 + h2.max_energy = 0 + g2.begin_turn(h2) + check(h2.energy == 1 and h2.max_energy == 1, "erste Runde: 1 Mana") + g2.begin_turn(h2) + check(h2.energy == 3 and h2.max_energy == 2, "gespartes Mana bleibt liegen") + + +## "Erhalte Mana" statt Nachziehen: Kartenvorteil ist im Laden Manavorteil. +func test_mana_effekt() -> void: + var s := setup(["w_geist"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var before: int = h.energy + g.play_card(h, C("w_geist")) + check(h.energy == before + 1, "Flussgeist gibt 1 Mana") + check(C("w_geist").text() == "Beim Ausspielen: Erhalte 1 Mana.", "Kartentext: " + C("w_geist").text()) + check(Cards.FIELD_COMBO_TEXT["Sturm"] == "Erhalte 1 Mana", "Sturm gibt Mana") + func test_gleiche_regeln() -> void: var r := RandomNumberGenerator.new() r.seed = 0 - var g := Game.new_battle(Profile.starter().deck_cards(), 3, r) + var g := Game.new_battle(3, r) var first: Game.Player = g.players[0] - check(g.current() == first and first.max_energy == 1 and first.energy == 1, "Startspieler: 1 Energie, kein Bonus") - check(first.hand.size() == Game.START_HAND + 1, "Startspieler 5 Handkarten") + 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") for p in g.players: check(p.life == Game.START_LIFE, "gleiches Leben") if p != first: - check(p.hand.size() == Game.START_HAND + 1 and p.start_bonus == 1, "Nachzieher: Karte mehr, Bonus offen") + check(p.start_bonus == 1, "Nachzieher: Bonus offen") 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 Energie plus Ausgleich") - check(second.hand.size() == Game.START_HAND + 2, "Nachzieher 6 Handkarten nach dem Ziehen") + check(g.current() == second and second.max_energy == 1 and second.energy == 2, "Nachzieher: 1 Mana plus Ausgleich") for i in 4: g.end_turn() - check(g.current() == second and second.energy == 2 and second.max_energy == 2, "Ausgleich nur einmal") + check(g.current() == second and second.max_energy == 2, "Ausgleich nur einmal") func test_zugreihenfolge() -> void: @@ -332,7 +442,7 @@ func test_zugreihenfolge() -> void: for seed in 12: var r := RandomNumberGenerator.new() r.seed = seed - var g := Game.new_battle(Profile.starter().deck_cards(), 2, r) + var g := Game.new_battle(2, 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") for i in g.players.size(): @@ -363,12 +473,12 @@ func test_jeder_gegen_jeden() -> void: check(g.enemies_of(h) == [o1, o2], "Spieler bekämpft alle Gegner") g.end_turn() o1.hand = [] - o1.mulligan_done = true 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") - var act := AI.choose_action(g, o1) - check(act.type == "end", "KI-Zug endet ohne weitere Aktion") + 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") @@ -378,11 +488,11 @@ 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(Profile.starter().deck_cards(), n, r) + 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.hand.size() == Game.START_HAND + 1, "Handkarten 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) @@ -402,55 +512,31 @@ func test_sieg_niederlage() -> void: check(g2.winner == "opponents", "Niederlage") -func test_deckregeln() -> void: - var starter := Profile.starter().deck_cards() - check(Game.validate_deck(starter)[0], "Starterdeck gültig") - var d := [] - for i in 19: - d.append(C("f_kobold")) - check(not Game.validate_deck(d)[0], "19 Karten ungültig") - d = [] - for i in 4: - d.append(C("f_kobold")) - for i in 16: - d.append(C("f_wolf")) - check(not Game.validate_deck(d)[0], "4 Kopien ungültig") - d = [] - for cid in ["f_kobold", "f_wolf", "f_asche", "f_funke", "w_geist", "w_reiter"]: - for i in 3: - d.append(C(cid)) - d.append(C("e_wall")) - d.append(C("e_wall")) - check("Elemente" in Game.validate_deck(d)[1], "3 Elemente ungültig") - check(not Game.validate_deck(starter, {"f_kobold": 1})[0], "nicht besessen") - - -func test_gegnerdeck() -> void: - var r := RandomNumberGenerator.new() - r.seed = 1 - for e in Cards.ELEMENTS: - var deck := Game.build_opponent_deck(r, [e]) - check(Game.validate_deck(deck)[0], "Mono-Gegnerdeck gültig: " + e) - check(Game.deck_elements(deck) == [e], "nur Element " + e) - check(Game.validate_deck(Game.build_opponent_deck(r, ["Feuer", "Luft"]))[0], "Zwei-Element-Gegnerdeck") - - +## Booster geben Seltenheitsstufen: höhere werten auf, der Rest wird zu Staub. func test_booster() -> void: var r := RandomNumberGenerator.new() r.seed = 3 for i in 50: - var cards := Profile.roll_booster(r) - check(cards.size() == 5, "5 Karten") - check(cards.any(func(c): return c.rarity != "gewöhnlich"), "mindestens eine seltene") + var rolled := Profile.roll_booster(r) + check(rolled.size() == 5, "5 Karten") + check(rolled.any(func(x): return x.rarity != "gewöhnlich"), "mindestens eine seltene") var p := Profile.new() p.coins = Profile.BOOSTER_COST var got := p.open_booster(r) - check(p.coins == 0 and got.size() == 5, "Booster kostet 100") - var total := 0 - for cid in p.collection: - total += p.collection[cid] - check(total == 5, "Sammlung +5") + check(p.coins == 0 and got.size() == 5, "Booster kostet %d" % Profile.BOOSTER_COST) + var upgraded := got.filter(func(x): return x.upgraded) + check(upgraded.all(func(x): return p.rarity(x.card.id) == x.rarity), "aufgewertete Karten tragen ihre Stufe") + check(p.dust == got.reduce(func(a, x): return a + int(x.dust), 0), "der Rest wird zu Staub") check(p.open_booster(r).is_empty(), "ohne Münzen kein Booster") + # Aufwerten und zerlegen + p.dust = 100000 + check(p.upgrade("f_wolf") == "" and p.level("f_wolf") >= 1, "aufgewertet") + var lvl := p.level("f_wolf") + check(p.dismantle("f_wolf") == "" and p.level("f_wolf") == lvl - 1, "eine Stufe abgegeben") + p.collection["f_wolf"] = Cards.RARITIES.size() - 1 + check(p.upgrade("f_wolf") != "", "über die höchste Stufe hinaus geht nicht") + p.collection.erase("f_wolf") + check(p.dismantle("f_wolf") != "" and p.rarity("f_wolf") == "gewöhnlich", "gewöhnliche Karten gehören allen") func test_spielstand() -> void: @@ -460,12 +546,13 @@ func test_spielstand() -> void: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) var p := Profile.load_or_new(path) - check(p.deck_valid()[0], "Starterprofil gültig") + check(p.coins == Profile.START_COINS and p.collection.is_empty(), "Starterprofil: alle Karten gewöhnlich") check(FileAccess.file_exists(path), "Datei angelegt") p.coins = 999 + p.collection["f_wolf"] = 2 p.save(path) var p2 := Profile.load_or_new(path) - check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == Cards.of_rarity("gewöhnlich").size() + 2, "geladen") + check(p2.coins == 999 and p2.level("f_wolf") == 2, "geladen") for suffix in ["", ".bak"]: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) @@ -490,7 +577,7 @@ func test_ki_spiele_enden() -> void: for i in 6: var pool: Array = Cards.ELEMENTS.duplicate() Game.shuffle(pool, r) - var g := Game.new_battle(Game.build_opponent_deck(r, [pool[0], pool[1]]), n, 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) @@ -509,7 +596,7 @@ func test_resonanz() -> void: check(h.board[0].power() == 4 and h.board[1].power() == 3, "Feuer+Feuer: beide +1") g.play_card(h, C("e_wall")) check(h.board[2].power() == 0, "Erde allein: kein Bonus") - check(C("f_wolf").text() == "" and Cards.RESONANCE_TEXT.begins_with("Resonanz"), "Resonanz ist Regel, kein Kartentext") + check(C("f_wolf").text() == "", "Resonanz ist Regel, kein Kartentext") var third := put(h, "f_asche") g.update_resonance() check(h.board[0].power() == 5 and third.power() == 6, "drei Feuer-Wesen: +2") @@ -524,6 +611,109 @@ func test_resonanz() -> void: check(h.board[0].power() == 3, "Resonanz sinkt mit den Partnern") +## Elementkreis: Wasser > Feuer > Luft > Erde > Wasser, im Vorteil +1 Schaden, im Nachteil -1. +func test_elementvorteil() -> void: + check(Cards.matchup(["Wasser"], ["Feuer"]) == 1 and Cards.matchup(["Feuer"], ["Wasser"]) == -1, "Wasser schlägt Feuer") + check(Cards.matchup(["Feuer"], ["Luft"]) == 1 and Cards.matchup(["Luft"], ["Erde"]) == 1 and Cards.matchup(["Erde"], ["Wasser"]) == 1, "der Kreis schließt sich") + check(Cards.matchup(["Feuer"], ["Feuer"]) == 0 and Cards.matchup(["Feuer"], ["Erde"]) == 0, "gleiche und unbeteiligte Elemente: kein Vorteil") + check(Cards.matchup(["Feuer", "Wasser"], ["Feuer"]) == 1, "Mehrfachelemente nehmen den besseren Wert") + check(Cards.matchup([], ["Feuer"]) == 0 and Cards.matchup(["Feuer"], []) == 0, "ohne Element kein Vorteil") + var s := setup(["f_ritter"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var mine := put(h, "w_reiter") # Wasser 3/3 + var his := put(o, "f_wolf") # Feuer 3/2 + g.attack(mine, his) + check(his.health == -2 and mine.health == 1, "im Vorteil: 1 mehr ausgeteilt, 1 weniger eingesteckt") + check(Game.matchup_bonus(mine, o) == 0, "gegen Helden gibt es keinen Vorteil") + var life := o.life + g.play_card(h, C("f_ritter"), o) # Feuerwesen, 1 Schaden an den Helden + check(o.life == life - 1, "Effekte auf Helden bleiben unverändert") + s = setup(["f_ritter"]) + g = s[0] + h = s[1] + o = s[2][0] + 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") + + +## Das Feld gehört allen am Tisch und kippt, sobald ein Element genug Karten gesehen hat. +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] + 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: + g.charge_field(C("f_kobold")) + check(g.field_element == "", "drei Karten reichen noch nicht") + g.charge_field(C("f_kobold")) + g.update_resonance() + check(g.field_element == "Feuer" and int(g.field_charge["Feuer"]) == 0, "die vierte Feuerkarte kippt das Feld, die Zähler beginnen von vorn") + check(g.log.any(func(l): return l.begins_with("Das Feld wird zu Vulkan")), "Feldwechsel im Verlauf") + check(wolf.field_bonus == 1 and wolf.power() == 4, "Feuerwesen bekommen +1 Angriff") + var stone := put(h, "e_wall") + var his := put(o, "f_kobold") + g.update_resonance() + check(stone.field_bonus == 0, "andere Elemente bleiben unberührt") + check(his.field_bonus == 1, "das Feld gilt auch für die Gegner") + var before := int(g.field_charge["Feuer"]) + g.play_card(h, C("f_ritter"), o) + check(int(g.field_charge["Feuer"]) == before + 1, "jede gespielte Karte lädt ihr Element") + for i in Cards.FIELD_CHARGE: + g.charge_field(C("w_reiter")) + check(g.field_element == "Wasser" and wolf.field_bonus == 0, "das Feld kippt weiter und nimmt den alten Bonus mit") + + +## Verschmelzen: zwei eigene Wesen werden eins, Werte Durchschnitt plus 1. +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 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") + check(g.can_fuse(h, a, a)[0] == false and g.can_fuse(h, a, put(o, "f_kobold"))[0] == false, "nur zwei verschiedene eigene Wesen") + h.energy = 1 + check(g.can_fuse(h, a, b)[0] == false, "zu wenig Mana") + h.energy = 5 + check(g.fuse(h, a, b) == "" and h.board.size() == 1, "aus zwei Wesen wird eines") + var cr: Game.Creature = h.board[0] + check(h.energy == 3, "die Verschmelzung kostet Mana") + check(cr.card.elements == ["Feuer", "Wasser"], "es erbt beide Elemente") + check(cr.name == "Dampf", "es heißt nach seiner Kombo") + check(cr.attack == 4 and cr.health == 3 and cr.max_health == 3, "Werte: Durchschnitt plus 1") + check(g.log.any(func(l): return l.find("verschmelzen zu Dampf") >= 0), "Verschmelzung im Verlauf") + # Schlüsselwörter beider Karten wandern mit + s = setup() + g = s[0] + h = s[1] + h.energy = 10 + var wall := put(h, "e_wall") # Erde 0/4, Wächter + var wind := put(h, "l_wind") # Luft 1/1, Flink + check(g.fuse(h, wall, wind) == "", "Erde und Luft verschmelzen") + var sand: Game.Creature = h.board[0] + check(sand.name == "Sand" and sand.taunt() and "Flink" in sand.card.keywords, "Wächter und Flink bleiben erhalten") + check(sand.attack == 1 and sand.health == 3, "0/4 und 1/1 werden 1/3") + # Das verschmolzene Wesen übersteht Speichern und Netzwerk + var back := NetState.from_snapshot(NetState.snapshot(g, -1)) + var copy: Game.Creature = back.players[0].board[0] + check(copy.name == "Sand" and copy.attack == 1 and copy.health == 3 and copy.taunt(), "verschmolzene Karte übersteht den Snapshot") + # Platz auf dem Feld: fünf Wesen, verschmelzen macht einen Platz frei + s = setup(["f_kobold"]) + g = s[0] + h = s[1] + h.energy = 10 + for i in Game.MAX_BOARD: + put(h, "f_kobold") + check(g.can_play(h, C("f_kobold"))[0] == false, "das Feld ist voll") + check(g.fuse(h, h.board[0], h.board[1]) == "" and g.can_play(h, C("f_kobold"))[0], "verschmelzen macht Platz") + + func test_feldkombos() -> void: # Dampf: Feuer neben Wasser friert das stärkste gegnerische Wesen ein var s := setup(["w_reiter"]) @@ -554,7 +744,7 @@ func test_feldkombos() -> void: 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") - # Schlamm heilt, Sturm zieht, Sand gibt +1/+1 + # Schlamm heilt, Sturm gibt Mana, Sand gibt +1/+1 s = setup(["e_wall", "l_wind", "w_reiter"]) g = s[0] h = s[1] @@ -562,9 +752,9 @@ func test_feldkombos() -> void: g.play_card(h, C("w_reiter")) g.play_card(h, C("e_wall")) check(h.life == 23, "Schlamm heilt um 3") - var hand_before := h.hand.size() + var mana_before := h.energy g.play_card(h, C("l_wind")) - check(h.hand.size() == hand_before, "Sturm zieht 1 (eine gespielt, eine gezogen)") + check(h.energy == mana_before + 1, "Sturm gibt 1 Mana") check(h.board[2].attack == 1, "nur eine Feldkombo je Ausspielen") s = setup(["l_wind"]) g = s[0] @@ -579,8 +769,8 @@ func test_feldkombos() -> void: put(h, "f_wolf") g.play_card(h, C("f_kobold")) check(not g.log.any(func(l): return l.begins_with("Feldkombo")), "Feuer+Feuer ist Resonanz, keine Feldkombo") - # Kombokarte bringt ihr zweites Element mit - s = setup(["fw_wolke", "fe_golem"]) + # Ein Feuerwesen neben Wasser löst Dampf aus + s = setup(["fe_golem"]) g = s[0] h = s[1] o = s[2][0] @@ -590,21 +780,23 @@ func test_feldkombos() -> void: check(t.frozen, "Lava-Wesen neben Wasser löst Dampf aus") -func test_schild_effekt_und_dreifach() -> void: - var s := setup(["e_steinhaut", "fff_sonne"]) +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 a := put(h, "f_wolf") - g.play_card(h, C("e_steinhaut"), a) - check(a.shield, "Steinhaut gibt Schild") - var sonne := C("fff_sonne") - check(sonne.rarity == "mythisch" and sonne.combo() == "Sonne" and sonne.is_combo(), "Dreifach-Kombo Sonne") - check(Cards.of_rarity("mythisch").size() == 4, "vier mythische Karten") + g.play_card(h, C("el_duenen"), a) + check(a.shield, "Dünenläufer gibt Schild") + # Frischer Tisch: die Sonne soll allein wirken, ohne Feldkombo mit eigenen Wesen + s = setup(["fff_sonne"]) + g = s[0] + h = s[1] + o = s[2][0] + 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")) check(o.life == 10 and troll.health == 1, "Kern der Sonne: 5 Schaden an alle") - check(Cards.combo_name(["Erde", "Erde", "Erde"]) == "Gebirge", "Dreifach-Name") func test_kopie() -> void: @@ -631,7 +823,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(Game.build_opponent_deck(r, [pool[0], pool[1]]), 1, r) + var g := Game.new_battle(1, r) # abwechselnd beginnt die schlaue oder die gierige KI var smart_is_human := i % 2 == 0 var guard := 0 @@ -644,77 +836,41 @@ func test_ki_besser_als_gierig() -> void: check(smart >= games / 2, "Vorausschau-KI ist mindestens so gut wie die gierige (%d von %d)" % [smart, games]) +## Alte Stände behalten Münzen, Staub, Kampagne und Einstellungen; die alte Sammlung entfällt. func test_spielstand_migration() -> void: - var alt := {"coins": 300, "collection": {"f_kobold": 2, "unbekannt": 1}, "deck": ["f_kobold", "f_kobold"]} + var alt := {"version": 5, "coins": 300, "dust": 70, "campaign_stage": 2, + "collection": {"f_kobold": 2}, "decks": [], "settings": {"volume": 0.3}} var p := Profile.from_dict(alt) - check(p.coins == 300 and p.collection == {"f_kobold": 2} and p.deck.size() == 2, "Version 1 gelesen") - check(p.stats.games == 0 and p.campaign_stage == 0, "Standardwerte ergänzt") - check(p.settings.volume == 0.8 and p.settings.animations, "Einstellungen Standard") - p.stats.wins = 3 - p.campaign_stage = 2 - p.settings.volume = 0.3 + check(p.coins == 300 and p.dust == 70 and p.campaign_stage == 2, "alter Stand gelesen") + check(p.collection.is_empty(), "Sammlung auf Standard") + check(is_equal_approx(p.settings.volume, 0.3) and p.settings.animations, "Einstellungen übernommen") + p.collection["f_wolf"] = 3 var d := p.to_dict() check(d.version == Profile.SAVE_VERSION, "Version gespeichert") var p2 := Profile.from_dict(JSON.parse_string(JSON.stringify(d))) - check(p2.stats.wins == 3 and p2.campaign_stage == 2, "Version 2 gelesen") - check(is_equal_approx(p2.settings.volume, 0.3), "Einstellungen gelesen") - - -func test_tagesaufgabe() -> void: - Profile.today_override = "2026-09-16" - var p := Profile.starter() - var coins := p.coins - var reward := p.record_battle(true, 1, p.deck_cards()) - check(reward == Profile.REWARD_WIN[1], "Duell-Belohnung") - check(p.coins == coins + reward + (p.daily_task().reward if p.daily.done else 0), "Münzen verbucht") - p.take_notices() - check(p.notices.is_empty(), "Meldungen abgeholt") - check(p.daily.date == "2026-09-16" and p.daily.task != "", "Tagesaufgabe gesetzt") - # Tagesaufgabe erfüllen (je nach Aufgabe: Sieg, zwei Kämpfe oder Booster) - var task := p.daily_task() - if not p.daily.done: - if task.kind == "game": - p.record_battle(false, 2, p.deck_cards()) - elif task.kind == "booster": - p.coins = 100 - p.open_booster() - check(p.daily.done, "Tagesaufgabe erfüllt: " + task.text) - var progress_before: int = p.daily.progress - p.record_battle(true, 1, p.deck_cards()) - check(p.daily.progress == progress_before, "erfüllte Aufgabe zählt nicht weiter") - Profile.today_override = "2026-09-17" - p.refresh_daily() - check(p.daily.date == "2026-09-17" and not p.daily.done and p.daily.progress == 0, "neuer Tag, neue Aufgabe") - Profile.today_override = "" - var mono := [] - for cid in ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche", "f_glutpfeil", "f_elementar"]: - for i in 3: - mono.append(Cards.by_id(cid)) - mono = mono.slice(0, 20) - p.record_battle(true, 3, mono) - check(p.stats.wins == 3 and p.stats.wins_by_opp["1"] == 2, "Statistik") + check(p2.level("f_wolf") == 3 and p2.campaign_stage == 2, "neuer Stand gelesen") func test_kampagne() -> void: var r := RandomNumberGenerator.new() r.seed = 4 check(Campaign.STAGES.size() == 10, "zehn Stufen") - var opps := Campaign.opponents(4, r) + 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(Game.deck_elements(opps[0].deck).all(func(e): return e in ["Feuer", "Erde"]), "Boss-Deck aus seinen Elementen") - var g := Game.new_battle_with(Profile.starter().deck_cards(), opps, r) + 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 p := Profile.starter() - var reward := p.record_battle(true, 1, p.deck_cards(), 0) + var reward := p.record_battle(true, 1, 0) check(p.campaign_stage == 1 and reward == Campaign.STAGES[0].reward, "Stufe 1 abgeschlossen") - reward = p.record_battle(true, 1, p.deck_cards(), 0) + 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, p.deck_cards(), 1) + reward = p.record_battle(false, 2, 1) check(p.campaign_stage == 1 and reward == Profile.REWARD_LOSS, "Niederlage schaltet nichts frei") p.campaign_stage = 9 - p.record_battle(true, 5, p.deck_cards(), 9) + p.record_battle(true, 5, 9) check(p.campaign_stage == 10, "Kampagne abgeschlossen") - var sim := Game.new_battle_with(Profile.starter().deck_cards(), Campaign.opponents(0, r), r) + var sim := Game.new_battle_with(Campaign.opponents(0), r) var guard := 0 while sim.winner == "" and guard < 300: AI.take_turn(sim, sim.current(), true) @@ -722,87 +878,6 @@ func test_kampagne() -> void: check(sim.winner != "", "Kampagnenduell endet") -func test_fallen() -> void: - var s := setup(["f_falle"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2][0] - check(C("f_falle").is_trap() and C("f_falle").text().begins_with("Falle. Wenn ein gegnerisches Wesen"), "Fallentext") - check(g.play_card(h, C("f_falle")) == "" and h.traps.size() == 1 and h.hand.is_empty() and h.energy == 8, "Falle gelegt") - check(g.log.back() == "Du legt eine Falle.", "Falle bleibt im Verlauf verdeckt") - g.end_turn() - var a := put(o, "e_troll") - check(g.attack(a, h) == "", "Angriff in die Feuerfalle") - check(h.traps.is_empty() and a.health == 3 and h.life == 27, "Feuerfalle: 3 Schaden, Angriff geht durch") - # Frostfalle bricht den Angriff ab - s = setup(["w_falle"]) - g = s[0] - h = s[1] - o = s[2][0] - g.play_card(h, C("w_falle")) - g.end_turn() - a = put(o, "e_troll") - g.attack(a, h) - check(h.life == 30 and a.frozen and a.attacks_left == 0, "Frostfalle: Angriff verpufft, Angreifer eingefroren") - # Erdwall beschwört einen Steinwall - s = setup(["e_falle"]) - g = s[0] - h = s[1] - o = s[2][0] - g.play_card(h, C("e_falle")) - g.end_turn() - a = put(o, "f_wolf") - g.attack(a, h) - check(h.board.size() == 1 and h.board[0].name == "Steinwall" and h.life == 27, "Erdwall beschwört Steinwall") - # Windfalle schickt ein ausgespieltes Wesen zurück - s = setup(["l_falle"], ["f_wolf"]) - g = s[0] - h = s[1] - o = s[2][0] - g.play_card(h, C("l_falle")) - g.end_turn() - g.play_card(o, C("f_wolf")) - check(o.board.is_empty() and C("f_wolf") in o.hand and o.energy == 8, "Windfalle: Wesen zurück, Energie weg") - # Höchstens zwei Fallen - s = setup(["f_falle", "f_falle", "f_falle"]) - g = s[0] - h = s[1] - g.play_card(h, C("f_falle")) - g.play_card(h, C("f_falle")) - check(not g.can_play(h, C("f_falle"))[0] and "Fallen" in g.can_play(h, C("f_falle"))[1], "höchstens 2 Fallen") - # Falle schützt auch Wesen: toter Angreifer trifft nicht mehr - s = setup(["f_falle"]) - g = s[0] - h = s[1] - o = s[2][0] - g.play_card(h, C("f_falle")) - var w := put(h, "e_wall") - g.end_turn() - a = put(o, "f_wolf") - g.attack(a, w) - check(not (a in o.board) and w.health == 4, "Feuerfalle tötet den Wolf, Steinwall bleibt heil") - - -func test_ki_sieht_fallen_nicht() -> void: - var s := setup(["w_falle"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2][0] - g.play_card(h, C("w_falle")) - var hi := g.players.find(h) - var oi := g.players.find(o) - check(g.clone().players[hi].traps.size() == 1, "volle Kopie kennt Fallen") - var g2 := g.clone_for(o) - check(g2.players[hi].traps.is_empty() and g2.players[oi].traps == o.traps, "KI-Kopie kennt fremde Fallen nicht") - g.end_turn() - o.hand = [] - o.mulligan_done = true - var a := put(o, "e_troll") - var act := AI.choose_action(g, o) - check(act.type == "attack" and act.target == h, "KI greift trotz verdeckter Falle an") - check(g.clone_for(h).players[hi].traps.size() == 1, "eigene Fallen bleiben in der eigenen Kopie") - - func test_zufallssamen() -> void: Game.test_seed = 77 var a := Game.make_rng().randi() @@ -815,34 +890,6 @@ func test_zufallssamen() -> void: check(c != d, "ohne Samen echter Zufall") -func test_deck_slots_und_werkstatt() -> void: - var p := Profile.starter() - check(p.decks.size() == Profile.DECK_SLOTS and p.deck.size() == 20 and p.decks[1].cards.is_empty(), "fünf Slots, Deck 1 gefüllt") - p.active_deck = 1 - check(p.deck.is_empty() and not p.deck_valid()[0] and p.deck_valid(0)[0], "aktives Deck wechselt") - p.deck = ["f_kobold"] - check(p.decks[1].cards == ["f_kobold"], "Zuweisung geht in den Slot") - p.active_deck = 0 - # Migration aus Version 2 (ein Deck) - var v2 := {"version": 2, "coins": 5, "collection": {"f_kobold": 3}, "deck": ["f_kobold", "f_kobold"]} - var m := Profile.from_dict(v2) - check(m.decks[0].cards == ["f_kobold", "f_kobold"] and m.active_deck == 0 and m.dust == 0, "Version 2 wird zu Slot 1") - var v3 := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) - check(v3.decks[1].cards == ["f_kobold"] and v3.deck_name(1) == "Deck 2", "Version 3 gelesen") - # Zerlegen und Herstellen - p.collection["f_kobold"] = 5 - check(p.dismantle("f_kobold") == "" and p.collection["f_kobold"] == 4 and p.dust == 5, "Zerlegen bringt Staub") - check(p.dismantle("f_kobold", 3) != "" and p.collection["f_kobold"] == 4, "Deckbedarf schützt Karten") - check(p.dismantle("ff_phoenix") != "", "nichts zu zerlegen") - p.dust = 100 - check(p.craft("ff_phoenix") != "" and p.dust == 100, "zu wenig Staub") - p.dust = 1600 - check(p.craft("ff_phoenix") == "" and p.collection["ff_phoenix"] == 1 and p.dust == 0, "Herstellen") - p.collection["f_wolf"] = 6 - var got := p.dismantle_extras() - check(got == 20 and p.collection["f_wolf"] == 3 and p.collection["f_kobold"] == 3, "Überschuss zerlegt") - - func test_spielstand_sicherung() -> void: var path := "user://test_sicherung.json" var abs := ProjectSettings.globalize_path(path) @@ -865,41 +912,12 @@ func test_spielstand_sicherung() -> void: DirAccess.remove_absolute(abs + suffix) -func test_mulligan() -> void: - var r := RandomNumberGenerator.new() - r.seed = 9 - var g := Game.new_battle(Profile.starter().deck_cards(), 1, r) - var first: Game.Player = g.players[0] - var hand_before := first.hand.duplicate() - var swap := [hand_before[0], hand_before[1]] - check(g.mulligan(first, swap) == "", "Mulligan erlaubt im ersten Zug") - check(first.hand.size() == hand_before.size() and first.deck.size() == 20 - hand_before.size(), "gleich viele Karten nachgezogen") - check(g.mulligan(first, [first.hand[0]]) != "", "nur einmal") - check(g.log.any(func(l): return "tauscht 2 Karten" in l), "Mulligan im Verlauf") - g.end_turn() - var second: Game.Player = g.players[1] - check(g.mulligan(first, []) != "", "nicht im fremden Zug") - check(g.mulligan(second, []) == "" and second.mulligan_done, "Hand behalten zählt als Mulligan") - for i in 2: - g.end_turn() - check(g.mulligan(g.current(), []) != "", "nicht mehr im zweiten Zug") - # KI tauscht teure Karten - var s := setup([], ["ff_phoenix", "f_kobold", "ww_leviathan"]) - var g2: Game = s[0] - var o: Game.Player = s[2][0] - g2.end_turn() - var act := AI.choose_action(g2, o) - check(act.type == "mulligan" and act.cards.size() == 2 and not (C("f_kobold") in act.cards), "KI legt teure Karten zurück") - AI.apply_action(g2, o, act) - check(o.mulligan_done and AI.choose_action(g2, o).type != "mulligan", "danach normale Züge") - - func test_merkmale() -> void: - var s := setup(["f_wolf", "l_falke", "e_wall", "f_funke"]) + 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] - h.perks = ["feuer_angriff", "flink_angriff", "waechter_leben", "schild_wesen", "alle_flink", "zauber_schaden", "resonanz_doppelt"] + 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] check(wolf.power() == 4 and wolf.shield and wolf.can_attack(), "Feuer +1, Schild, sofort bereit") @@ -907,9 +925,6 @@ func test_merkmale() -> void: check(h.board[1].power() == 4, "Flink +1") g.play_card(h, C("e_wall")) check(h.board[2].health == 6 and h.board[2].max_health == 6, "Wächter +2 Leben") - var life_before: int = o.life - g.play_card(h, C("f_funke"), o) - check(o.life == life_before - 3, "Zauber +1 Schaden") put(h, "f_kobold") g.update_resonance() check(wolf.resonance == 2 and wolf.power() == 6, "Resonanz doppelt") @@ -917,32 +932,32 @@ 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, r)[0] + 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(Profile.starter().deck_cards(), Campaign.opponents(9, r), r, true) + var g3 := Game.new_battle_with(Campaign.opponents(9), r, true) var archi: Game.Player = g3.opponents[0] check(g3.players[0] == g3.human, "feste Reihenfolge: Mensch beginnt") g3.end_turn() - check(g3.current() == archi and archi.max_energy == 3, "Archimagier beginnt mit 3 Energie") - var nyx: Game.Player = Campaign.opponents(5, r)[0] - var g4 := Game.new_battle_with(Profile.starter().deck_cards(), [nyx], r, true) - var before: int = nyx.hand.size() + 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) g4.end_turn() - check(nyx.hand.size() == before + 2, "Nyx zieht 2 Karten") + # 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") func test_ki_zwei_schritte() -> void: - # Gegner hat 5 Leben, eigenes Wesen 3 Angriff, Verhärten (+2) auf der Hand: erst verstärken, dann töten - var s := setup(["e_haerten"]) + # Gegner hat 5 Leben, das eigene Wesen kann noch nicht angreifen, der Wüstenkönig (flink, 5 Angriff) + # liegt auf der Hand: erst ausspielen, dann mit ihm töten. + var s := setup(["el_koenig"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2][0] - h.mulligan_done = true h.turns = 2 o.life = 5 - var a := put(h, "f_wolf") + put(h, "f_wolf", false) var act := AI.choose_action(g, h) - check(act.type == "play" and act.card == C("e_haerten") and act.target == a, "KI verstärkt zuerst") + check(act.type == "play" and act.card == C("el_koenig"), "KI spielt zuerst den König") AI.apply_action(g, h, act) var act2 := AI.choose_action(g, h) check(act2.type == "attack" and act2.target == o, "dann tödlicher Angriff") @@ -951,168 +966,34 @@ func test_ki_zwei_schritte() -> void: func test_snapshot() -> void: - var s := setup(["f_falle", "f_wolf"], ["w_eis"], 2) + var s := setup(["f_wolf"], ["w_frost"], 2) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2][0] - g.play_card(h, C("f_falle")) g.play_card(h, C("f_wolf")) put(o, "e_troll") var hi := g.players.find(h) var oi := g.players.find(o) + g.roll_shop(h) + g.roll_shop(o) 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[hi].hand.is_empty() and g2.players[hi].traps.size() == 1 and g2.players[hi].traps[0].id == "f_falle", "eigene Hand und Fallen sichtbar") - check(g2.players[oi].hand.size() == 1 and g2.players[oi].hand[0] != C("w_eis"), "fremde Hand nur als Anzahl") - check(g2.players[oi].deck.size() == o.deck.size(), "fremdes Deck nur als Anzahl") + 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") var cr2 := g2.find_creature(h.board[0].uid) check(cr2 != null and cr2.card.id == "f_wolf" and cr2.owner() == g2.players[hi] and not cr2.ready, "Wesen mit Besitzer und Zustand") check(g2.events.size() == g.events.size() and g2.events.back().type == g.events.back().type, "Ereignisse übertragen") - check(not g2.can_play(g2.players[hi], C("fff_sonne"))[0] and g2.valid_targets(g2.players[hi], "enemy_creature").size() == 1, "Kopie kennt die Regeln") + check(g2.valid_targets(g2.players[hi], "enemy_creature").size() == 1 and g2.buy(g2.players[oi], 0) == "Nicht dein Zug", "Kopie kennt die Regeln") var snap_o := g.snapshot(oi) - check(snap_o.players[hi].hand is int and snap_o.players[hi].traps is int and snap_o.players[oi].hand is Array, "Sicht des Gegners") - - -func test_statistik_pity_tagesaufgaben() -> void: - Profile.today_override = "2026-09-20" - var p := Profile.starter() - var deck := p.deck_cards() - p.record_battle(true, 3, deck, -1, ["f_kobold", "f_kobold", "f_funke"]) - check(p.card_stats["f_kobold"].played == 2 and p.card_stats["f_funke"].played == 1, "gespielte Karten gezählt") - check(p.card_stats["f_kobold"].games == 1 and p.card_stats["f_kobold"].wins == 1 and p.card_stats["e_wall"].games == 1, "Partien und Siege je Deckkarte") - p.record_battle(false, 1, deck, -1, []) - check(p.card_stats["f_kobold"].games == 2 and p.card_stats["f_kobold"].wins == 1, "Niederlage zählt Partie") - # Pity-Zähler - p.coins = 100000 - var r := RandomNumberGenerator.new() - r.seed = 1 - p.booster_since_mythic = Profile.PITY_BOOSTERS - 1 - var got := p.open_booster(r) - check(got.any(func(c): return c.rarity == "mythisch"), "nach %d Boostern garantiert mythisch" % Profile.PITY_BOOSTERS) - check(p.booster_since_mythic == 0, "Zähler zurückgesetzt") - var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) - check(v.card_stats["f_kobold"].played == 2 and v.booster_since_mythic == 0 and v.settings.has("music"), "Version 4 gelesen") - # Zwölf Tagesaufgaben, elementbezogen - check(Profile.DAILY_TASKS.size() == 12, "zwölf Tagesaufgaben") - var seen := {} - for day in range(1, 13): - Profile.today_override = "2026-10-%02d" % day - p.refresh_daily() - seen[p.daily.task] = true - check(seen.size() == 12, "jede Aufgabe kommt vor") - for t in Profile.DAILY_TASKS: - var q := Profile.starter() - q.daily = {"date": Profile.today(), "task": t.id, "progress": 0, "done": false} - var mono := [] - for i in 20: - mono.append(Cards.by_id("w_reiter")) - match t.kind: - "win": - q.record_battle(true, 1, mono) - "game": - for i in t.goal: - q.record_battle(false, 1, mono) - "booster": - q.coins = 1000 - for i in t.goal: - q.open_booster(r) - "craft": - q.dust = 10000 - q.craft("f_kobold") - "win_vs3": - q.record_battle(true, 3, mono) - "win_element:Wasser": - q.record_battle(true, 1, mono) - "win_element:Feuer": - q.record_battle(true, 1, q.deck_cards()) - "win_element:Erde": - q.record_battle(true, 1, q.deck_cards()) - "win_element:Luft": - var luft := [] - for i in 20: - luft.append(Cards.by_id("l_falke")) - q.record_battle(true, 1, luft) - _: - for i in t.goal: - q.daily_event(t.kind) - check(q.daily.done, "Tagesaufgabe erfüllbar: " + t.id) - Profile.today_override = "" - - -func test_deck_umbenennen_und_auffuellen() -> void: - var p := Profile.starter() - check(p.rename_deck(1, " Feuerdeck ") == "" and p.deck_name(1) == "Feuerdeck", "umbenannt") - check(p.rename_deck(1, "") != "" and p.rename_deck(1, "x".repeat(40)) != "", "Name geprüft") - check(p.autofill_deck(1) == "" and p.deck_valid(1)[0], "leerer Slot aufgefüllt und spielbar") - check(Game.deck_elements(p.deck_cards(1)).size() <= 2, "höchstens zwei Elemente") - p.decks[2].cards = ["l_falke", "l_wind"] - check(p.autofill_deck(2) == "" and p.deck_valid(2)[0], "Luft-Slot aufgefüllt") - check(p.deck_cards(2).all(func(c): return c.elements.all(func(e): return e in ["Luft", Game.deck_elements(p.deck_cards(2))[1] if Game.deck_elements(p.deck_cards(2)).size() > 1 else "Luft"])), "Auffüllen bleibt bei den Elementen") - var q := Profile.new() - q.collection = {"f_kobold": 1} - check(q.autofill_deck(0) != "", "zu wenig Karten: Fehlermeldung") - - -func test_neue_fallen() -> void: - check(Cards.all().filter(func(c): return c.is_trap()).size() == 12, "zwölf Fallen") - check(C("e_sandfalle").text() == "Falle. Wenn ein Gegner ein Wesen ausspielt: Es wird eingefroren.", "Fallentext je Auslöser") - check(C("f_aschefalle").text().ends_with("3 Schaden an dieses Wesen."), "Aschefalle-Text") - # Böenfalle: Angreifer zurück auf die Hand, Angriff verpufft - var s := setup(["l_boenfalle"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2][0] - g.play_card(h, C("l_boenfalle")) - g.end_turn() - var a := put(o, "e_troll") - g.attack(a, h) - check(o.board.is_empty() and C("e_troll") in o.hand and h.life == 30, "Böenfalle: Angreifer zurück, kein Schaden") - # Aschefalle: 3 Schaden an das ausgespielte Wesen - s = setup(["f_aschefalle"], ["f_wolf"]) - g = s[0] - h = s[1] - o = s[2][0] - g.play_card(h, C("f_aschefalle")) - g.end_turn() - g.play_card(o, C("f_wolf")) - check(o.board.is_empty(), "Aschefalle tötet den 3/2 Wolf beim Ausspielen") - # Sandfalle friert das ausgespielte Wesen ein, Spiegelfalle beschwört einen Windgeist - s = setup(["e_sandfalle", "l_spiegel"], ["e_troll", "w_reiter"]) - g = s[0] - h = s[1] - o = s[2][0] - g.play_card(h, C("e_sandfalle")) - g.play_card(h, C("l_spiegel")) - g.end_turn() - g.play_card(o, C("e_troll")) - check(o.board.size() == 1 and o.board[0].frozen and h.traps.size() == 1, "Sandfalle friert ein, eine Falle je Auslöser") - g.play_card(o, C("w_reiter")) # Wasser neben Erde: Schlamm heilt nur - check(h.board.size() == 1 and h.board[0].name == "Windgeist", "Spiegelfalle beschwört Windgeist beim zweiten Wesen") - check(h.traps.is_empty(), "beide Fallen verbraucht") - # Nebelfalle bricht ab und zieht, Dornenfalle verletzt und heilt - s = setup(["w_nebelfalle", "e_dornen"]) - g = s[0] - h = s[1] - o = s[2][0] - g.play_card(h, C("w_nebelfalle")) - g.play_card(h, C("e_dornen")) - h.life = 20 - g.end_turn() - a = put(o, "e_troll") - var hand_before := h.hand.size() - g.attack(a, h) - check(h.life == 20 and h.hand.size() == hand_before + 1 and a.health == 6, "Nebelfalle zuerst: verpufft, Karte gezogen") - var b := put(o, "f_wolf") - g.attack(b, h) - check(b.health <= 0 or not (b in o.board), "Dornenfalle: 2 Schaden töten den Wolf") - check(h.life == 23, "Dornenfalle heilt 3, Angriff entfällt") + check(snap_o.players[hi].hand is int and snap_o.players[oi].hand is Array, "Sicht des Gegners") func test_kapitel_zwei_und_merkmalwahl() -> void: check(Campaign.total() == 30 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2 and Campaign.chapter(29) == 3, "dreißig Stufen in drei Kapiteln") - check(Campaign.stage(25).perks.size() == 3 and Campaign.stage(25).name == "Nyx" and Campaign.target_rating(29, 1.0) == 4.4, "Kapitel 3 mit drei Merkmalen und höherer Zielstärke") + check(Campaign.stage(25).perks.size() == 3 and Campaign.stage(25).name == "Nyx", "Kapitel 3 mit drei Merkmalen") for st in Campaign.STAGES3: for pk in st.perks: check(Game.PERKS.has(pk), "Merkmal %s bekannt" % pk) @@ -1121,186 +1002,31 @@ 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, r) + 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 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(p.deck_cards(), 1, r, {"human_perks": [p.perk]}) - check(g.human.perks == ["feuer_angriff"] and g.log.any(func(l): return l.begins_with("Du: Feuerwesen")), "eigenes Merkmal wirkt und steht im Verlauf") + var g := Game.new_battle(1, 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, p.deck_cards(), 19) + p.record_battle(true, 5, 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") -func test_bestenliste() -> void: - Profile.today_override = "2026-09-16" - var mono := [] - for i in 20: - mono.append(Cards.by_id("w_reiter")) - var r := RandomNumberGenerator.new() - r.seed = 4 - var g := Game.new_battle(mono, 3, r, {"everyone_perk": "start_energie", "start_life": 20}) - check(g.players.all(func(p): return p.life == 20 and "start_energie" in p.perks), "Regelvariante: 20 Leben und Startenergie für alle") - check(g.players[0].max_energy == 3, "Startspieler hat 3 Energie") - var p := Profile.starter() - p.record_battle(true, 3, mono, -1, [], 7) - p.record_battle(true, 3, mono, -1, [], 9) - check(p.records.streak == 2 and p.records.best_streak == 2 and p.records.fastest_win == 7, "Serie und schnellster Sieg") - p.record_battle(false, 1, mono, -1, [], 12) - check(p.records.streak == 0 and p.records.best_streak == 2, "Niederlage bricht die Serie") - for i in 5: - p.record_battle(true, 1, mono, -1, [], 10) - check(p.records.best_streak == 5, "neue beste Serie") - Profile.today_override = "" - - -func test_deckstaerke_und_schwierigkeit() -> void: - check(is_equal_approx(Cards.card_value(C("f_kobold")), 1.0) and is_equal_approx(Cards.card_value(C("ff_phoenix")), 4.6), "Kartenwert aus Seltenheit, Schlüsselwörtern, Effekten") - var starter := Profile.starter().deck_cards() - var rs := Game.deck_rating(starter) - check(rs > 1.2 and rs < 1.5, "Starter mit zwei seltenen Karten bei etwa 1,3 (%.2f)" % rs) - check(Profile.starter().deck_valid()[0] and starter.any(func(c): return c.id == "f_drache") and starter.any(func(c): return c.id == "e_baum"), "Feuerdrache und Uralter Baum im Starterdeck") - var r := RandomNumberGenerator.new() - r.seed = 5 - for target in [1.2, 1.6, 2.2, 2.6]: - for elems in [["Feuer"], ["Wasser", "Luft"]]: - var d := Game.build_opponent_deck_rated(r, elems, target) - check(Game.validate_deck(d)[0] and absf(Game.deck_rating(d) - target) <= 0.25, "Zielstärke %.1f für %s erreicht (%.2f)" % [target, "+".join(elems), Game.deck_rating(d)]) - var strong := Game.build_opponent_deck_rated(r, ["Feuer", "Luft"], 3.0) - check(Game.deck_rating(strong) >= 2.7, "Zielstärke 3,0 mit zwei Elementen nahezu erreichbar (%.2f)" % Game.deck_rating(strong)) - var g := Game.new_battle(starter, 2, r, {"target_rating": 1.3}) - for o in g.opponents: - check(absf(Game.deck_rating(o.deck + o.hand) - 1.3) <= 0.3, "Gegner auf Zielstärke (%.2f)" % Game.deck_rating(o.deck + o.hand)) - check(is_equal_approx(Campaign.target_rating(0, 1.3), 1.3) and is_equal_approx(Campaign.target_rating(0, 2.5), 2.5) and is_equal_approx(Campaign.target_rating(9, 1.3), 2.6), "Kampagne: Stufe oder eigene Stärke, was höher ist") - var opps := Campaign.opponents(2, r, 1.3) - check(opps.size() == 2 and absf(Game.deck_rating(opps[0].deck) - 1.5) <= 0.25 and absf(Game.deck_rating(opps[1].deck) - 1.5) <= 0.3, "Borin und Gefolge auf 1,5") - var p := Profile.starter() - check(p.difficulty().name == "Normal" and is_equal_approx(p.target_rating(), rs), "Normal: Zielstärke gleich eigener Stärke") - p.settings.difficulty = "schwer" - check(is_equal_approx(p.target_rating(), rs * 1.2) and p.difficulty().smart, "Schwer: 1,2-fach mit Vorausschau-KI") - p.settings.difficulty = "leicht" - check(not p.difficulty().smart and is_equal_approx(p.target_rating(), rs * 0.9), "Leicht: einfache KI, 0,9-fach") - var reward := p.record_battle(true, 1, starter, -1, [], 5, p.difficulty().reward) - check(reward == int(round(Profile.REWARD_WIN[1] * 0.75)), "Leicht: weniger Münzen") - var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) - check(v.settings.difficulty == "leicht", "Schwierigkeit gespeichert") - v.settings.difficulty = "unsinn" - check(v.difficulty().name == "Normal", "unbekannte Schwierigkeit fällt auf Normal zurück") - - -func test_starter_gegen_passende_gegner() -> void: - check(Game.archetypes("leicht").size() == 5 and Game.archetypes("schwer").size() == 5 and Game.archetypes().size() == 10, "Archetypen geteilt") - var table: Dictionary = DeckGen.strength() - var keys: Array = table.keys() - keys.sort_custom(func(a, b): return table[a] < table[b]) - check(keys[0] in Game.archetypes("leicht") and keys[-1] in Game.archetypes("schwer"), "schwächster Archetyp in Leicht, stärkster in Schwer") - check(Game.archetypes("leicht").all(func(k): return table[k] <= table[Game.archetypes("schwer")[0]]), "Hälften nach Stärke getrennt") - var r := RandomNumberGenerator.new() - r.seed = 6 - var starter := Profile.starter().deck_cards() - var rs := Game.deck_rating(starter) - var results := {} - for key in ["leicht", "schwer"]: - var d: Dictionary = Profile.DIFFICULTIES[key] - var wins := 0 - var games := 24 - for i in games: - var g := Game.new_battle(starter, 1, r, {"target_rating": rs * float(d.factor), "tier": d.tier}) - var arch := Cards.combo_key(Game.deck_elements(g.opponents[0].deck + g.opponents[0].hand)) - check(arch in Game.archetypes(d.tier), "Archetyp passt zur Stufe (%s)" % arch) - var guard := 0 - while g.winner == "" and guard < 300: - var p := g.current() - AI.take_turn(g, p, (not d.smart) and not p.is_human) - guard += 1 - if g.winner == "human": - wins += 1 - results[key] = wins - check(results.leicht > results.schwer, "Leicht ist leichter als Schwer (%d zu %d von 24)" % [results.leicht, results.schwer]) - check(results.leicht >= 16, "Leicht: Starter gewinnt meistens (%d von 24)" % results.leicht) - - -func test_kurve_statistik_vorschlag_verbesserung() -> void: - var starter := Profile.starter().deck_cards() - check(is_zero_approx(DeckGen.curve_penalty(starter)), "Starterkurve ohne Abzug") - var teuer := [] - for i in 20: - teuer.append(Cards.by_id("f_zunge")) - check(DeckGen.curve_penalty(teuer) >= 0.4 - 0.001 and Game.deck_rating(teuer) < 1.0, "teures Deck: Kurvenabzug") - var stats := {"f_kobold": {"played": 30, "games": 20, "wins": 16}} - check(DeckGen.card_value(Cards.by_id("f_kobold"), stats) > Cards.card_value(Cards.by_id("f_kobold")), "gute Statistik hebt den Wert") - check(Game.deck_rating(starter, stats) > Game.deck_rating(starter), "Deckstärke mit Statistik") - var few := {"f_kobold": {"played": 3, "games": 3, "wins": 3}} - check(is_equal_approx(DeckGen.card_value(Cards.by_id("f_kobold"), few), 1.0), "zu wenig Partien: Basiswert") - # Vorschlag nach Serien - var p := Profile.starter() - var mono := starter - check(p.suggest_difficulty() == "", "kein Vorschlag am Anfang") - for i in 3: - p.record_battle(true, 1, mono, -1, [], 6, 1.0, 4) - check(p.suggest_difficulty() == "", "drei knappe Siege: noch kein Vorschlag") - p.records.streak = 0 - p.records.margins = [] - for i in 3: - p.record_battle(true, 1, mono, -1, [], 6, 1.0, 20) - check(p.suggest_difficulty() == "schwer" and p.notices.any(func(n): return n.begins_with("Tipp: 3 Siege")), "drei deutliche Siege: Schwer vorschlagen") - p.records.streak = 0 - p.records.margins = [] - for i in 5: - p.record_battle(true, 1, mono, -1, [], 6, 1.0, 2) - check(p.suggest_difficulty() == "schwer" and p.records.margins.size() == 5, "fünf Siege egal wie knapp") - p.settings.difficulty = "schwer" - p.take_notices() - for i in 3: - p.record_battle(false, 1, mono) - check(p.records.loss_streak == 3 and p.suggest_difficulty() == "normal", "drei Niederlagen: Normal vorschlagen") - p.settings.difficulty = "leicht" - check(p.suggest_difficulty() == "", "auf Leicht kein Abstieg") - # Deck verbessern - var q := Profile.starter() - check(q.improve_deck_with("ff_phoenix", true) != "", "nicht besessen") - q.collection["ff_phoenix"] = 1 - check(q.improve_deck_with("ff_phoenix", true) == "", "Phönix passt (Feuer)") - q.collection["ww_leviathan"] = 1 - check(q.improve_deck_with("ww_leviathan", true) != "", "Leviathan passt nicht (drittes Element)") - check(q.deck_suggestions(["ff_phoenix", "ww_leviathan", "f_kobold"]) == ["ff_phoenix"], "Vorschläge gefiltert") - var before := q.deck.duplicate() - check(q.improve_deck_with("ff_phoenix") == "" and q.deck.count("ff_phoenix") == 1 and q.deck.size() == 20 and q.deck_valid()[0], "Phönix eingetauscht") - check(Game.deck_rating(q.deck_cards()) > Game.deck_rating(before.map(func(id): return Cards.by_id(id))), "Deck stärker") - check(q.improve_deck_with("ff_phoenix", true) != "", "zweite Kopie nicht möglich") - - -func test_wasser_antworten() -> void: - var s := setup(["w_ebbe", "w_tiefenstoss", "w_strudel"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2][0] - var t := put(o, "e_troll") - g.play_card(h, C("w_ebbe"), t) - check(t.health == 5 and t.frozen, "Ebbe: 1 Schaden und Eis") - g.play_card(h, C("w_tiefenstoss"), t) - check(t.health == 2, "Tiefenstoß: 3 Schaden") - var hand := h.hand.size() - g.play_card(h, C("w_strudel"), t) - check(o.board.is_empty() and C("e_troll") in o.hand and h.hand.size() == hand, "Strudel: zurück auf die Hand, eine Karte gezogen") - check(C("w_ebbe").rarity == "gewöhnlich" and Profile.starter().collection.has("w_ebbe"), "Ebbe in der Startsammlung") - - func test_ki_zeitbudget() -> void: var r := RandomNumberGenerator.new() r.seed = 12 - var g := Game.new_battle(Profile.starter().deck_cards(), 5, r) + var g := Game.new_battle(5, r) var h: Game.Player = g.players[0] - h.mulligan_done = true h.energy = 10 h.max_energy = 10 for i in 8: - h.hand.append(Cards.by_id("f_funke")) + h.hand.append(Cards.by_id("f_ritter")) for p in g.players: for i in 3: put(p, "e_troll") @@ -1310,57 +1036,29 @@ func test_ki_zeitbudget() -> void: var ms := Time.get_ticks_msec() - t0 AI.time_budget_ms = 150 check(act.type in ["play", "attack", "end"] and ms < 80, "Zeitbudget greift (%d ms)" % ms) - check(Cards.by_id("wl_gischt").keywords == ["Flink"] and Cards.by_id("wl_gischt").combo() == "Sturm", "Gischtläufer für Sturm") + check(Cards.by_id("wl_gischt").keywords == ["Flink"] and Cards.by_id("wl_gischt").elements == ["Wasser"], "Gischtläufer ist Wasser") var v := Profile.from_dict(JSON.parse_string(JSON.stringify(Profile.starter().to_dict()))) - check(v.settings.has("big_font") and v.records.margins is Array, "Version 5 mit neuen Feldern") + check(v.settings.has("big_font") and v.coins == Profile.START_COINS, "Starterstand gelesen") -## Elementbooster enthalten nur Karten des Elements; Deck-Codes sind umkehrbar und robust. ## Englisch: jede Karte hat einen englischen Namen, Kartentexte und Schlüsselwörter werden übersetzt. func test_englische_karten() -> void: check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen") - var de: String = Cards.by_id("f_funke").text() + var de: String = Cards.by_id("f_ritter").text() TranslationServer.set_locale("en") - var funke := Cards.by_id("f_funke") - var falle := Cards.by_id("w_falle") + var ritter := Cards.by_id("f_ritter") var wall := Cards.by_id("e_wall") - check(funke.title() == "Spark" and funke.text() == "2 damage to a target.", "Funke auf Englisch: %s / %s" % [funke.title(), funke.text()]) - check(falle.text().begins_with("Trap. When an enemy creature attacks") and "The attack fizzles" in falle.text(), "Fallentext auf Englisch: %s" % falle.text()) - check(wall.text() == "Guardian." and Cards.by_id("l_spiegel").text().ends_with("Summon a Wind Spirit."), "Schlüsselwort und Beschwörung auf Englisch") + check(ritter.title() == "Blazing Knight" and ritter.text() == "Swift. On play: 1 damage to a target.", "Ritter auf Englisch: %s / %s" % [ritter.title(), ritter.text()]) + check(wall.text() == "Guardian.", "Schlüsselwort auf Englisch") TranslationServer.set_locale("de") - check(funke.title() == "Funke" and funke.text() == de, "zurück auf Deutsch") - - -func test_elementbooster_und_deckcode() -> void: - var p := Profile.starter() - p.coins = 500 - var r := RandomNumberGenerator.new() - r.seed = 5 - var got: Array = p.open_booster(r, "Wasser") - check(got.size() == 5 and got.all(func(c): return "Wasser" in c.elements) and p.coins == 500 - Profile.ELEMENT_BOOSTER_COST, "Wasser-Booster: fünf Wasserkarten, %d Münzen" % Profile.ELEMENT_BOOSTER_COST) - p.coins = 100 - check(p.open_booster(r, "Feuer").is_empty() and p.coins == 100, "Elementbooster braucht %d Münzen" % Profile.ELEMENT_BOOSTER_COST) - for e in Cards.ELEMENTS: - for rar in Profile.RARITY_WEIGHTS: - check(not Profile.booster_pool(rar, e).is_empty(), "%s hat %s Karten" % [e, rar]) - var deck: Array = Profile.starter().deck - var code := Profile.deck_code(deck) - check(code.begins_with(Profile.CODE_PREFIX) and Profile.parse_deck_code(code).size() == 20, "Deck-Code hin und zurück") - var back: Array = Profile.parse_deck_code(code) - back.sort() - var orig: Array = deck.duplicate() - orig.sort() - check(back == orig, "Deck-Code enthält genau die Karten") - check(Profile.parse_deck_code("kaputt").is_empty() and Profile.parse_deck_code("DINGER1:x_nix*2").is_empty() - and Profile.parse_deck_code("DINGER1:f_funke*9").size() == Game.MAX_COPIES, "ungültige Codes leer, Kopien gedeckelt") - check(Cards.by_id("w_flutfalle").is_trap() and Cards.by_id("fl_sturmfalke").combo() == "Blitz" and Cards.by_id("el_sandsturm").is_combo(), "neue Karten im Pool") + check(ritter.title() == "Lodernder Ritter" and ritter.text() == de, "zurück auf Deutsch") ## Voller Spielstand (viewer -1) stellt Hände, Decks und Bretter wieder her; Zuschauer (-2) sehen nichts Verborgenes. func test_kampf_speichern() -> void: var r := RandomNumberGenerator.new() r.seed = 7 - var g := Game.new_battle(Profile.starter().deck_cards(), 2, r) + var g := Game.new_battle(2, r) g.start() AI.take_turn(g, g.current()) var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1))) @@ -1370,7 +1068,7 @@ func test_kampf_speichern() -> void: var a: Game.Player = g.players[i] var b: Game.Player = g2.players[i] var ids := func(cs: Array): return cs.map(func(c): return c.id) - same = same and ids.call(a.hand) == ids.call(b.hand) and ids.call(a.deck) == ids.call(b.deck) \ + same = same and ids.call(a.hand) == ids.call(b.hand) \ and a.life == b.life and a.board.size() == b.board.size() and a.is_human == b.is_human check(same and g2.current_idx == g.current_idx and g2.turn == g.turn and g2.human.is_human, "voller Spielstand: Hände, Decks und Bretter erhalten") var guard := 0 @@ -1379,7 +1077,7 @@ func test_kampf_speichern() -> void: guard += 1 check(g2.winner != "", "fortgesetzte Partie ist bis zum Ende spielbar") var s2: Dictionary = g.snapshot(-2) - check(s2.players.all(func(p): return not (p.hand is Array) and not (p.traps is Array) and not (p.deck is Array)), "Zuschauer-Sicht ohne Hände, Decks und Fallen") + 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") ## Jeder tr("...")-Text in der Oberfläche hat einen englischen Eintrag. @@ -1396,7 +1094,7 @@ func test_uebersetzung_vollstaendig() -> void: key = line.substr(0, line.find(",")) keys[key] = true var missing := [] - for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + ["Wesen", "Zauber", "Falle"] + Cards.TRIGGER_TEXT.values(): + for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + Cards.FIELD_NAMES.values() + Cards.FIELD_TEXT.values(): if not keys.has(k): missing.append(k) var d := DirAccess.open("res://scripts/ui") diff --git a/tests/ui_test.gd b/tests/ui_test.gd index 6d8f659..13dbba8 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -83,11 +83,6 @@ func pad(button: int) -> void: ## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist. -func clear_mulligan(scr) -> void: - if scr.mulligan_box.visible: - await click(scr.b_keep) - - func hover(node: Control) -> void: var ev := InputEventMouseMotion.new() ev.position = node.get_global_rect().get_center() @@ -123,15 +118,6 @@ func find_button(text: String, root: Node = null) -> Button: return null -func card_views(root: Node) -> Array: - var out := [] - for c in root.get_children(): - if c is CardView: - out.append(c) - out.append_array(card_views(c)) - return out - - ## Wartet, bis keine Kachel mehr animiert wird (Angriffe verschieben Kacheln kurz). func settle(scr) -> void: for i in 40: @@ -147,7 +133,6 @@ func wait_for_human(scr) -> void: await frames(1) wait += 1 await frames(1) - await clear_mulligan(scr) func _ready() -> void: @@ -164,9 +149,9 @@ func _ready() -> void: add_child(main) await frames(3) var fast := OS.get_environment("DINGER_FAST") != "" - var suite := [test_menu, test_gamepad_menue, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen, - test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, - test_kampf_fortsetzen, test_gamepad_deckbau, test_deckcode, test_elementbooster] + 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] if not fast: suite += [test_kampf.bind(2, 3), test_kampf_abbrechen] # Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten. @@ -209,108 +194,16 @@ func test_booster() -> void: check(scr.revealed.size() == 5, "alle aufgedeckt") await shot("04_booster_offen") check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert") - var ids: Array = scr.cards.map(func(c): return c.id) - var better: Array = main.profile.deck_suggestions(ids) - check(scr.b_improve.visible == (not better.is_empty()), "Deck-Vorschlag nur bei passender Karte") - if scr.b_improve.visible: - var deck_before: Array = main.profile.deck.duplicate() - await click(scr.b_improve) - check(main.profile.deck != deck_before and main.profile.deck_valid()[0] and "im Deck" in scr.msg_label.text, "Karte ins Deck übernommen") + for res in scr.cards: + if res.upgraded: + check(main.profile.level(res.card.id) == Cards.RARITIES.find(res.rarity), "aufgewertete Karte trägt ihre Stufe") + else: + check(res.dust > 0, "sonst gibt es Staub") check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand") await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") -func test_deckbau() -> void: - await click(find_button("Deck bauen")) - var scr = screen() - check(scr is DeckScreen, "Deckbildschirm") - check(scr.deck.size() == 20, "Deck geladen") - check("Stärke" in scr.elems_label.text, "Deckstärke im Deckbau") - await shot("05_deck") - await click(find_button("Leeren")) - check(scr.deck.is_empty(), "geleert") - var first_id: String = card_views(scr.grid)[0].card.id - var allowed: int = mini(main.profile.collection[first_id], Game.MAX_COPIES) - await click(card_views(scr.grid)[0]) - check(scr.deck == [first_id], "Karte hinzugefügt") - for i in allowed: - await click(card_views(scr.grid)[0]) - check(scr.deck.count(first_id) == allowed and "Kopien" in scr.msg_label.text, "nur besessene Kopien (%d)" % allowed) - await click(find_button("Speichern")) - check(main.profile.deck.size() == 20 and scr.msg_label.text != "Deck gespeichert", "zu kleines Deck nicht gespeichert") - await click(find_button("Wasser")) - await click(card_views(scr.grid)[0]) - await click(find_button("Erde")) - await click(card_views(scr.grid)[0]) - check("Elemente" in scr.msg_label.text, "drittes Element abgelehnt") - await click(find_button("Alle")) - # Suche, Sortierung, fehlende Karten - var all_count := card_views(scr.grid).size() - scr.search_box.text = "Wächter" - scr.search_box.text_changed.emit("Wächter") - await frames(1) - var found := card_views(scr.grid) - check(found.size() > 0 and found.size() < all_count, "Suche filtert") - check(found.all(func(v): return "Wächter" in v.card.text() or "Wächter" in v.card.name), "Suche trifft Kartentext") - scr.search_box.text = "" - scr.search_box.text_changed.emit("") - scr.sort_box.select(1) - scr.sort_box.item_selected.emit(1) - await frames(1) - var names: Array = card_views(scr.grid).map(func(v): return v.card.name) - var sorted_names := names.duplicate() - sorted_names.sort() - check(names == sorted_names, "Sortierung nach Name") - scr.missing_box.button_pressed = true - await frames(1) - check(card_views(scr.grid).size() == Cards.all().size(), "Fehlende Karten eingeblendet") - var missing = card_views(scr.grid).filter(func(v): return main.profile.collection.get(v.card.id, 0) == 0) - check(not missing.is_empty(), "es gibt fehlende Karten") - await click(missing[0]) - check("fehlt" in scr.msg_label.text, "fehlende Karte nicht ins Deck") - scr.missing_box.button_pressed = false - scr.sort_box.select(0) - scr.sort_box.item_selected.emit(0) - await frames(1) - var n: int = scr.deck.size() - await click(scr.deck_list.get_children()[0]) - check(scr.deck.size() == n - 1, "Zeile entfernt Karte") - await hover(card_views(scr.grid)[0]) - check(scr.preview.visible, "Vorschau bei Hover") - await shot("06_deck_bearbeitet") - # Slots: Deck 2 aus Deck 1 kopieren, speichern, aktiv setzen - await click(scr.slot_buttons[1]) - if scr.slot != 1 or not scr.deck.is_empty(): - print("DEBUG Slot: slot=", scr.slot, " deck=", scr.deck.size(), " rect=", scr.slot_buttons[1].get_global_rect(), - " hovered=", get_viewport().gui_get_hovered_control(), " decks1=", main.profile.decks[1].cards.size()) - check(scr.slot == 1 and scr.deck.is_empty(), "Slot 2 leer") - scr.deck = main.profile.decks[0].cards.duplicate() - scr.refresh() - await click(find_button("Speichern")) - check(main.profile.decks[1].cards.size() == 20 and "gespeichert" in scr.msg_label.text, "Slot 2 gespeichert") - await click(find_button("Aktiv setzen")) - check(main.profile.active_deck == 1 and main.profile.deck_name() == "Deck 2", "Slot 2 aktiv") - check(Profile.load_or_new(SAVE).active_deck == 1, "aktiver Slot gespeichert") - scr.name_box.text = "Testdeck" - scr.name_box.text_submitted.emit("Testdeck") - check(main.profile.deck_name(1) == "Testdeck" and Profile.load_or_new(SAVE).deck_name(1) == "Testdeck", "Deck umbenannt") - await click(scr.slot_buttons[2]) - check(scr.slot == 2 and scr.deck.is_empty(), "Slot 3 leer") - await click(find_button("Auto-Ausfüllen")) - check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar") - await shot("25_deck_slots") - await click(scr.deck_list.get_children()[0]) # ungespeicherte Änderung: eine Karte entfernt - check(scr.dirty(), "Deck geändert") - await click(find_button("Zurück")) - check(scr.confirm.visible and screen() == scr, "Warnung bei ungespeicherten Änderungen") - await click(find_button("Weiter bearbeiten")) - check(not scr.confirm.visible, "weiter bearbeiten") - await click(find_button("Zurück")) - await click(find_button("Verwerfen")) - check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen") - - func target_view(scr, valid: Array) -> Control: for v in scr.enemy_views: if v.creature in valid: @@ -326,17 +219,26 @@ func target_view(scr, valid: Array) -> Control: func human_turn(scr) -> void: var g: Game = scr.game - await clear_mulligan(scr) + # erst kaufen, solange Mana und Handplatz reichen + for i in 12: + var offers: Array = scr.shop_views.filter(func(v): return g.human.energy >= v.card.cost and g.human.hand.size() < Game.MAX_HAND) + if offers.is_empty(): + break + var offer: Cards.Card = offers[0].card + var mana: int = g.human.energy + await click(offers[0]) + check(g.human.energy == mana - offer.cost, "gekauft: " + offer.name) + # dann ausspielen, das kostet nichts for i in 12: var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0]) if playable.is_empty(): break var card: Cards.Card = playable[0].card - var energy: int = g.human.energy + var board_before: int = g.human.board.size() await click(playable[0]) if scr.pending != null: await click(target_view(scr, g.valid_targets(g.human, card.target_type()))) - check(g.human.energy == energy - card.cost, "Karte gespielt: " + card.name) + check(g.human.board.size() > board_before, "Karte gespielt: " + card.name) for i in 12: var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack()) if ready.is_empty() or g.winner != "": @@ -377,9 +279,8 @@ func test_gegnerauswahl() -> void: await click(find_button("Einzelspieler")) var sel = screen() check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet") - check("Deckstärke" in sel.rating_label.text and "Gegnerdecks" in sel.rating_label.text - and not ("Archetypen" in sel.rating_label.text or "KI" in sel.rating_label.text), "unter der Schwierigkeit nur die zwei Stärken") 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() @@ -400,17 +301,17 @@ func test_gegnerauswahl() -> void: await click(find_button("Starten")) var hard = screen() check(hard is BattleScreen and hard.game.opponents.size() == 1, "Kampf mit einem Gegner gestartet") - var own := Game.deck_rating(main.profile.deck_cards()) var opp: Game.Player = hard.game.opponents[0] - var opp_rating := Game.deck_rating(opp.deck + opp.hand) - check(opp_rating >= own * 1.05 - 0.05 and hard.smart_ai, "Schwer: Gegnerdeck stärker (%.2f zu %.2f), Vorausschau-KI" % [opp_rating, own]) + 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") 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] - check(Game.deck_rating(opp.deck + opp.hand) <= own * 1.0 + 0.15 and not easy.smart_ai, "Leicht: schwächeres Deck, einfache KI") + check(opp.max_life == Profile.DIFFICULTIES.leicht.life and not easy.smart_ai, "Leicht: Gegner mit %d Leben, einfache KI" % Profile.DIFFICULTIES.leicht.life) await give_up() await click(find_button("Einzelspieler")) await click(find_button("Normal")) @@ -438,7 +339,6 @@ func leave_battle() -> void: func give_up() -> void: var scr = screen() - await clear_mulligan(scr) await click(find_button("Aufgeben")) check(scr.confirm.visible, "Aufgeben fragt nach") await click(find_button("Weiterspielen")) @@ -448,71 +348,6 @@ func give_up() -> void: check(screen() is MenuScreen, "Aufgeben führt ins Menü") -## Gamepad im Deckbau: Karten und Deckzeilen sind fokussierbar, A wirkt wie ein Klick, B geht zurück. -func test_gamepad_deckbau() -> void: - await click(find_button("Deck bauen")) - var scr = screen() - var before: int = scr.deck.size() - await pad(JOY_BUTTON_DPAD_DOWN) - check(get_viewport().gui_get_focus_owner() != null and main.pad_mode, "Gamepad setzt Fokus im Deckbau") - var row: Control = scr.deck_list.get_child(0) - row.grab_focus() - await frames(1) - check(row.has_focus() and scr.preview.visible, "Deckzeile fokussierbar, Vorschau folgt") - await pad(JOY_BUTTON_A) - check(scr.deck.size() == before - 1, "A entfernt die fokussierte Karte aus dem Deck") - var elems: Array = Game.deck_elements(scr.deck_cards()) - var target: CardView = null - for v in scr.grid.views(): - if not v.dim and v.card.elements.all(func(e): return e in elems): - target = v - break - target.grab_focus() - await frames(1) - check(target.has_focus() and scr.preview.visible and scr.preview.card == target.card, "Karte im Raster fokussierbar mit Vorschau") - var target_id: String = target.card.id - await pad(JOY_BUTTON_A) # das Raster wird danach neu aufgebaut, target ist dann freigegeben - check(scr.deck.size() == before and target_id in scr.deck, "A nimmt die fokussierte Karte ins Deck (%d Karten, Meldung '%s')" % [scr.deck.size(), scr.msg_label.text]) - await shot("31_gamepad_deckbau") - await pad(JOY_BUTTON_B) - check(scr.confirm.visible, "B fragt bei ungespeicherten Änderungen nach") - await click(find_button("Verwerfen")) - check(screen() is MenuScreen, "Verwerfen führt ins Menü") - await hover(main) - - -## Deck-Code kopieren, einfügen und übernehmen. -func test_deckcode() -> void: - await click(find_button("Deck bauen")) - var scr = screen() - await click(find_button("Code")) - check(scr.code_dialog.visible and scr.code_box.text.begins_with(Profile.CODE_PREFIX), "Code-Dialog zeigt den Deck-Code") - await click(find_button("In Zwischenablage")) - check(DisplayServer.clipboard_get() == scr.code_box.text, "Code in der Zwischenablage") - await shot("32_deckcode") - scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,x_nix*1" - await click(find_button("Übernehmen")) - check(scr.deck.size() == Game.DECK_SIZE and "Ungültiger Code" in scr.msg_label.text, "ungültiger Code wird abgelehnt") - scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,fff_sonne*1" - await click(find_button("Übernehmen")) - check(scr.deck == ["f_funke", "f_funke", "e_wall"] and "Kern der Sonne" in scr.msg_label.text and not scr.code_dialog.visible, "Code übernommen, fehlende Karte gemeldet") - await click(find_button("Zurück")) - await click(find_button("Verwerfen")) - check(screen() is MenuScreen, "Deckbau verlassen") - - -## Elementbooster liefert nur Karten des Elements. -func test_elementbooster() -> void: - main.profile.coins = 300 - await click(find_button("Booster öffnen")) - var scr = screen() - await click(find_button("Feuer")) - check(scr.cards.size() == 5 and scr.cards.all(func(c): return "Feuer" in c.elements) and main.profile.coins == 300 - Profile.ELEMENT_BOOSTER_COST, "Feuer-Booster: fünf Feuerkarten") - await click(find_button("Alle aufdecken")) - await shot("33_elementbooster") - await click(find_button("Zurück")) - - ## Kampf speichern, im Menü fortsetzen, Aufgeben löscht die Datei. func test_kampf_fortsetzen() -> void: await start_battle(1) @@ -569,28 +404,20 @@ func test_sammlung() -> void: var scr = screen() check(scr is CollectionScreen, "Sammlung geöffnet") var p: Profile = main.profile - p.dust = 0 - var in_decks := {} - for d in p.decks: - for cid in d.cards: - in_decks[cid] = true - var free = scr.grid.views().filter(func(v): return p.collection.get(v.card.id, 0) > 0 and not in_decks.has(v.card.id)) - check(not free.is_empty(), "eine freie Karte zum Zerlegen") - var card: Cards.Card = free[0].card - await click(free[0]) - check(scr.selected == card, "Karte gewählt") - var before: int = p.collection[card.id] - await click(scr.b_dismantle) - check(p.collection[card.id] == before - 1 and p.dust == Profile.DUST_DISMANTLE[card.rarity], "zerlegt, Staub gutgeschrieben") + check(scr.grid.views().size() == Cards.all().size(), "alle Karten gehören dir") p.dust = 5000 scr.search_box.text = "Phönix" scr.search_box.text_changed.emit("Phönix") await frames(1) var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0] await click(phoenix) - await click(scr.b_craft) - check(p.collection.get("ff_phoenix", 0) == 1 and p.dust == 5000 - Profile.DUST_CRAFT["legendär"], "Phönix hergestellt") - check(Profile.load_or_new(SAVE).collection.get("ff_phoenix", 0) == 1, "Sammlung gespeichert") + 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") + check(Profile.load_or_new(SAVE).level("ff_phoenix") == 1, "Sammlung gespeichert") + var dust: int = p.dust + await click(scr.b_dismantle) + check(p.level("ff_phoenix") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["selten"], "wieder zerlegt") await shot("20_sammlung") await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") @@ -634,7 +461,7 @@ func test_profil_zuruecksetzen() -> void: check(not scr.confirm.visible, "abgebrochen") await click(find_button("Profil zurücksetzen")) await click(find_button("Ja, zurücksetzen")) - check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and main.profile.collection.size() > 0, "Profil zurückgesetzt") + check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and main.profile.collection.is_empty(), "Profil zurückgesetzt") check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert") @@ -646,7 +473,7 @@ func test_sprache() -> void: await frames(2) check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch") check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen") - check(TranslationServer.translate("Zug %d · Du bist dran") == "Turn %d · Your turn" and TranslationServer.translate("Nicht genug Energie") == "Not enough energy", "dynamische Texte übersetzt") + check(TranslationServer.translate("Zug %d · Du bist dran") == "Turn %d · Your turn" and TranslationServer.translate("Nicht genug Mana") == "Not enough mana", "dynamische Texte übersetzt") check(find_button("Back") != null, "Knöpfe übersetzt") await shot("26_englisch") await click(find_button("Back")) @@ -670,41 +497,39 @@ func test_kampf_steuerung() -> void: scr.ai_delay = 0.0 await wait_for_human(scr) var g: Game = scr.game - var idx := -1 - for i in g.human.hand.size(): - if g.human.hand[i].is_creature() and g.can_play(g.human, g.human.hand[i])[0]: - idx = i - break - check(idx >= 0, "spielbares Wesen auf der Hand") - if idx >= 0: - var played_id: String = g.human.hand[idx].id - var board_before: int = g.human.board.size() - await key(KEY_1 + idx) - check(g.human.board.size() == board_before + 1, "Wesen per Taste gespielt") - # Rückgängig: Wesen zurück auf die Hand - check(scr.b_undo.visible, "Rückgängig angeboten") - var energy: int = g.human.energy - await click(scr.b_undo) - g = scr.game - check(g.human.board.size() == board_before and g.human.hand.any(func(c): return c.id == played_id) and g.human.energy > energy, "Rückgängig stellt Hand und Energie her") - check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht") - await key(KEY_1 + idx) - check(g.human.board.size() == board_before + 1, "erneut gespielt") + g.human.energy = 20 # das Angebot ist zufällig, hier soll nur die Steuerung geprüft werden + scr.refresh() + check(scr.shop_views.size() == Game.SHOP_SIZE and g.human.hand.is_empty(), "voller Laden, leere Hand") + # Kaufen: Mana weg, Karte auf der Hand + var offer: Cards.Card = scr.shop_views[0].card + var mana: int = g.human.energy + await click(scr.shop_views[0]) + check(g.human.hand.size() == 1 and g.human.hand[0] == offer and g.human.energy == mana - offer.cost, "Angebot gekauft") + # Ausspielen kostet nichts + var board_before: int = g.human.board.size() + mana = g.human.energy + await key(KEY_1) + check(g.human.board.size() == board_before + 1 and g.human.energy == mana, "Wesen per Taste gespielt, ohne Mana") + # Rückgängig: Wesen zurück auf die Hand + check(scr.b_undo.visible, "Rückgängig angeboten") + await click(scr.b_undo) + g = scr.game + check(g.human.board.size() == board_before and g.human.hand.any(func(c): return c.id == offer.id), "Rückgängig stellt die Hand her") + check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht") + await key(KEY_1) + check(g.human.board.size() == board_before + 1, "erneut gespielt") + # Neu würfeln kostet Mana + var shop_before: Array = g.human.shop.duplicate() + mana = g.human.energy + await click(scr.b_reroll) + check(g.human.energy == mana - Game.REROLL_COST and g.human.shop != shop_before, "Laden neu gewürfelt") await shot("22_kampf_steuerung") - # Gamepad: Zeiger bewegen, Karte spielen + # Gamepad: Zeiger im Laden bewegen, A kauft await pad(JOY_BUTTON_DPAD_RIGHT) - check(scr.gp_zone == "hand" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweiter Handkarte") - var pad_idx := -1 - for i in g.human.hand.size(): - if g.human.hand[i].is_creature() and g.can_play(g.human, g.human.hand[i])[0]: - pad_idx = i - break - if pad_idx >= 0: - while scr.gp_index != pad_idx: - await pad(JOY_BUTTON_DPAD_RIGHT) - var board_before2: int = g.human.board.size() - await pad(JOY_BUTTON_A) - check(g.human.board.size() == board_before2 + 1, "Gamepad A spielt das Wesen") + check(scr.gp_zone == "shop" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweitem Angebot") + var hand_before: int = g.human.hand.size() + await pad(JOY_BUTTON_A) + check(g.human.hand.size() == hand_before + 1, "Gamepad A kauft das Angebot") scr.ai_delay = 0.75 await key(KEY_SPACE) check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") @@ -717,7 +542,6 @@ func test_kampf_steuerung() -> void: w += 1 check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w) scr.ai_delay = 0.0 - await clear_mulligan(scr) await key(KEY_ESCAPE) check(scr.confirm.visible, "Esc öffnet Aufgeben") await key(KEY_ESCAPE) @@ -725,6 +549,92 @@ func test_kampf_steuerung() -> void: await give_up() +## Grenzen im Kampf: volle Hand, volles Feld, kein Mana. +func test_kampf_grenzen() -> void: + await start_battle(1) + var scr = screen() + scr.ai_delay = 0.0 + await wait_for_human(scr) + var g: Game = scr.game + g.human.energy = 100 + scr.refresh() + await frames(1) + # Hand voll kaufen + var guard := 0 + while g.human.hand.size() < Game.MAX_HAND and guard < 30: + guard += 1 + if scr.shop_views.is_empty(): + await click(scr.b_reroll) + continue + await click(scr.shop_views[0]) + check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND) + check(scr.shop_views.all(func(v): return v.dim), "bei voller Hand ist nichts mehr kaufbar") + if not scr.shop_views.is_empty(): + await click(scr.shop_views[0]) + check(scr.msg_label.text == tr("Hand voll"), "Meldung bei voller Hand: " + scr.msg_label.text) + # Feld voll spielen + guard = 0 + while g.human.board.size() < Game.MAX_BOARD and guard < 30: + guard += 1 + await key(KEY_1) + if scr.pending != null: # Karte will ein Ziel + var valid: Array = g.valid_targets(g.human, scr.pending.target_type()) + if valid.is_empty(): + await right_click() + else: + await click(target_view(scr, valid)) + check(g.human.board.size() == Game.MAX_BOARD, "Feld mit %d Wesen voll" % Game.MAX_BOARD) + await key(KEY_1) + check(scr.msg_label.text == tr("Dein Feld ist voll"), "Meldung bei vollem Feld: " + scr.msg_label.text) + await shot("25_kampf_voll") + # Verschmelzen macht wieder Platz + var before: int = g.human.board.size() + await click(scr.b_fuse) + await click(scr.own_views[0]) + await click(scr.own_views[1]) + check(g.human.board.size() == before - 1, "Verschmelzen macht einen Platz frei") + # Ohne Mana kein Neuwürfeln + g.human.energy = 0 + scr.refresh() + await frames(1) + check(scr.b_reroll.disabled, "ohne Mana kein Neuwürfeln") + await give_up() + + +## Feldanzeige und Verschmelzen im Kampf. +func test_kampf_feld_und_verschmelzen() -> void: + await start_battle(1) + 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) + g.next_uid += 1 + g.human.board.append(cr) + g.human.energy = 10 + 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(g.human.board[0].field_bonus == 1 and g.human.board[0].power() == 4, "das Feuerwesen bekommt +1 Angriff") + check(scr.b_fuse.visible and scr.b_fuse.text == "Verschmelzen", "Verschmelzen wird angeboten") + await shot("23_feld") + await click(scr.b_fuse) + check(scr.fusing and scr.b_fuse.text == "Abbrechen", "Verschmelzen ausgewählt") + await click(scr.own_views[0]) + check(scr.fuse_first != null, "erstes Wesen gewählt") + await shot("24_verschmelzen") + await click(scr.own_views[1]) + check(g.human.board.size() == 1 and g.human.board[0].name == "Dampf", "die beiden Wesen sind zu Dampf verschmolzen") + check(not scr.fusing and g.human.energy == 8, "Modus beendet und Mana bezahlt") + check(scr.own_views.size() == 1, "nur noch eine eigene Kachel") + await give_up() + + func test_netzwerk() -> void: await click(find_button("Netzwerkspiel")) var scr = screen() @@ -756,7 +666,6 @@ func test_netzwerk() -> void: var rounds := 0 while b.game != null and b.game.winner == "" and rounds < 2: if b.game.current() == b.perspective: - await clear_mulligan(b) if b.game.current() == b.perspective: await click(b.b_end) var w2 := 0 @@ -767,7 +676,7 @@ func test_netzwerk() -> void: if b.game.winner != "": print("DEBUG Netz: Sieger=", b.game.winner, " Verlauf=", b.game.log.slice(maxi(0, b.game.log.size() - 12))) check(b.game.turn >= 2 and b.game.winner == "", "Runden gespielt, Partie läuft (Zug %d)" % b.game.turn) - check(b.game.log.any(func(l): return l.begins_with("Bot spielt") or l.begins_with("Bot behält") or l.begins_with("Bot tauscht")), "Bot hat gehandelt") + check(b.game.log.any(func(l): return l.begins_with("Bot kauft") or l.begins_with("Bot spielt")), "Bot hat gehandelt") await shot("24_netzwerk_kampf") # Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen var quit_file := "user://bot_quit" @@ -781,7 +690,6 @@ func test_netzwerk() -> void: check(main.net.waiting.has("Bot") and b.game.log.any(func(l): return "Verbindung verloren" in l), "Platz wird freigehalten") var turn_before: int = b.game.turn for i in 2: - await clear_mulligan(b) if b.game.current() == b.perspective and b.game.winner == "": await click(b.b_end) var w4 := 0 @@ -799,7 +707,6 @@ func test_netzwerk() -> void: check(main.net.waiting.is_empty() and main.net.seats.size() == 2 and b.game.log.any(func(l): return l == "Bot ist zurück."), "Bot wieder verbunden, Platz zurück") var turn2: int = b.game.turn for i in 2: - await clear_mulligan(b) if b.game.current() == b.perspective and b.game.winner == "": await click(b.b_end) var w6 := 0 @@ -873,7 +780,6 @@ func test_host_wechsel() -> void: await shot("34_host_wechsel") # Der alte Host kehrt nicht zurück: ein Zug wird übersprungen, nach der Frist scheidet er aus b.ai_delay = 0.0 - await clear_mulligan(b) if b.game.current() == b.perspective: await click(b.b_end) w = 0 @@ -906,7 +812,7 @@ func test_zuschauer() -> void: while (b.game == null or b.game.turn < 2) and w < 900: await frames(1) w += 1 - check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.mulligan_box.visible and not b.b_end.visible, + check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.b_end.visible, "Zuschauer sieht die Partie ohne Hände und ohne Züge (Zug %d)" % (b.game.turn if b.game != null else 0)) await shot("35_zuschauer") check(not b.b_end.visible and not b.b_undo.visible, "Zuschauer hat keine Zugknöpfe") @@ -964,7 +870,7 @@ func test_kampf(n_opp: int, shot_turn: int) -> void: var g: Game = scr.game check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost") await wait_for_human(scr) - check(g.current() == g.human and g.human.mulligan_done, "Spieler ist dran, Mulligan erledigt") + check(g.current() == g.human, "Spieler ist dran") check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar") await shot("07_kampf_start") var coins: int = main.profile.coins @@ -998,35 +904,30 @@ func test_kampf_abbrechen() -> void: var g: Game = scr.game await wait_for_human(scr) check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner") - # Falle legen und Kombo-Hinweis prüfen - g.human.hand.append(Cards.by_id("f_falle")) + # Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit g.human.energy = 10 var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000) g.human.board.append(wolf) g.human.hand.append(Cards.by_id("w_reiter")) + g.human.looks["w_reiter"] = Cards.RARITIES.find("episch") scr.refresh() - var trap_view = scr.hand_views.filter(func(v): return v.card.id == "f_falle")[0] - await click(trap_view) - check(g.human.traps.size() == 1 and scr.trap_views.size() == 1, "Falle gelegt und sichtbar") 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(reiter_view) - var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel) - if hint_boxes.size() != 1: - print("DEBUG Hinweis: hints=", scr.combo_hints(Cards.by_id("w_reiter")), " kinder=", scr.preview_holder.get_children().map(func(c): return c.get_class()), - " brett=", g.human.board.map(func(c): return c.name), " hovered=", get_viewport().gui_get_hovered_control(), " reiter_pos=", reiter_view.get_global_rect()) - check(hint_boxes.size() == 1 and "Feldkombo Dampf" in hint_boxes[0].text, "Kombo-Hinweis in der Vorschau") - await shot("21_kampf_falle_hinweis") - var spells: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0]) - if not spells.is_empty(): - var card: Cards.Card = spells[0].card - await click(spells[0]) + 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") + await shot("21_kampf_vorschau") + var targeted: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0]) + if not targeted.is_empty(): + var card: Cards.Card = targeted[0].card + await click(targeted[0]) check(scr.pending == card, "Ziel wird erwartet") await right_click() check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab") await hover(scr.hand_views[0]) if scr.preview_holder.get_child_count() == 0: - print("DEBUG Hover: mulligan=", scr.mulligan_box.visible, " overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(), + 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")) diff --git a/translations/ui.csv b/translations/ui.csv index 9607a4c..cab5825 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -5,31 +5,22 @@ Einzelspieler,Single player Kampagne,Campaign Netzwerkspiel,Network game Booster öffnen,Open boosters -Deck bauen,Build deck Einstellungen,Settings Beenden,Quit Zurück,Back Start,Start -Speichern,Save -Leeren,Clear Suche,Search -Fehlende,Missing Alle,All -Aktiv setzen,Set active -Auto-Ausfüllen,Auto-fill -Umbenennen,Rename Zug beenden,End turn Aufgeben,Surrender Weiterspielen,Keep playing "Ja, aufgeben","Yes, surrender" Wirklich aufgeben?,Really surrender? -Der Kampf endet ohne Münzen.,The battle ends without coins. Zurück zum Menü,Back to menu Lupe,Zoom Schließen,Close Held als Ziel,Target hero Verlauf,Log -Karte überfahren für Details,Hover a card for details Lautstärke,Volume Musik,Music KI-Tempo,AI speed @@ -39,30 +30,17 @@ Booster,Boosters Booster kaufen (100 Münzen),Buy booster (100 coins) Alle aufdecken,Reveal all Zerlegen,Dismantle -Herstellen,Craft Kämpfen,Fight Wiederholen,Replay Gesperrt,Locked Spiel eröffnen,Host game Beitreten,Join -Behalten,Keep -Tauschen,Swap -Hand tauschen?,Swap cards? -Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game. -Fallen,Traps Du bist dran,Your turn -Antreten,Enter Gegnerzüge überspringen,Skip AI turns Rückgängig,Undo Kapitel 1,Chapter 1 Kapitel 2,Chapter 2 "Münzen: %d · Staub: %d","Coins: %d · Dust: %d" -"Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d","Collection: %d of %d cards (%d total) · Campaign: %d/%d" -Aktives Deck: %s (%s),Active deck: %s (%s) -leer,empty -Tagesaufgabe erledigt: %s ✓,Daily task done: %s ✓ -Tagesaufgabe: %s (%d/%d) · +%d Münzen,Daily task: %s (%d/%d) · +%d coins -"Aktives Deck nicht spielbar: ","Active deck not playable: " Zug %d · Du bist dran,Turn %d · Your turn Zug %d · %s ist dran …,Turn %d · %s is playing … Wähle ein Ziel,Choose a target @@ -70,57 +48,35 @@ Wähle ein Angriffsziel,Choose an attack target Abgebrochen,Cancelled %s kann nicht angreifen,%s cannot attack Nicht dein Zug,Not your turn -Nicht genug Energie,Not enough energy +Nicht auf der Hand,Not in your hand Dein Feld ist voll,Your board is full Kein gültiges Ziel,No valid target Ungültiges Ziel,Invalid target -Höchstens 2 Fallen,At most 2 traps Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first Sieg!,Victory! Niederlage,Defeat +%d Münzen,+%d coins -"Deck braucht genau %d Karten (%d)","Deck needs exactly %d cards (%d)" -Höchstens %dx dieselbe Karte,At most %dx the same card -Karte nicht oft genug in der Sammlung,Not enough copies in your collection -Höchstens %d Elemente im Deck,At most %d elements in a deck Schwierigkeit,Difficulty Leicht,Easy Normal,Normal Schwer,Hard -Stärke %.1f,Strength %.1f -"Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten","Deck strength: average of rarity, keywords and effects" -Neu würfeln,Reroll -Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s) Profil zurücksetzen,Reset profile Große Schrift,Large text -Verwerfen,Discard -Weiter bearbeiten,Keep editing -Ungespeicherte Änderungen,Unsaved changes Duell,Duel %d am Tisch,%d at the table -%d Spieler am Tisch,%d players 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" -Aufgeben beendet den Kampf ohne Münzen.,Surrendering ends the battle without coins. -Speichern hebt ihn für später auf.,Saving keeps it for later. -%s Schaden an den Angreifer,%s damage to the attacker -%s Schaden an dieses Wesen,%s damage to that creature -Der Angreifer wird eingefroren,The attacker is frozen -Es wird eingefroren,It is frozen -Es geht zurück auf die Hand,It returns to its owner's hand -Der Angriff verpufft,The attack fizzles -Beschwöre einen %s,Summon a %s %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 Heile dich um %s,Heal yourself for %s -Ziehe 1 Karte,Draw 1 card -Ziehe %s Karten,Draw %s cards +Erhalte 1 Mana,Gain 1 mana +Erhalte %s Mana,Gain %s mana Friere ein gegnerisches Wesen ein,Freeze an enemy creature Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature Friere alle gegnerischen Wesen ein,Freeze all enemy creatures @@ -131,17 +87,11 @@ Alle befreundeten Wesen erhalten %s,All friendly creatures get %s Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield Erhält Schild,Gets Shield Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield -Falle. %s: %s,Trap. %s: %s Beim Ausspielen: %s,On play: %s -Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift,When an enemy creature attacks you or one of your creatures -Wenn ein Gegner ein Wesen ausspielt,When an opponent plays a creature Wächter,Guardian Flink,Swift Schild,Shield Doppelschlag,Double Strike -Wesen,Creature -Zauber,Spell -Falle,Trap gewöhnlich,common selten,rare episch,epic @@ -161,33 +111,49 @@ Blitz,Lightning Schlamm,Mud Sturm,Storm Sand,Sand -Sonne,Sun -Ozean,Ocean -Gebirge,Mountains -Himmel,Sky -"Code übernommen, es fehlen: ","Code applied, missing: " -Elemente: ,Elements: %s zerlegt (+%d Staub),%s dismantled (+%d dust) -%s hergestellt,%s crafted -%s ist jetzt im Deck %s,%s is now in deck %s -Deck-Code,Deck code -In Zwischenablage,To clipboard -Aus Zwischenablage,From clipboard -Code,Code -Ungültiger Code,Invalid code -Code übernommen,Code applied -Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.,Copy to share or paste a code. Cards you do not own are skipped. -Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos,Element booster (%d coins): only cards of that element and its combos -"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks.","Chapter 3: three perks, full tables and the strongest decks." -Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!,All three chapters completed. The Archmage is defeated for good! Kapitel %d,Chapter %d Zuschauen,Watch Zuschauer,Spectator "Zuschauer: Hände sind verdeckt","Spectator: hands are hidden" -Deine Deckstärke %.1f · Gegnerdecks %.1f,Your deck strength %.1f · Opponent decks %.1f Starten,Start 1 Gegner,1 opponent %d Gegner,%d opponents -Übernehmen,Apply 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 +Flut,Flood +Fels,Rock +Wirbel,Vortex +Feuerwesen haben +1 Angriff,Fire creatures get +1 attack +Wasserwesen haben +1 Angriff,Water creatures get +1 attack +Erdwesen haben +1 Angriff,Earth creatures get +1 attack +Luftwesen haben +1 Angriff,Air creatures get +1 attack +Aufgewertet: %d von %d Karten · Kampagne: %d/%d,Upgraded: %d of %d cards · Campaign: %d/%d +%s ist jetzt %s,%s is now %s +Alle Kapitel abgeschlossen,All chapters completed +Elemente,Elements +Aufwerten,Upgrade +Aufwerten zu %s (%d Staub),Upgrade to %s (%d dust) +Höchste Stufe,Top tier +Keine Karte gewählt,No card selected +Zerlegen (+%d Staub),Dismantle (+%d dust) +Nicht genug Staub (%d nötig),Not enough dust (%d needed) +Schon auf der höchsten Stufe,Already at the top tier +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) +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 diff --git a/translations/ui.en.translation b/translations/ui.en.translation index 69fb7a1366e028c85f4dd9792a0c79aab9882050..b8d5c73bfc7eb1f0dcfe2a3c8db8d8db4e3ad006 100644 GIT binary patch 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 literal 7732 zcma)B2~?Cvw*FackdG7@-lHW^=(dv=Ko^Xy6ChUhM@%jT#e-O56<+ z$2`%fBXJ!aBQYBHh#3>n(WlW-bF!F>`}T|&^Ygyj{DmD(-Z`(2A60eh?ziezgSnbq z6(@c?F?I$dKrcXTvFnVwnSAMZtJY*QXze<)3FNkq2Zndz4g~H=jvmZt|DUl7n52M} zx0xN*628!2F3}na?dC#T32)*#f7d;e*2r@tVdQN#Z8>jq`sbA3x!#~#3=S)#tquck zD>PT~R;#X*=X|hJp|w>M+O@?7p7X=9*ioYA?IOVo1e@KeGnKa~w3S$O7CVR>=LUoW zP8&&aaetPB!V0U_VdYfBi_ia=D#~XkKn5HI=+lZ1<<}SJF0LsZM5oUH%m<0Y`cOnOUDiaKC$1M@Tn?-S>VZ!H+Hb`81#kj54_pSW z13v<{0OEOo@wqssgh>EjpgTbO^pRp73Pb}*KsqoMXxnXV9glf|%en~jGI2da9Ot;~ zzbnp{Vq7iG*J7*(wg9_D8q?3lPdm_?u>Jy2sqCc_!>lR%{)nILa2YAZ%+ zH>7uWw=?~dVUX^95Ww{M_x3*;FWPgBr*w73&>93mK2uv{{Qu@}tUvBzkJ0zp-^h53 z1&fQ(zj-S*BO3A)i)%dIvyLbI9YJ^Wdz0q-HGv?qd`(Au#jr>DcCF9fMjz2Ef)S#* z(l-}4-PG5a~ z8WZx1;$VFCNh3b$hWs)AOrDebF2lNG9=@$V{Li=G5Y>Ztnf&NoJ{K~urrZ+(R((+Y zAMi1GgyM{0?QUVfzc42s$^YSUxAjjjvix04Iu=(0p{}6y09f1)6G!}341J7`(dYCU zxTg+_j&@eht$DT#@+_}|)JESa$PeX`&?_LSY6I332Z8BZI=R_$5^KsMoiRpVH2cdw z4PcA{P3b~^l=|O8W?@Y_UG;Qoe8?Y$e&!d`lXm6Iiv}c)#liAdnmH&z2OW%m*4+9y zy6^Nr%K;|u*0gjzy-6$%MjyEBnV%kdDQB+ts(e+^xqre4ofmp!8UME8{Cyd4A^^00 zF7#P}zecBm396i6{N~`l-|pu^XZ6fj-OF^-C9fM*w|C!zb1d&0xZwVikr=8s*=KS? z&b*e?8TwuUEf6?Ul$&(}YnGP*-a=~x{2qqAWPs)Ay*np!=*?z%ioO-OLh6!d##ej5 z=T;Gn(K)BH^kK_Q&!<>ZyrBS#%XF>Ni(#O5toNLgamyyd89D=fT=+i?S=w{FsF!?a zdS)j~@;nWvD8?`VE;;vB?ro`~H=M;4d2)qx4K^u1bWa|ya!lL;Id-3y3lZ}gk$l>7 z?K6X(WPWY}jp<=_@4i0mNe1#wcKZS>{#DBQL9^f|<(BBzH^dirfiU^(dJeYUYI*z< zlu`TyruXB$w|dgFOel*O8{beTv!y!8as5L!X z(iQQNU(`>Ty~xjcJ=qC=sWxE%vw3ZC#KUqh(cPk)oqBX`&nm20{d=|?9u)#+b|1F> zXjp6nGo4@JXZkA+U0YlWCell?%)fWeH*PnAK)jT1i{YUq6!LVB=sP8o@4kz-8~`7k zCAyPLKDO#{`E}6QIT`HOJyZdC;-MIs{7-kU_sv3lWP@~L>fF2c@D;uubBd3AR=$^3 z^#J-vHV)VjQh6v0e)q#%0Wf_&zvmoF#Dd*Zrtjj2qhC-B$OgTSfy;GIokqb{?32=c;%89*YI(B%cm>4Q|YVF!cwzXZiC=nwN`m7~11?gl+jD2!2!j2+WSb z_;cDW7e5)jD7sho&VRzMegB-=3?}9mt93?r|J|U6Rlz#{s#pW8HN@p;+!(Wz1X1{*@^B(!& zW%Se&_jUxpK9jH1-2Lns79I2QKMM|CJBT2uuh5-h^{cjC3Ym!o^+7t{La#m5hyFzV z%W}=wwJ^%Qzpy-YTVqVx4LNpS7(L0YF(VRn7=S$`!0erz6%W1$%*l@z8&)`);TPpc1~C1eP2Oj-p`Ykv*Kc~>kh_p0 zpQx6UTf+Ha-uWva)DLq4llOb#S)TyC7Zxw;jSM-hDv=@HTJ)pRlpTnvKbxMvGQgZsNxB_UUG;URF+j-4oQ! zQ3Vo7P9JZ9OLOQ9rLt1KM9*n>yWRQsxi=g-JC~**dC)bl{@fv zO)kxBw%K{B4K^aOC>^s~J^IHF8nmIScdJ_zC*-=DCkEERg>?Cjae2t4Ra<7~GIiw@ zc0_0~Xscizf8i|UvZ1Igv!l#96E~XYEu3b$w(WPF@;C{tSd;CFCON5IoAf`I4Z&a|36=vmlQYprEx; zaE+X4kN*!ed4OGkqr<__ye)|7b4lUKCf^nhh|+v$phpw3>#uI31TCyf!;U;4IOtv4 zX?4?c!q^*}rY*CWZrdM`yf~pxvIcDVm#0Y7q4wwqD)!hS_1mCkBm{+P%xx0DNq_J$PX8}1&Sr)=opEYCDA=xVT*p? z4!6gsTm0RhBN^#B8y$Y<#ozY!C^QV=%_E-X&<}%JU?APWL%lroJvV z=23ej_vr`98ebtYpg5(Sxx(w})jasDaTtwelT3?i)2?{vwwjnMQ8zQjj<9Guu#WL0 zzH}(CE<~Z&ISyKxlt|g0S~?Q05^MI1e`qcj_D)I%4`tS>oi2-aHlDUQZm+WoaYAO8 z`_+R{3GOdlAsIUo@U> zcHn=uWVlQ^J)R&{8>_~`4XzaY!h$pL3%Z;Ij$AtG8 zFB@HH+;2S0CQKbBPF=!lO`arOI-pHHmNEDr=x4rSK zg|@o)A1Du%Zt`$;A#}|7(HYcx&~W!F$5NwiJ5$cCv0*B9#uo*zQ4~m=9V*inbI(g$ zsLc3pK*8BFoqWtbL{t(*R=#)fwB3r>jFpb5Rf5X-3T`fXH$)jVeYl|aQZ!5HT4vgfw(;w0&G%Jykx(y8>VCjT;7pho(GAU6r|NpObVQZ-#y+QLZ(Ihv|S q-=c(g=P`q%JX9iHX{E(tsDhkwWcwu-pKE-_qwmV}JunylU;MvYnrR^b