diff --git a/README.md b/README.md index ddd4459..1f1b598 100644 --- a/README.md +++ b/README.md @@ -20,23 +20,22 @@ Der Spielstand liegt unter `user://spielstand.json` ## Spielsysteme -- 81 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos sind die Namen verschmolzener Wesen -- Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet; je Element gibt es ein 0/2-Wesen für 0 Mana +- 120 Karten in `data/karten.json`: je Element alle Werte von 0/1 bis 5/5, die ID verrät sie (`f_3_2` = Feuer 3/2), Namen aus Eigenschaft und Wesen; Kombos sind die Namen verschmolzener Wesen +- Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts) - Mana: 1 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen, Legen und Verschmelzen -- Kein Deckbau: beide ziehen vom gemeinsamen Stapel (jede Karte dreimal). Zu Beginn fünf Karten, alle im ersten Zug spielbar. Auf dem Stapel liegen drei Karten offen, nie teurer als das Mana des Zugs; beim Ziehen wählt man eine, eine neue rückt nach. Das erste Ziehen im Zug ist frei, jedes weitere kostet 2 Mana; eine Handkarte abwerfen bringt 1 Mana (Rechtsklick) +- Jeder spielt mit seinem eigenen Stapel aus 40 bis 60 Karten (Menü „Deck“: eigene Karten rein oder raus, „Auffüllen“ nimmt die teuersten des gewählten Filters). Zu Beginn fünf im ersten Zug spielbare Karten. Ziehen bringt eine Karte, die erste im Zug ist frei, dann 1, 2, 3 … Mana; ist der Stapel leer, kostet jeder Zug 1, 2, 3 … Leben (Erschöpfung); eine Handkarte abwerfen bringt 1 Mana (Rechtsklick) - Eigenschaften gibt es nur über Elementpaare (siehe Elementboni): Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug). Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink; verletzte Wesen heilen nicht von selbst - Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar und bekommt den Elementbonus erst offen; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf -- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik wie bei Yu-Gi-Oh: Common matt, Rare silberner Name und Rahmen mit Glanzstreifen, Super Rare Holo-Bild, Ultra Rare goldener Name und Rahmen mit Holo-Bild, Ultimate Rare Relief, Holo auf Bild und Rahmen, Funkeln und schillernde Farben; auch auf dem Feld für alle sichtbar, Werte bleiben gleich. Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern -- Booster (100 Münzen): fünf Karten mit gewürfelter Stufe (50/25/15/8/2 %, mindestens eine Rare), höhere werten die Karte auf, der Rest wird zu Staub (50/100/200/500/1000). Aufwerten kostet 100/200/500/1000 Staub, eine Stufe abgeben bringt den Zerlegewert zurück -- Elementboni, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); je 10 Mana an ausgespielten Karten eines Elements geben den eigenen Wesen dieses Elements +1 Angriff (verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen). Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Boni. Zwei Leisten in der Mitte, oben die des Gegners, unten die eigene, zeigen je Element zwei Symbole, gefüllt je offenem eigenen Wesen, und das Mana der ausgespielten Karten +- Beim ersten Start wählt man eines von vier Startdecks (40 Karten: 25 eines Elements bis Angriff 4, je fünf billige der anderen), der Rest kommt aus Boostern oder der Werkstatt. Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare) und ist je Stufe +1/+1 stärker, bei gleichen Kosten; die Stufe zeigt sich auch in der Optik (Glanz, Holo, Relief, Funkeln). Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern +- Booster (100 Münzen): fünf Karten mit gewürfelter Stufe (45/25/15/10/5 %, mindestens eine Rare); neue kommen in Sammlung und Stapel, eine höhere Stufe ersetzt die alte Version (die wird zu Staub), der Rest wird zu Staub (50/100/200/500/1000). Werkstatt in der Sammlung: Aufwerten kostet 100/200/500/1000 Staub, Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub +- Elementboni, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); je fünf ausgespielte Karten eines Elements geben den eigenen Wesen dieses Elements +1/+1 (verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen). Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Boni. Die Zähler stehen links neben dem jeweiligen Brett und zeigen nur Elemente, bei denen etwas passiert ist: zwei Symbole, gefüllt je offenem eigenen Wesen, und die Zahl der ausgespielten Karten - Elementvorteil beim Kampf (Wasser > Feuer > Luft > Erde > Wasser): wer im Vorteil ist, macht 1 Schaden mehr, wer im Nachteil ist, 1 weniger, beim Angriff wie beim Konter, gegen Helden nichts; so greift und kontert auch ein Wesen mit 0 Angriff, nur nicht ohne Vorteil; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht. Ist ein Angreifer gewählt, zeigen die Gegnerkarten ihren Konter gegen ihn (grün mehr, rot weniger); über einem Ziel zeigt der Angreifer seinen Angriff mit Vorteil, und wer stirbt, trägt die Marke „stirbt“ -- Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar, per Doppelklick auf das eigene Wesen direkt. Nichts schlägt durch -- Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde halten, auf dem Brett wie auf der Hand (Gamepad: Y, „Verschmelzen mit …“). Das neue Wesen hat je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten passend dazu, Elemente und Eigenschaften beider; kostet 1 Mana, verdoppelt je weiterer Verschmelzung (1, 2, 4, 8); drei oder mehr Elemente ergeben eine Chimäre +- Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar, per Doppelklick auf das eigene Wesen direkt. Nichts schlägt durch. Den Zug beendet die Leertaste oder der Rechtsklick ins Leere; Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden) +- Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde halten, auf dem Brett wie auf der Hand (Gamepad: Y, „Verschmelzen mit …“). Das neue Wesen hat je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten aus den neuen Werten, Elemente und Eigenschaften beider; es kostet die halbe Manasumme beider Karten (aufgerundet); drei oder mehr Elemente ergeben eine Chimäre - Duell mit ausgeloster Zugreihenfolge, beide beginnen mit 1 Mana; die Hand fasst 5 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) +- Kampagne: drei Kapitel mit je zehn Magiern, Bosse mit ein bis drei Merkmalen und eigenen Stapeln (Kapitel 1 bis Kosten 3, Kapitel 2 bis 5 als Rare, Kapitel 3 alles als Super Rare), eigenes Merkmal nach Kapitel 1 - 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; Ton und Musik sind standardmäßig aus @@ -53,7 +52,6 @@ godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spiel godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots DINGER_ONLY=test_sammlung godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests -DINGER_NETZ=1 godot --path . tests/ui_test.tscn # zusätzlich die langsamen Netzwerktests (Bot-Prozesse) godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Stapel, Musik godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf ``` @@ -71,13 +69,11 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe - `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json` - `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementboni, Kampf, Verschmelzen -- `scripts/deckgen.gd` – gemeinsamer Stapel und Gegner -- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk +- `scripts/deckgen.gd` – Stapel: zufällige für Gegner, Standardstapel aus der Sammlung - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) -- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Booster, Einstellungen +- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Stapel, Booster, Werkstatt, Einstellungen - `scripts/campaign.gd` – Kampagnenstufen - `scenes/main.tscn` – Startszene -- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden - `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `element_view.gd` den Elementzähler, `battle_layout.gd` die Aufteilung des Kampfes - `translations/ui.csv` – englische Oberflächentexte - `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs diff --git a/anforderungen.md b/anforderungen.md index 129c64d..f66b954 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -5,18 +5,17 @@ ## Grundidee - Kartenspiel wie zum Beispiel Magic - PC-Spiel -- Duell: Spieler gegen einen KI-Gegner (oder einen Mitspieler im Netz), beide mit gleichen Regeln +- Duell: Spieler gegen einen KI-Gegner, beide mit gleichen Regeln - Einzelspieler-Knopf führt zur Wahl der Schwierigkeit - Kampagne gegen benannte Magier als Fortschritt -- Im lokalen Netz zu zweit spielbar, weitere als Zuschauer ## Sammeln -- Jede Karte gehört jedem von Anfang an: wer zum ersten Mal spielt, kann so gut sein wie wer tausend Partien hat -- Gesammelt wird die Seltenheitsstufe einer Karte (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare), und die ist reine Optik wie bei Yu-Gi-Oh: silberner oder goldener Name, Holo-Bild, Relief, Funkeln, Schillern; auch im Netzwerkspiel sichtbar; Fusionen tragen die höhere Stufe der Eltern -- Booster öffnen (Chancen 50/25/15/8/2 %, mindestens eine Rare): höhere Stufen werten Karten auf, der Rest wird zu Staub (50/100/200/500/1000) -- Staub wertet gezielt auf (100/200/500/1000), eine Stufe abgeben bringt den Zerlegewert zurück -- Kein Deckbau: alle ziehen vom gemeinsamen Stapel (jede Karte dreimal); zu Beginn fünf Karten, alle im ersten Zug spielbar; auf dem Stapel liegen drei Karten offen, nie teurer als das Mana des Zugs, beim Ziehen wählt man eine, eine neue rückt nach; das erste Ziehen im Zug ist frei, jedes weitere kostet 2 Mana -- Mana für Ziehen, Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet; 0/2 kostet 0), und Verschmelzen, Abwerfen bringt 1; 1 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten +- Man startet mit einem von vier Startdecks (40 Karten: 25 eines Elements bis Angriff 4, je fünf billige der anderen); alle Karten sammeln ist das lange Ziel +- Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare); je Stufe +1/+1 bei gleichen Kosten, dazu Optik (Glanz, Holo, Relief, Funkeln); Fusionen tragen die höhere Stufe der Eltern +- Booster öffnen (Chancen 45/25/15/10/5 %, mindestens eine Rare): neue Karten kommen in Sammlung und Stapel, von jeder Karte zählt die seltenste Version, die alte wird zu Staub, der Rest wird zu Staub (50/100/200/500/1000) +- Werkstatt: Staub wertet gezielt auf (100/200/500/1000), Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub +- Jeder hat sein Deck: 40 bis 60 eigene Karten, im Menü „Deck“ zusammengestellt (Auffüllen folgt dem Filter); zu Beginn fünf im ersten Zug spielbare Karten daraus; Ziehen bringt eine Karte, die erste im Zug frei, dann 1, 2, 3 … Mana; leerer Stapel: jeder Zug kostet 1, 2, 3 … Leben +- Mana für Ziehen, Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet, höchstens 10; 0/2 kostet 0) und Verschmelzen (halbe Manasumme beider Karten), Abwerfen bringt 1; 1 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten - Karten haben nur Angriff und Leben; Eigenschaften kommen allein über Elementpaare: Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug) - Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink (auch im ersten Zug); verletzte Wesen heilen nicht von selbst - Wesen lassen sich verdeckt legen (mittlere Maustaste oder Kartenmenü); aufdecken ist frei, frühestens im nächsten Zug, dann greift es sofort an; ein Angriff deckt auf @@ -24,10 +23,10 @@ - Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen ## Elemente -- Jede Karte gehört zu genau einem Element +- Jede Karte gehört zu genau einem Element; je Element gibt es alle Werte von 0/1 bis 5/5 (120 Karten) - Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) - Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag) -- Je 10 Mana an ausgespielten Karten eines Elements: +1 Angriff für die eigenen Wesen des Elements, jeder Spieler zählt für sich; verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen; die Karte zeigt vorher, was sie bekäme; verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte und Boni +- Je fünf ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements, jeder Spieler zählt für sich; verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen; die Karte zeigt vorher, was sie bekäme; verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte und Boni - Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (wer im Vorteil ist, macht 1 Schaden mehr, wer im Nachteil ist, 1 weniger, beim Angriff wie beim Konter; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht) - Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde stillhalten, auf dem Brett oder auf der Hand (Gamepad: über das Kartenmenü); das neue erbt Elemente und Eigenschaften beider, je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten folgen den Werten; Fusionskosten 1 Mana, verdoppelt je weiterer Verschmelzung - Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus diff --git a/data/karten.json b/data/karten.json index 0eba2b6..8e2e762 100644 --- a/data/karten.json +++ b/data/karten.json @@ -1,9 +1,109 @@ { "karten": [ { - "id": "f_kobold", - "name": "Feuerkobold", - "name_en": "Fire Kobold", + "id": "f_0_1", + "name": "Schwelender Funke", + "name_en": "Smoldering Spark", + "elements": [ + "Feuer" + ], + "attack": 0, + "health": 1 + }, + { + "id": "f_0_2", + "name": "Schwelender Kobold", + "name_en": "Smoldering Kobold", + "elements": [ + "Feuer" + ], + "attack": 0, + "health": 2 + }, + { + "id": "f_0_3", + "name": "Schwelender Wolf", + "name_en": "Smoldering Wolf", + "elements": [ + "Feuer" + ], + "attack": 0, + "health": 3 + }, + { + "id": "f_0_4", + "name": "Schwelender Krieger", + "name_en": "Smoldering Warrior", + "elements": [ + "Feuer" + ], + "attack": 0, + "health": 4 + }, + { + "id": "f_0_5", + "name": "Schwelender Drache", + "name_en": "Smoldering Dragon", + "elements": [ + "Feuer" + ], + "attack": 0, + "health": 5 + }, + { + "id": "f_1_1", + "name": "Glimmender Funke", + "name_en": "Glowing Spark", + "elements": [ + "Feuer" + ], + "attack": 1, + "health": 1 + }, + { + "id": "f_1_2", + "name": "Glimmender Kobold", + "name_en": "Glowing Kobold", + "elements": [ + "Feuer" + ], + "attack": 1, + "health": 2 + }, + { + "id": "f_1_3", + "name": "Glimmender Wolf", + "name_en": "Glowing Wolf", + "elements": [ + "Feuer" + ], + "attack": 1, + "health": 3 + }, + { + "id": "f_1_4", + "name": "Glimmender Krieger", + "name_en": "Glowing Warrior", + "elements": [ + "Feuer" + ], + "attack": 1, + "health": 4 + }, + { + "id": "f_1_5", + "name": "Glimmender Drache", + "name_en": "Glowing Dragon", + "elements": [ + "Feuer" + ], + "attack": 1, + "health": 5 + }, + { + "id": "f_2_1", + "name": "Lodernder Funke", + "name_en": "Blazing Spark", "elements": [ "Feuer" ], @@ -11,9 +111,59 @@ "health": 1 }, { - "id": "f_wolf", - "name": "Flammenwolf", - "name_en": "Flame Wolf", + "id": "f_2_2", + "name": "Lodernder Kobold", + "name_en": "Blazing Kobold", + "elements": [ + "Feuer" + ], + "attack": 2, + "health": 2 + }, + { + "id": "f_2_3", + "name": "Lodernder Wolf", + "name_en": "Blazing Wolf", + "elements": [ + "Feuer" + ], + "attack": 2, + "health": 3 + }, + { + "id": "f_2_4", + "name": "Lodernder Krieger", + "name_en": "Blazing Warrior", + "elements": [ + "Feuer" + ], + "attack": 2, + "health": 4 + }, + { + "id": "f_2_5", + "name": "Lodernder Drache", + "name_en": "Blazing Dragon", + "elements": [ + "Feuer" + ], + "attack": 2, + "health": 5 + }, + { + "id": "f_3_1", + "name": "Brennender Funke", + "name_en": "Burning Spark", + "elements": [ + "Feuer" + ], + "attack": 3, + "health": 1 + }, + { + "id": "f_3_2", + "name": "Brennender Kobold", + "name_en": "Burning Kobold", "elements": [ "Feuer" ], @@ -21,9 +171,59 @@ "health": 2 }, { - "id": "f_asche", - "name": "Aschekrieger", - "name_en": "Ash Warrior", + "id": "f_3_3", + "name": "Brennender Wolf", + "name_en": "Burning Wolf", + "elements": [ + "Feuer" + ], + "attack": 3, + "health": 3 + }, + { + "id": "f_3_4", + "name": "Brennender Krieger", + "name_en": "Burning Warrior", + "elements": [ + "Feuer" + ], + "attack": 3, + "health": 4 + }, + { + "id": "f_3_5", + "name": "Brennender Drache", + "name_en": "Burning Dragon", + "elements": [ + "Feuer" + ], + "attack": 3, + "health": 5 + }, + { + "id": "f_4_1", + "name": "Flammender Funke", + "name_en": "Flaming Spark", + "elements": [ + "Feuer" + ], + "attack": 4, + "health": 1 + }, + { + "id": "f_4_2", + "name": "Flammender Kobold", + "name_en": "Flaming Kobold", + "elements": [ + "Feuer" + ], + "attack": 4, + "health": 2 + }, + { + "id": "f_4_3", + "name": "Flammender Wolf", + "name_en": "Flaming Wolf", "elements": [ "Feuer" ], @@ -31,19 +231,189 @@ "health": 3 }, { - "id": "f_drache", - "name": "Feuerdrache", - "name_en": "Fire Dragon", + "id": "f_4_4", + "name": "Flammender Krieger", + "name_en": "Flaming Warrior", "elements": [ "Feuer" ], - "attack": 6, + "attack": 4, + "health": 4 + }, + { + "id": "f_4_5", + "name": "Flammender Drache", + "name_en": "Flaming Dragon", + "elements": [ + "Feuer" + ], + "attack": 4, "health": 5 }, { - "id": "w_geist", - "name": "Flussgeist", - "name_en": "River Spirit", + "id": "f_5_1", + "name": "Infernalischer Funke", + "name_en": "Infernal Spark", + "elements": [ + "Feuer" + ], + "attack": 5, + "health": 1 + }, + { + "id": "f_5_2", + "name": "Infernalischer Kobold", + "name_en": "Infernal Kobold", + "elements": [ + "Feuer" + ], + "attack": 5, + "health": 2 + }, + { + "id": "f_5_3", + "name": "Infernalischer Wolf", + "name_en": "Infernal Wolf", + "elements": [ + "Feuer" + ], + "attack": 5, + "health": 3 + }, + { + "id": "f_5_4", + "name": "Infernalischer Krieger", + "name_en": "Infernal Warrior", + "elements": [ + "Feuer" + ], + "attack": 5, + "health": 4 + }, + { + "id": "f_5_5", + "name": "Infernalischer Drache", + "name_en": "Infernal Dragon", + "elements": [ + "Feuer" + ], + "attack": 5, + "health": 5 + }, + { + "id": "w_0_1", + "name": "Stiller Tropfen", + "name_en": "Still Drop", + "elements": [ + "Wasser" + ], + "attack": 0, + "health": 1 + }, + { + "id": "w_0_2", + "name": "Stiller Geist", + "name_en": "Still Spirit", + "elements": [ + "Wasser" + ], + "attack": 0, + "health": 2 + }, + { + "id": "w_0_3", + "name": "Stiller Reiter", + "name_en": "Still Rider", + "elements": [ + "Wasser" + ], + "attack": 0, + "health": 3 + }, + { + "id": "w_0_4", + "name": "Stiller Wächter", + "name_en": "Still Warden", + "elements": [ + "Wasser" + ], + "attack": 0, + "health": 4 + }, + { + "id": "w_0_5", + "name": "Stiller Leviathan", + "name_en": "Still Leviathan", + "elements": [ + "Wasser" + ], + "attack": 0, + "health": 5 + }, + { + "id": "w_1_1", + "name": "Rieselnder Tropfen", + "name_en": "Trickling Drop", + "elements": [ + "Wasser" + ], + "attack": 1, + "health": 1 + }, + { + "id": "w_1_2", + "name": "Rieselnder Geist", + "name_en": "Trickling Spirit", + "elements": [ + "Wasser" + ], + "attack": 1, + "health": 2 + }, + { + "id": "w_1_3", + "name": "Rieselnder Reiter", + "name_en": "Trickling Rider", + "elements": [ + "Wasser" + ], + "attack": 1, + "health": 3 + }, + { + "id": "w_1_4", + "name": "Rieselnder Wächter", + "name_en": "Trickling Warden", + "elements": [ + "Wasser" + ], + "attack": 1, + "health": 4 + }, + { + "id": "w_1_5", + "name": "Rieselnder Leviathan", + "name_en": "Trickling Leviathan", + "elements": [ + "Wasser" + ], + "attack": 1, + "health": 5 + }, + { + "id": "w_2_1", + "name": "Strömender Tropfen", + "name_en": "Streaming Drop", + "elements": [ + "Wasser" + ], + "attack": 2, + "health": 1 + }, + { + "id": "w_2_2", + "name": "Strömender Geist", + "name_en": "Streaming Spirit", "elements": [ "Wasser" ], @@ -51,9 +421,59 @@ "health": 2 }, { - "id": "w_reiter", - "name": "Wellenreiter", - "name_en": "Wave Rider", + "id": "w_2_3", + "name": "Strömender Reiter", + "name_en": "Streaming Rider", + "elements": [ + "Wasser" + ], + "attack": 2, + "health": 3 + }, + { + "id": "w_2_4", + "name": "Strömender Wächter", + "name_en": "Streaming Warden", + "elements": [ + "Wasser" + ], + "attack": 2, + "health": 4 + }, + { + "id": "w_2_5", + "name": "Strömender Leviathan", + "name_en": "Streaming Leviathan", + "elements": [ + "Wasser" + ], + "attack": 2, + "health": 5 + }, + { + "id": "w_3_1", + "name": "Tosender Tropfen", + "name_en": "Roaring Drop", + "elements": [ + "Wasser" + ], + "attack": 3, + "health": 1 + }, + { + "id": "w_3_2", + "name": "Tosender Geist", + "name_en": "Roaring Spirit", + "elements": [ + "Wasser" + ], + "attack": 3, + "health": 2 + }, + { + "id": "w_3_3", + "name": "Tosender Reiter", + "name_en": "Roaring Rider", "elements": [ "Wasser" ], @@ -61,9 +481,59 @@ "health": 3 }, { - "id": "w_frost", - "name": "Frostmagier", - "name_en": "Frost Mage", + "id": "w_3_4", + "name": "Tosender Wächter", + "name_en": "Roaring Warden", + "elements": [ + "Wasser" + ], + "attack": 3, + "health": 4 + }, + { + "id": "w_3_5", + "name": "Tosender Leviathan", + "name_en": "Roaring Leviathan", + "elements": [ + "Wasser" + ], + "attack": 3, + "health": 5 + }, + { + "id": "w_4_1", + "name": "Flutender Tropfen", + "name_en": "Flooding Drop", + "elements": [ + "Wasser" + ], + "attack": 4, + "health": 1 + }, + { + "id": "w_4_2", + "name": "Flutender Geist", + "name_en": "Flooding Spirit", + "elements": [ + "Wasser" + ], + "attack": 4, + "health": 2 + }, + { + "id": "w_4_3", + "name": "Flutender Reiter", + "name_en": "Flooding Rider", + "elements": [ + "Wasser" + ], + "attack": 4, + "health": 3 + }, + { + "id": "w_4_4", + "name": "Flutender Wächter", + "name_en": "Flooding Warden", "elements": [ "Wasser" ], @@ -71,19 +541,99 @@ "health": 4 }, { - "id": "w_kraken", - "name": "Kraken", - "name_en": "Kraken", + "id": "w_4_5", + "name": "Flutender Leviathan", + "name_en": "Flooding Leviathan", "elements": [ "Wasser" ], - "attack": 6, - "health": 6 + "attack": 4, + "health": 5 }, { - "id": "e_wall", - "name": "Steinwall", - "name_en": "Stone Wall", + "id": "w_5_1", + "name": "Abgründiger Tropfen", + "name_en": "Abyssal Drop", + "elements": [ + "Wasser" + ], + "attack": 5, + "health": 1 + }, + { + "id": "w_5_2", + "name": "Abgründiger Geist", + "name_en": "Abyssal Spirit", + "elements": [ + "Wasser" + ], + "attack": 5, + "health": 2 + }, + { + "id": "w_5_3", + "name": "Abgründiger Reiter", + "name_en": "Abyssal Rider", + "elements": [ + "Wasser" + ], + "attack": 5, + "health": 3 + }, + { + "id": "w_5_4", + "name": "Abgründiger Wächter", + "name_en": "Abyssal Warden", + "elements": [ + "Wasser" + ], + "attack": 5, + "health": 4 + }, + { + "id": "w_5_5", + "name": "Abgründiger Leviathan", + "name_en": "Abyssal Leviathan", + "elements": [ + "Wasser" + ], + "attack": 5, + "health": 5 + }, + { + "id": "e_0_1", + "name": "Ruhender Kiesel", + "name_en": "Resting Pebble", + "elements": [ + "Erde" + ], + "attack": 0, + "health": 1 + }, + { + "id": "e_0_2", + "name": "Ruhender Golem", + "name_en": "Resting Golem", + "elements": [ + "Erde" + ], + "attack": 0, + "health": 2 + }, + { + "id": "e_0_3", + "name": "Ruhender Troll", + "name_en": "Resting Troll", + "elements": [ + "Erde" + ], + "attack": 0, + "health": 3 + }, + { + "id": "e_0_4", + "name": "Ruhender Hüter", + "name_en": "Resting Keeper", "elements": [ "Erde" ], @@ -91,9 +641,99 @@ "health": 4 }, { - "id": "e_wurzel", - "name": "Wurzelwächter", - "name_en": "Root Guardian", + "id": "e_0_5", + "name": "Ruhender Titan", + "name_en": "Resting Titan", + "elements": [ + "Erde" + ], + "attack": 0, + "health": 5 + }, + { + "id": "e_1_1", + "name": "Fester Kiesel", + "name_en": "Firm Pebble", + "elements": [ + "Erde" + ], + "attack": 1, + "health": 1 + }, + { + "id": "e_1_2", + "name": "Fester Golem", + "name_en": "Firm Golem", + "elements": [ + "Erde" + ], + "attack": 1, + "health": 2 + }, + { + "id": "e_1_3", + "name": "Fester Troll", + "name_en": "Firm Troll", + "elements": [ + "Erde" + ], + "attack": 1, + "health": 3 + }, + { + "id": "e_1_4", + "name": "Fester Hüter", + "name_en": "Firm Keeper", + "elements": [ + "Erde" + ], + "attack": 1, + "health": 4 + }, + { + "id": "e_1_5", + "name": "Fester Titan", + "name_en": "Firm Titan", + "elements": [ + "Erde" + ], + "attack": 1, + "health": 5 + }, + { + "id": "e_2_1", + "name": "Harter Kiesel", + "name_en": "Hard Pebble", + "elements": [ + "Erde" + ], + "attack": 2, + "health": 1 + }, + { + "id": "e_2_2", + "name": "Harter Golem", + "name_en": "Hard Golem", + "elements": [ + "Erde" + ], + "attack": 2, + "health": 2 + }, + { + "id": "e_2_3", + "name": "Harter Troll", + "name_en": "Hard Troll", + "elements": [ + "Erde" + ], + "attack": 2, + "health": 3 + }, + { + "id": "e_2_4", + "name": "Harter Hüter", + "name_en": "Hard Keeper", "elements": [ "Erde" ], @@ -101,9 +741,59 @@ "health": 4 }, { - "id": "e_golem", - "name": "Felsgolem", - "name_en": "Rock Golem", + "id": "e_2_5", + "name": "Harter Titan", + "name_en": "Hard Titan", + "elements": [ + "Erde" + ], + "attack": 2, + "health": 5 + }, + { + "id": "e_3_1", + "name": "Wuchtiger Kiesel", + "name_en": "Massive Pebble", + "elements": [ + "Erde" + ], + "attack": 3, + "health": 1 + }, + { + "id": "e_3_2", + "name": "Wuchtiger Golem", + "name_en": "Massive Golem", + "elements": [ + "Erde" + ], + "attack": 3, + "health": 2 + }, + { + "id": "e_3_3", + "name": "Wuchtiger Troll", + "name_en": "Massive Troll", + "elements": [ + "Erde" + ], + "attack": 3, + "health": 3 + }, + { + "id": "e_3_4", + "name": "Wuchtiger Hüter", + "name_en": "Massive Keeper", + "elements": [ + "Erde" + ], + "attack": 3, + "health": 4 + }, + { + "id": "e_3_5", + "name": "Wuchtiger Titan", + "name_en": "Massive Titan", "elements": [ "Erde" ], @@ -111,29 +801,159 @@ "health": 5 }, { - "id": "e_troll", - "name": "Bergtroll", - "name_en": "Mountain Troll", + "id": "e_4_1", + "name": "Berstender Kiesel", + "name_en": "Bursting Pebble", "elements": [ "Erde" ], - "attack": 3, - "health": 6 + "attack": 4, + "health": 1 }, { - "id": "e_baum", - "name": "Uralter Baum", - "name_en": "Ancient Tree", + "id": "e_4_2", + "name": "Berstender Golem", + "name_en": "Bursting Golem", "elements": [ "Erde" ], - "attack": 3, - "health": 9 + "attack": 4, + "health": 2 }, { - "id": "l_wind", - "name": "Windgeist", - "name_en": "Wind Spirit", + "id": "e_4_3", + "name": "Berstender Troll", + "name_en": "Bursting Troll", + "elements": [ + "Erde" + ], + "attack": 4, + "health": 3 + }, + { + "id": "e_4_4", + "name": "Berstender Hüter", + "name_en": "Bursting Keeper", + "elements": [ + "Erde" + ], + "attack": 4, + "health": 4 + }, + { + "id": "e_4_5", + "name": "Berstender Titan", + "name_en": "Bursting Titan", + "elements": [ + "Erde" + ], + "attack": 4, + "health": 5 + }, + { + "id": "e_5_1", + "name": "Gewaltiger Kiesel", + "name_en": "Mighty Pebble", + "elements": [ + "Erde" + ], + "attack": 5, + "health": 1 + }, + { + "id": "e_5_2", + "name": "Gewaltiger Golem", + "name_en": "Mighty Golem", + "elements": [ + "Erde" + ], + "attack": 5, + "health": 2 + }, + { + "id": "e_5_3", + "name": "Gewaltiger Troll", + "name_en": "Mighty Troll", + "elements": [ + "Erde" + ], + "attack": 5, + "health": 3 + }, + { + "id": "e_5_4", + "name": "Gewaltiger Hüter", + "name_en": "Mighty Keeper", + "elements": [ + "Erde" + ], + "attack": 5, + "health": 4 + }, + { + "id": "e_5_5", + "name": "Gewaltiger Titan", + "name_en": "Mighty Titan", + "elements": [ + "Erde" + ], + "attack": 5, + "health": 5 + }, + { + "id": "l_0_1", + "name": "Leiser Hauch", + "name_en": "Quiet Breeze", + "elements": [ + "Luft" + ], + "attack": 0, + "health": 1 + }, + { + "id": "l_0_2", + "name": "Leiser Bote", + "name_en": "Quiet Herald", + "elements": [ + "Luft" + ], + "attack": 0, + "health": 2 + }, + { + "id": "l_0_3", + "name": "Leiser Falke", + "name_en": "Quiet Falcon", + "elements": [ + "Luft" + ], + "attack": 0, + "health": 3 + }, + { + "id": "l_0_4", + "name": "Leiser Sturmgeist", + "name_en": "Quiet Storm Spirit", + "elements": [ + "Luft" + ], + "attack": 0, + "health": 4 + }, + { + "id": "l_0_5", + "name": "Leiser Greif", + "name_en": "Quiet Griffin", + "elements": [ + "Luft" + ], + "attack": 0, + "health": 5 + }, + { + "id": "l_1_1", + "name": "Flüchtiger Hauch", + "name_en": "Fleeting Breeze", "elements": [ "Luft" ], @@ -141,9 +961,99 @@ "health": 1 }, { - "id": "l_falke", - "name": "Falke", - "name_en": "Falcon", + "id": "l_1_2", + "name": "Flüchtiger Bote", + "name_en": "Fleeting Herald", + "elements": [ + "Luft" + ], + "attack": 1, + "health": 2 + }, + { + "id": "l_1_3", + "name": "Flüchtiger Falke", + "name_en": "Fleeting Falcon", + "elements": [ + "Luft" + ], + "attack": 1, + "health": 3 + }, + { + "id": "l_1_4", + "name": "Flüchtiger Sturmgeist", + "name_en": "Fleeting Storm Spirit", + "elements": [ + "Luft" + ], + "attack": 1, + "health": 4 + }, + { + "id": "l_1_5", + "name": "Flüchtiger Greif", + "name_en": "Fleeting Griffin", + "elements": [ + "Luft" + ], + "attack": 1, + "health": 5 + }, + { + "id": "l_2_1", + "name": "Rascher Hauch", + "name_en": "Swift Breeze", + "elements": [ + "Luft" + ], + "attack": 2, + "health": 1 + }, + { + "id": "l_2_2", + "name": "Rascher Bote", + "name_en": "Swift Herald", + "elements": [ + "Luft" + ], + "attack": 2, + "health": 2 + }, + { + "id": "l_2_3", + "name": "Rascher Falke", + "name_en": "Swift Falcon", + "elements": [ + "Luft" + ], + "attack": 2, + "health": 3 + }, + { + "id": "l_2_4", + "name": "Rascher Sturmgeist", + "name_en": "Swift Storm Spirit", + "elements": [ + "Luft" + ], + "attack": 2, + "health": 4 + }, + { + "id": "l_2_5", + "name": "Rascher Greif", + "name_en": "Swift Griffin", + "elements": [ + "Luft" + ], + "attack": 2, + "health": 5 + }, + { + "id": "l_3_1", + "name": "Wirbelnder Hauch", + "name_en": "Whirling Breeze", "elements": [ "Luft" ], @@ -151,9 +1061,19 @@ "health": 1 }, { - "id": "l_taenzer", - "name": "Sturmtänzer", - "name_en": "Storm Dancer", + "id": "l_3_2", + "name": "Wirbelnder Bote", + "name_en": "Whirling Herald", + "elements": [ + "Luft" + ], + "attack": 3, + "health": 2 + }, + { + "id": "l_3_3", + "name": "Wirbelnder Falke", + "name_en": "Whirling Falcon", "elements": [ "Luft" ], @@ -161,529 +1081,99 @@ "health": 3 }, { - "id": "l_schlange", - "name": "Luftschlange", - "name_en": "Air Serpent", + "id": "l_3_4", + "name": "Wirbelnder Sturmgeist", + "name_en": "Whirling Storm Spirit", "elements": [ "Luft" ], - "attack": 2, - "health": 2 - }, - { - "id": "l_greif", - "name": "Greif", - "name_en": "Griffin", - "elements": [ - "Luft" - ], - "attack": 5, + "attack": 3, "health": 4 }, { - "id": "ff_elementar", - "name": "Inferno-Elementar", - "name_en": "Inferno Elemental", + "id": "l_3_5", + "name": "Wirbelnder Greif", + "name_en": "Whirling Griffin", "elements": [ - "Feuer" + "Luft" ], - "attack": 7, + "attack": 3, "health": 5 }, { - "id": "ff_phoenix", - "name": "Phönix", - "name_en": "Phoenix", - "elements": [ - "Feuer" - ], - "attack": 8, - "health": 6 - }, - { - "id": "ww_schrecken", - "name": "Tiefseeschrecken", - "name_en": "Deep Sea Terror", - "elements": [ - "Wasser" - ], - "attack": 7, - "health": 7 - }, - { - "id": "ww_leviathan", - "name": "Leviathan", - "name_en": "Leviathan", - "elements": [ - "Wasser" - ], - "attack": 8, - "health": 8 - }, - { - "id": "ee_bastion", - "name": "Felsbastion", - "name_en": "Rock Bastion", - "elements": [ - "Erde" - ], - "attack": 1, - "health": 7 - }, - { - "id": "ee_riese", - "name": "Bergriese", - "name_en": "Mountain Giant", - "elements": [ - "Erde" - ], - "attack": 6, - "health": 9 - }, - { - "id": "ee_titan", - "name": "Titan der Erde", - "name_en": "Titan of Earth", - "elements": [ - "Erde" - ], - "attack": 8, - "health": 12 - }, - { - "id": "ll_adler", - "name": "Sturmadler", - "name_en": "Storm Eagle", + "id": "l_4_1", + "name": "Stürmischer Hauch", + "name_en": "Stormy Breeze", "elements": [ "Luft" ], "attack": 4, - "health": 4 + "health": 1 }, { - "id": "ll_herr", - "name": "Herr der Winde", - "name_en": "Lord of Winds", + "id": "l_4_2", + "name": "Stürmischer Bote", + "name_en": "Stormy Herald", "elements": [ "Luft" ], - "attack": 6, - "health": 6 - }, - { - "id": "fw_kessel", - "name": "Kesselgeist", - "name_en": "Cauldron Spirit", - "elements": [ - "Feuer" - ], - "attack": 3, - "health": 3 - }, - { - "id": "fw_geysir", - "name": "Geysir-Drache", - "name_en": "Geyser Dragon", - "elements": [ - "Wasser" - ], - "attack": 5, - "health": 6 - }, - { - "id": "fe_golem", - "name": "Lavagolem", - "name_en": "Lava Golem", - "elements": [ - "Feuer" - ], - "attack": 3, - "health": 3 - }, - { - "id": "fe_wall", - "name": "Magmawall", - "name_en": "Magma Wall", - "elements": [ - "Erde" - ], - "attack": 2, - "health": 7 - }, - { - "id": "fe_vulkan", - "name": "Vulkanherz", - "name_en": "Volcano Heart", - "elements": [ - "Feuer" - ], - "attack": 6, - "health": 8 - }, - { - "id": "fl_vogel", - "name": "Donnervogel", - "name_en": "Thunderbird", - "elements": [ - "Luft" - ], - "attack": 3, - "health": 3 - }, - { - "id": "fl_gott", - "name": "Gewittergott", - "name_en": "Thunder God", - "elements": [ - "Feuer" - ], - "attack": 7, - "health": 6 - }, - { - "id": "we_kroete", - "name": "Sumpfkröte", - "name_en": "Swamp Toad", - "elements": [ - "Wasser" - ], - "attack": 1, - "health": 5 - }, - { - "id": "we_moor", - "name": "Moorwächter", - "name_en": "Bog Guardian", - "elements": [ - "Erde" - ], "attack": 4, - "health": 8 - }, - { - "id": "we_koloss", - "name": "Sumpfkoloss", - "name_en": "Swamp Colossus", - "elements": [ - "Erde" - ], - "attack": 5, - "health": 14 - }, - { - "id": "wl_nebel", - "name": "Nebelreiter", - "name_en": "Mist Rider", - "elements": [ - "Wasser" - ], - "attack": 4, - "health": 4 - }, - { - "id": "wl_gischt", - "name": "Gischtläufer", - "name_en": "Spray Runner", - "elements": [ - "Wasser" - ], - "attack": 3, "health": 2 }, { - "id": "wl_koenigin", - "name": "Sturmkönigin", - "name_en": "Storm Queen", + "id": "l_4_3", + "name": "Stürmischer Falke", + "name_en": "Stormy Falcon", "elements": [ "Luft" ], - "attack": 6, - "health": 6 - }, - { - "id": "el_laeufer", - "name": "Sandläufer", - "name_en": "Sand Runner", - "elements": [ - "Luft" - ], - "attack": 2, - "health": 2 - }, - { - "id": "el_duene", - "name": "Dünenwächter", - "name_en": "Dune Guardian", - "elements": [ - "Erde" - ], - "attack": 3, - "health": 4 - }, - { - "id": "el_koenig", - "name": "Wüstenkönig", - "name_en": "Desert King", - "elements": [ - "Erde" - ], - "attack": 5, - "health": 7 - }, - { - "id": "f_elementar", - "name": "Feuerelementar", - "name_en": "Fire Elemental", - "elements": [ - "Feuer" - ], - "attack": 5, - "health": 4 - }, - { - "id": "f_ritter", - "name": "Lodernder Ritter", - "name_en": "Blazing Knight", - "elements": [ - "Feuer" - ], "attack": 4, "health": 3 }, { - "id": "w_qualle", - "name": "Quallenschwarm", - "name_en": "Jellyfish Swarm", - "elements": [ - "Wasser" - ], - "attack": 2, - "health": 3 - }, - { - "id": "w_tiefen", - "name": "Tiefenwächter", - "name_en": "Deep Guardian", - "elements": [ - "Wasser" - ], - "attack": 4, - "health": 6 - }, - { - "id": "e_kiesel", - "name": "Kieselgolem", - "name_en": "Pebble Golem", - "elements": [ - "Erde" - ], - "attack": 2, - "health": 3 - }, - { - "id": "e_hirte", - "name": "Baumhirte", - "name_en": "Tree Shepherd", - "elements": [ - "Erde" - ], - "attack": 2, - "health": 6 - }, - { - "id": "l_fuchs", - "name": "Wolkenfuchs", - "name_en": "Cloud Fox", + "id": "l_4_4", + "name": "Stürmischer Sturmgeist", + "name_en": "Stormy Storm Spirit", "elements": [ "Luft" ], - "attack": 2, - "health": 2 - }, - { - "id": "l_laeufer", - "name": "Windläufer", - "name_en": "Wind Runner", - "elements": [ - "Luft" - ], - "attack": 3, - "health": 4 - }, - { - "id": "ff_glut", - "name": "Glutherz", - "name_en": "Ember Heart", - "elements": [ - "Feuer" - ], - "attack": 3, - "health": 3 - }, - { - "id": "ww_abyss", - "name": "Abyssalwächter", - "name_en": "Abyssal Guardian", - "elements": [ - "Wasser" - ], - "attack": 3, - "health": 8 - }, - { - "id": "ee_faust", - "name": "Felsenfaust", - "name_en": "Rock Fist", - "elements": [ - "Erde" - ], - "attack": 5, - "health": 6 - }, - { - "id": "ll_zwilling", - "name": "Zwillingswind", - "name_en": "Twin Wind", - "elements": [ - "Luft" - ], - "attack": 2, - "health": 3 - }, - { - "id": "fw_nebel", - "name": "Nebelgeist", - "name_en": "Mist Spirit", - "elements": [ - "Wasser" - ], - "attack": 2, - "health": 4 - }, - { - "id": "fe_schlange", - "name": "Magmaschlange", - "name_en": "Magma Serpent", - "elements": [ - "Feuer" - ], - "attack": 3, - "health": 4 - }, - { - "id": "fl_bote", - "name": "Sturmbote", - "name_en": "Storm Herald", - "elements": [ - "Luft" - ], - "attack": 3, - "health": 3 - }, - { - "id": "we_moorkroete", - "name": "Moorkröte", - "name_en": "Bog Toad", - "elements": [ - "Wasser" - ], - "attack": 2, - "health": 6 - }, - { - "id": "wl_boen", - "name": "Böenreiter", - "name_en": "Gale Rider", - "elements": [ - "Luft" - ], - "attack": 3, - "health": 4 - }, - { - "id": "el_duenen", - "name": "Dünenläufer", - "name_en": "Dune Runner", - "elements": [ - "Luft" - ], - "attack": 3, - "health": 3 - }, - { - "id": "f_funkenwerfer", - "name": "Funkenwerfer", - "name_en": "Spark Thrower", - "elements": [ - "Feuer" - ], - "attack": 2, - "health": 3 - }, - { - "id": "f_zunge", - "name": "Flammenzunge", - "name_en": "Flame Tongue", - "elements": [ - "Feuer" - ], - "attack": 6, - "health": 5 - }, - { - "id": "w_schlange", - "name": "Flussschlange", - "name_en": "River Serpent", - "elements": [ - "Wasser" - ], "attack": 4, "health": 4 }, { - "id": "w_taucher", - "name": "Perlentaucher", - "name_en": "Pearl Diver", + "id": "l_4_5", + "name": "Stürmischer Greif", + "name_en": "Stormy Griffin", "elements": [ - "Wasser" + "Luft" ], "attack": 4, "health": 5 }, { - "id": "e_lehm", - "name": "Lehmkrieger", - "name_en": "Clay Warrior", - "elements": [ - "Erde" - ], - "attack": 3, - "health": 2 - }, - { - "id": "e_hueter", - "name": "Waldhüter", - "name_en": "Forest Warden", - "elements": [ - "Erde" - ], - "attack": 4, - "health": 7 - }, - { - "id": "l_bote", - "name": "Windbote", - "name_en": "Wind Herald", + "id": "l_5_1", + "name": "Orkanartiger Hauch", + "name_en": "Gale Breeze", "elements": [ "Luft" ], - "attack": 1, + "attack": 5, + "health": 1 + }, + { + "id": "l_5_2", + "name": "Orkanartiger Bote", + "name_en": "Gale Herald", + "elements": [ + "Luft" + ], + "attack": 5, "health": 2 }, { - "id": "l_jaeger", - "name": "Himmelsjäger", - "name_en": "Sky Hunter", + "id": "l_5_3", + "name": "Orkanartiger Falke", + "name_en": "Gale Falcon", "elements": [ "Luft" ], @@ -691,124 +1181,24 @@ "health": 3 }, { - "id": "fff_sonne", - "name": "Kern der Sonne", - "name_en": "Core of the Sun", - "elements": [ - "Feuer" - ], - "attack": 9, - "health": 7 - }, - { - "id": "www_ozean", - "name": "Herz des Ozeans", - "name_en": "Heart of the Ocean", - "elements": [ - "Wasser" - ], - "attack": 7, - "health": 10 - }, - { - "id": "eee_gebirge", - "name": "Seele des Gebirges", - "name_en": "Soul of the Mountains", - "elements": [ - "Erde" - ], - "attack": 10, - "health": 15 - }, - { - "id": "lll_himmel", - "name": "Atem des Himmels", - "name_en": "Breath of the Sky", + "id": "l_5_4", + "name": "Orkanartiger Sturmgeist", + "name_en": "Gale Storm Spirit", "elements": [ "Luft" ], - "attack": 7, - "health": 7 - }, - { - "id": "fe_aschegolem", - "name": "Aschegolem", - "name_en": "Ash Golem", - "elements": [ - "Feuer" - ], - "attack": 4, - "health": 7 - }, - { - "id": "fl_sturmfalke", - "name": "Sturmfalke", - "name_en": "Storm Falcon", - "elements": [ - "Luft" - ], - "attack": 4, - "health": 4 - }, - { - "id": "we_schlammriese", - "name": "Schlammriese", - "name_en": "Mud Giant", - "elements": [ - "Erde" - ], - "attack": 4, - "health": 9 - }, - { - "id": "wl_sturmgeist", - "name": "Sturmgeist", - "name_en": "Storm Spirit", - "elements": [ - "Wasser" - ], "attack": 5, "health": 4 }, { - "id": "f_funke", - "name": "Glutfunke", - "name_en": "Ember Spark", - "elements": [ - "Feuer" - ], - "attack": 0, - "health": 2 - }, - { - "id": "w_tropfen", - "name": "Tautropfen", - "name_en": "Dewdrop", - "elements": [ - "Wasser" - ], - "attack": 0, - "health": 2 - }, - { - "id": "e_kruemel", - "name": "Erdkrümel", - "name_en": "Soil Crumb", - "elements": [ - "Erde" - ], - "attack": 0, - "health": 2 - }, - { - "id": "l_hauch", - "name": "Windhauch", - "name_en": "Breeze", + "id": "l_5_5", + "name": "Orkanartiger Greif", + "name_en": "Gale Griffin", "elements": [ "Luft" ], - "attack": 0, - "health": 2 + "attack": 5, + "health": 5 } ] } diff --git a/design.md b/design.md index 3963474..82c1515 100644 --- a/design.md +++ b/design.md @@ -6,7 +6,7 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw - **Hearthstone** – Kosten und Werte springen aus dem Kartenrahmen (Kristall oben links, Angriff und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt - kurz. Auf dem Brett: eigene Seite unten, Gegner oben, links Kästen und Stapel, rechts oben Knöpfe, Wesen + kurz. Auf dem Brett: eigene Seite unten, Gegner oben, links Elementzähler und Meldungen, rechts Stapel und Kästen beider Seiten, Wesen als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt. - **Teamfight Tactics** – Synergien durch gleiche Elemente: zwei eigene geben den Effekt des Elements, drei auf dem Tisch den Wertebonus; die Elementleiste zeigt den Stand. @@ -41,14 +41,14 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben: - **Hand** als bis zu fünf Karten in Feldgröße unten, in einem leichten Bogen, die des Gegners gleich groß als Rückseiten oben; der - Stapel links auf halber Höhe als drei offene Karten übereinander (Klick zieht die Karte, das erste - Ziehen im Zug frei), darüber der Gegnerkasten, darunter der eigene. + eigener Stapel rechts unten als Kartenstoß mit den Kosten des nächsten Ziehens (das erste im Zug + frei), darüber der eigene Kasten; der Stapel des Gegners rechts oben, sein Kasten darunter. - **Verschmelzen** per Ziehen: eine Hand- oder Brettkarte auf eine passende ziehen, ein goldener Ring füllt sich eine Sekunde lang, dann verschmelzen die beiden. Mit Gamepad über das Y-Menü („Verschmelzen mit …“, dann A auf dem Partner). - **Eigenes Brett** mit fünf sichtbaren Plätzen; links daneben die eigene Leiste (Leben, Mana, Einkommen, Zug), rechts „Rückgängig“, nur wenn es gerade möglich ist. -- **Elementleiste** je Spieler, die des Gegners darüber. +- **Elementzähler** je Spieler als Liste links neben seinem Brett, nur Elemente mit Wesen oder gespielten Karten. Je Element zwei Symbole in seiner Form, gefüllt je offenem eigenen Wesen (beide voll: Sondereffekt), daneben das Mana der ausgespielten Karten; je 10 gibt es +1 Angriff. - **Mittlere Maustaste** auf eine Handkarte legt sie verdeckt. **Kartenmenü** per Rechtsklick @@ -59,7 +59,8 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben: macht Ziele, Texte und Balance eindeutig. - **Vorschau** steht fest rechts auf halber Höhe, darunter die Zusatzzeile, mit Resonanz, Feld und Feldkombo darunter. -- **Verlauf**, **Aufgeben** und **Zug beenden** als Knöpfe oben rechts; der Verlauf öffnet als Überlagerung. +- **Optionen** per Esc als Überlagerung: Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden; der Verlauf + öffnet darüber. Zug beenden per Leertaste oder Rechtsklick ins Leere, oben rechts nur Rückgängig. - Handkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen steht der Elementkonter (+1/−1) am Ziel, bei Verdeckten nicht. @@ -75,14 +76,47 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben: ## Menüs -- Hauptmenü: Titel und drei Spielarten links, darunter Booster, Sammlung, Einstellungen, +- Hauptmenü: Titel, Einzelspieler, Kampagne und Deck links, darunter Booster, Sammlung, Einstellungen, Beenden; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und Staub oben rechts. - Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter. - Kampagne als Pfad mit zehn Stationen je Kapitel, die nächste pulsiert; darunter die gewählte Stufe mit Gegner, Leben, Belohnung und Merkmalen. Die Bosse werden über Leben und Merkmale stärker, nicht über Begleiter. -- Sammlung: sechs Spalten, aufgewertete Karten zuerst, rechts die große Vorschau. +- Sammlung: sechs Spalten, eigene Karten zuerst (höchste Stufen vorn), fehlende abgedunkelt, rechts + die große Vorschau mit Aufwerten, Zerlegen oder Herstellen. Deck: gleiches Raster, nur eigene Karten, + Stapelkarten hell, die anderen abgedunkelt; Klick wechselt. + +## Grafik + +Ziel +- Wie ein normales Godot/Unity-Spiel, nicht primitiv +- Keine Kunstwerke + +Stil +- Dunkler Tisch, Elemente leuchten +- Rahmen wie Hearthstone, Glanz wie Pokémon TCG +- Eine Palette, ein Font, eine Formensprache +- Palette aus style.gd erweitern, nicht ersetzen +- Font DejaVu Sans + +Regeln +- Zahlen groß, Kontrast hoch +- Zustände sichtbar: Hover, Pressed, Disabled, Fokus +- Icons als SVG, ein Strichstil + +Verbote +- Flache graue Panels +- Lila Verläufe +- Effekte ohne Zweck + +Entschieden +- Richtung Stein: warm, Holz und Bronze, Bevel-Rahmen, Vignette +- Werte und Kristall bleiben im Kartenrahmen +- Rundungen und Kanten glatt, keine Treppchen + +Offen +- Radius, Ränder, Schatten im Spiel nachziehen ## Offen diff --git a/scripts/ai.gd b/scripts/ai.gd index 24f111c..45a3a94 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -201,7 +201,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary: best = firsts[i].action if best.type == "end": if game.can_draw(p)[0]: - return {"type": "draw", "index": pick_index(game, p)} # Mana übrig und Platz auf der Hand: lieber ziehen als passen + return {"type": "draw"} # Mana übrig und Platz auf der Hand: lieber ziehen als passen return best return map_action(game, view, best) @@ -258,24 +258,10 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: if t != null: return {"type": "attack", "creature": cr, "target": t} if game.can_draw(p)[0]: - return {"type": "draw", "index": pick_index(game, p)} + return {"type": "draw"} return {"type": "end"} -## Welche der offenen Karten: die wertvollste, die nach dem Ziehen noch bezahlbar wäre, sonst die billigste. -static func pick_index(game: Game, p: Game.Player) -> int: - var left: int = p.energy - game.draw_cost(p) - var best := 0 - var best_score := -INF - for i in p.offer.size(): - var c: Cards.Card = p.offer[i] - var score := float(c.attack + c.health) if c.cost <= left else -10.0 - c.cost - if score > best_score: - best_score = score - best = i - return best - - ## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen; ## der Held nur, wenn kein Wesen mehr steht. static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature): @@ -302,7 +288,7 @@ static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void "play": game.play_card(p, action.card, bool(action.get("face_down", false))) "draw": - game.draw(p, int(action.get("index", 0))) + game.draw(p) "reveal": game.reveal(p, action.creature) "attack": diff --git a/scripts/campaign.gd b/scripts/campaign.gd index 54d3f4d..5799813 100644 --- a/scripts/campaign.gd +++ b/scripts/campaign.gd @@ -64,9 +64,16 @@ static func stage(i: int) -> Dictionary: return CHAPTERS[chapter(i) - 1][i % STAGES.size()] -## Boss einer Stufe mit dem Leben und den Merkmalen der Stufe. -static func opponent(stage_index: int) -> Game.Player: +## Boss einer Stufe mit dem Leben, den Merkmalen und einem Stapel der Stufe; ab Kapitel 2 sind +## seine Karten eine Seltenheitsstufe höher (+1/+1), ab Kapitel 3 zwei. +static func opponent(stage_index: int, r: RandomNumberGenerator = null) -> Game.Player: var st := stage(stage_index) var boss := Game.Player.new(st.name, false, int(st.life), st.title) boss.perks = Array(st.perks) + if r == null: + r = Game.make_rng() + var ch := chapter(stage_index) + boss.deck = DeckGen.opponent_deck(r, ch) + for id in boss.deck: + boss.looks[id] = ch - 1 return boss diff --git a/scripts/cards.gd b/scripts/cards.gd index 5e90309..79e6005 100644 --- a/scripts/cards.gd +++ b/scripts/cards.gd @@ -93,9 +93,9 @@ static func best_element(own: Array, other: Array) -> String: return best -## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet (0/2 kostet nichts). +## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts). static func cost_of(attack: int, health: int) -> int: - return (2 * attack + health) / 3 + return mini(10, (2 * attack + health) / 3) ## Wert eines verschmolzenen Wesens: der bessere Wert plus ein Drittel des schwächeren (mindestens 1), @@ -132,8 +132,7 @@ static func fused(a: Card, b: Card, attack: int = -1, health: int = -1) -> Card: for k in KEYWORDS: if k in a.keywords or k in b.keywords: c.keywords.append(k) - c.fusions = maxi(a.fusions, b.fusions) + 1 - c.look = maxi(a.look, b.look) # die höhere Stufe gewinnt; Game.fuse rechnet die Sammlung ein + c.look = maxi(a.look, b.look) # die höhere Stufe gewinnt return c @@ -157,19 +156,18 @@ class Card extends RefCounted: var elements: Array var attack: int = 0 var health: int = 0 - var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet + var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet, höchstens 10 (aus den Grundwerten) var keywords: Array = [] # feste Eigenschaften der Karte (Elementpaare geben ihre zur Laufzeit) - var fusions := 0 # wie oft verschmolzen (Verschmelzen kostet 1, 2, 4, 8 …) - var look := -1 # Seltenheitsstufe verschmolzener Karten (die höhere der Eltern); -1: aus der Sammlung des Besitzers + var look := -1 # Seltenheitsstufe des Exemplars (gibt +1/+1 je Stufe); -1: Grundkarte aus dem Pool func to_dict() -> Dictionary: var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health} if not keywords.is_empty(): d["keywords"] = keywords - if fusions > 0: - d["fusions"] = fusions if look >= 0: d["look"] = look + if cost != Cards.cost_of(attack, health): + d["cost"] = cost return d ## Stufe dieser Karte für einen Besitzer mit dieser Sammlung (Game.Player.looks). @@ -184,9 +182,8 @@ class Card extends RefCounted: c.elements = Array(d.elements) c.attack = int(d.get("attack", 0)) c.health = int(d.get("health", 0)) - c.cost = Cards.cost_of(c.attack, c.health) + c.cost = int(d.get("cost", Cards.cost_of(c.attack, c.health))) c.keywords = Array(d.get("keywords", [])) - c.fusions = int(d.get("fusions", 0)) c.look = int(d.get("look", -1)) return c @@ -194,6 +191,15 @@ class Card extends RefCounted: func copy() -> Card: return Card.from_dict(to_dict()) + ## Exemplar in einer Seltenheitsstufe: +1/+1 je Stufe, die Kosten bleiben die der Grundkarte. + func leveled(level: int) -> Card: + var c := copy() + c.look = level + c.attack += level + c.health += level + c.cost = cost + return c + ## Anzeigename in der aktuellen Sprache. func title() -> String: if name_en != "" and TranslationServer.get_locale().begins_with("en"): @@ -206,10 +212,6 @@ class Card extends RefCounted: func combo() -> String: return Cards.combo_name(elements) - ## Mana für die nächste Verschmelzung mit dieser Karte. - func fusion_cost() -> int: - return 1 << fusions - ## Kartentext: die festen Eigenschaften. func text() -> String: if keywords.is_empty(): diff --git a/scripts/deckgen.gd b/scripts/deckgen.gd index ddcd155..842563a 100644 --- a/scripts/deckgen.gd +++ b/scripts/deckgen.gd @@ -1,16 +1,54 @@ class_name DeckGen -## Der gemeinsame Stapel (jede Karte dreimal) und Gegner. +## Stapel: die vier Startdecks, Auffüllen aus der Sammlung, zufällige Stapel für Gegner. -const COPIES := 3 +const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens -## Frisch gemischter Stapel; das Ende des Arrays ist oben. -static func deck(r: RandomNumberGenerator) -> Array: +## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang. +static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array: + var cheap := Cards.all().filter(func(c): return c.cost <= Game.START_MANA) + var rest := Cards.all().filter(func(c): return c.cost > Game.START_MANA and c.cost <= max_cost) + Game.shuffle(cheap, r) + Game.shuffle(rest, r) + var out := [] + for c in cheap.slice(0, CHEAP_IN_RANDOM) + rest + cheap.slice(CHEAP_IN_RANDOM): + if out.size() >= Game.DECK_MIN: + break + out.append(c.id) + return out + + +## Stapel eines Kampagnengegners: Kapitel 1 bis Kosten 3, Kapitel 2 bis 5, Kapitel 3 alles. +static func opponent_deck(r: RandomNumberGenerator, chapter: int) -> Array: + return random_deck(r, [3, 5, 99][clampi(chapter - 1, 0, 2)]) + + +## Startdeck für ein Element: die 25 Karten des Elements bis Angriff 4, dazu je fünf billige der +## anderen Elemente (DECK_MIN Karten). +static func start_deck(element: String) -> Array: var out := [] for c in Cards.all(): - for i in COPIES: - out.append(c) - Game.shuffle(out, r) + if c.elements[0] == element and c.attack <= 4: + out.append(c.id) + for e in Cards.ELEMENTS: + if e == element: + continue + var cheap := Cards.all().filter(func(c): return c.elements[0] == e and c.cost <= Game.START_MANA) + cheap.sort_custom(func(a, b): return a.cost < b.cost if a.cost != b.cost else a.attack > b.attack) + for c in cheap.slice(0, 5): + out.append(c.id) + return out + + +## Füllt deck aus pool (Karten-IDs) bis limit auf, die teuersten zuerst; nichts doppelt. +static func fill(deck: Array, pool: Array, limit: int) -> Array: + var out := deck.duplicate() + var cards := pool.filter(func(id): return not (id in out)).map(func(id): return Cards.by_id(id)) + cards.sort_custom(func(a, b): return a.cost > b.cost if a.cost != b.cost else a.id < b.id) + for c in cards: + if out.size() >= limit: + break + out.append(c.id) return out diff --git a/scripts/game.gd b/scripts/game.gd index c2a6fbf..ad957ac 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -7,11 +7,11 @@ const MAX_BOARD := 5 const MAX_HAND := 5 const START_HAND := 5 # Startkarten, alle im ersten Zug bezahlbar const START_MANA := 1 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt -const DRAW_COST := 2 # Ziehen: eine bezahlbare Karte vom Stapel +const DECK_MIN := 40 # Karten je Stapel: mindestens so viele (Gegner und Startdecks genau so viele) +const DECK_MAX := 60 # und höchstens so viele const START_LIFE := 30 const ELEMENT_PAIR := 2 # so viele eigene offene Wesen eines Elements: Sondereffekt des Elements -const ELEMENT_MANA := 10 # je so viel Mana an ausgespielten Karten eines Elements: +1 Angriff für die eigenen Wesen des Elements -const OFFER := 3 # offene Karten auf dem Stapel, höchstens so teuer wie das Mana des Zugs; beim Ziehen wählt man eine, eine neue rückt nach +const ELEMENT_COUNT := 5 # je so viele ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements const DISCARD_GAIN := 1 # Abwerfen einer Handkarte bringt so viel Mana # Merkmale der Kampagnen-Bosse (Player.perks). @@ -129,17 +129,18 @@ class Player extends RefCounted: var is_human: bool var life: int var max_life: int - var hand: Array = [] # eigene Exemplare (Cards.Card.copy) + var hand: Array = [] # eigene Exemplare (Cards.Card, in der eigenen Seltenheitsstufe) var board: Array = [] + var deck: Array = [] # eigener Stapel: Karten-IDs, das Ende ist oben var energy := 0 # Mana in diesem Zug; der Rest verfällt var max_energy := 0 # Mana zu Zugbeginn (START_MANA + eigene Züge) var order := 0 # Platz in der Zugreihenfolge var perks: Array = [] # Boss-Merkmale (siehe PERKS) var looks := {} # Karten-ID -> Seltenheitsstufe, reine Optik (siehe Profile) var turns := 0 # begonnene eigene Züge - var played := {} # Element -> Mana der bisher ausgespielten Karten (je ELEMENT_MANA: +1 Angriff) - var offer: Array = [] # die OFFER offenen Karten auf dem Stapel (nur der Besitzer sieht sie) - var drew := false # in diesem Zug schon gezogen? Das erste Ziehen ist frei + var played := {} # Element -> bisher ausgespielte Karten (je ELEMENT_COUNT: +1/+1) + var draws := 0 # in diesem Zug gezogene Karten; die n-te kostet n-1 Mana + var fatigue := 0 # Züge ohne Karten im Stapel: so viel Leben kostet der nächste func _init(n: String, h: bool, l: int, t: String = "") -> void: name = n @@ -153,6 +154,25 @@ class Player extends RefCounted: func alive() -> bool: return life > 0 + ## Eine Karte vom eigenen Stapel als Exemplar in der eigenen Stufe; null, wenn er leer ist. + ## Mit max_cost kommt die oberste bezahlbare, zu teure wandern nach unten (Starthand). + func draw_from_deck(max_cost: int = 99) -> Cards.Card: + var skipped := [] + var id := "" + while not deck.is_empty(): + var top: String = deck.pop_back() + if Cards.by_id(top).cost <= max_cost: + id = top + break + skipped.append(top) + if id == "" and not skipped.is_empty(): # nichts Bezahlbares: dann eben die oberste + id = skipped.pop_front() + for sk in skipped: + deck.insert(0, sk) + if id == "": + return null + return Cards.by_id(id).leveled(int(looks.get(id, 0))) + ## Offene eigene Wesen mit diesem Element. func element_count(e: String) -> int: var n := 0 @@ -172,20 +192,19 @@ class Player extends RefCounted: p.looks = looks p.turns = turns p.played = played.duplicate() - p.offer = offer.duplicate() - p.drew = drew + p.deck = deck.duplicate() + p.draws = draws + p.fatigue = fatigue for c in board: p.board.append(c.clone(p)) return p -var human: Player # der (erste) menschliche Spieler -var humans: Array = [] # alle menschlichen Spieler (im Netz zwei) +var human: Player # der menschliche Spieler var opponent: Player # der andere am Tisch var players: Array = [] # in Zugreihenfolge var winner_player: Player = null var rng: RandomNumberGenerator -var deck: Array = [] # gemeinsamer Stapel, das Ende ist oben (nur Host und Einzelspieler) var turn := 0 var current_idx := 0 var log: Array[String] = [] @@ -199,7 +218,6 @@ func _init(h: Player, o: Player, r: RandomNumberGenerator = null) -> void: human = h opponent = o players = [h, o] - humans = players.filter(func(p): return p.is_human) if r == null: r = make_rng() rng = r @@ -250,7 +268,6 @@ func clone() -> Game: var idx := players.find(human) var g := Game.new(copies[idx], copies[1 - idx], RandomNumberGenerator.new()) g.players = copies # gleiche Zugreihenfolge wie das Original - g.deck = deck.duplicate() g.turn = turn g.current_idx = current_idx g.winner = winner @@ -278,16 +295,21 @@ func masked_clone(viewer: Player) -> Game: # --- Zugablauf --------------------------------------------------------------- +## Jeder spielt mit seinem eigenen Stapel (leer: ein zufälliger); die Starthand sind fünf im +## ersten Zug bezahlbare Karten daraus. func start(fixed_order: bool = false) -> void: if not fixed_order: shuffle(players, rng) # zufällige Zugreihenfolge - deck = DeckGen.deck(rng) for i in players.size(): var p: Player = players[i] p.order = i - for c in draw_cards(START_HAND, START_MANA): - p.hand.append(c.copy()) - p.offer = draw_cards(OFFER, START_MANA) + if p.deck.is_empty(): + p.deck = DeckGen.random_deck(rng) + shuffle(p.deck, rng) + for k in START_HAND: + var c := p.draw_from_deck(START_MANA) + if c != null: + p.hand.append(c) turn = 1 current_idx = 0 say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name))) @@ -297,54 +319,31 @@ func start(fixed_order: bool = false) -> void: begin_turn(current()) -## Nimmt n Karten vom Stapel, die höchstens max_cost kosten; zu teure wandern unter den Stapel. -## Ist der Stapel leer, kommt ein frischer. -func draw_cards(n: int, max_cost: int = 999) -> Array: - var out := [] - var skipped := [] - var looked := 0 - while out.size() < n: - if deck.is_empty(): - deck = DeckGen.deck(rng) - say("Der Stapel wird neu gemischt.") - var c: Cards.Card = deck.pop_back() - looked += 1 - if c.cost <= max_cost or looked > deck.size() + skipped.size() + n: - out.append(c) - else: - skipped.append(c) - for c in skipped: - deck.insert(0, c) - return out - - -## Ziehen: eine der offenen Karten kommt auf die Hand, eine neue rückt nach. Das erste Ziehen -## im Zug ist frei, jedes weitere kostet DRAW_COST. +## Ziehen: eine Karte vom eigenen Stapel. Die erste im Zug ist frei, jede weitere kostet eins +## mehr (0, 1, 2 …). func can_draw(p: Player) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] if p.hand.size() >= MAX_HAND: return [false, "Deine Hand ist voll"] + if p.deck.is_empty(): + return [false, "Dein Stapel ist leer"] if p.energy < draw_cost(p): return [false, "Nicht genug Mana"] return [true, ""] func draw_cost(p: Player) -> int: - return 0 if not p.drew or "ziehen_gratis" in p.perks else DRAW_COST + return maxi(0, p.draws - (1 if "ziehen_gratis" in p.perks else 0)) -func draw(p: Player, index: int = 0) -> String: +func draw(p: Player) -> String: var ok := can_draw(p) if not ok[0]: return ok[1] - if index < 0 or index >= p.offer.size(): - return "Keine Karte gewählt" p.energy -= draw_cost(p) - p.drew = true - var card: Cards.Card = p.offer[index] - p.hand.append(card.copy()) - p.offer[index] = draw_cards(1, maxi(p.max_energy, START_MANA))[0] # nie teurer als das Mana dieses Zugs + p.draws += 1 + p.hand.append(p.draw_from_deck()) say("%s zieht." % p.name) emit({"type": "draw", "player": players.find(p)}) return "" @@ -359,9 +358,15 @@ func begin_turn(p: Player) -> void: c.ready = true # lag eine Runde: greift jetzt an, Verdecktes darf aufgedeckt werden c.attacks_done = 0 p.turns += 1 - p.drew = false # das erste Ziehen im Zug ist wieder frei + p.draws = 0 # das erste Ziehen im Zug ist wieder frei say("--- Zug %d: %s ---" % [turn, p.name]) emit({"type": "turn", "player": players.find(p)}) + if p.deck.is_empty(): # Erschöpfung: ohne Stapel kostet jeder Zug mehr Leben, damit es ein Ende gibt + p.fatigue += 1 + p.life -= p.fatigue + say("%s hat keine Karten mehr und verliert %d Leben." % [p.name, p.fatigue]) + emit({"type": "damage", "target": ref_of(p), "amount": p.fatigue}) + check_end() ## Zugende: Wesen mit Regeneration heilen ganz, dann ist der andere dran. @@ -417,7 +422,7 @@ func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String: cr.max_health += 1 p.board.append(cr) for e in card.elements: - p.played[e] = int(p.played.get(e, 0)) + card.cost + p.played[e] = int(p.played.get(e, 0)) + 1 if face_down: say("%s legt ein Wesen verdeckt." % p.name) else: @@ -492,9 +497,9 @@ static func look_of(p: Player, c: Cards.Card) -> int: return c.look if c.look >= 0 else int(p.looks.get(c.id, 0)) -## Kosten: 1 Mana, verdoppelt je Verschmelzung, die schon in einer der beiden steckt. +## Kosten: die Hälfte der Manasumme beider Karten, aufgerundet. static func fusion_cost(a, b) -> int: - return 1 << maxi(card_of(a).fusions, card_of(b).fusions) + return ceili((card_of(a).cost + card_of(b).cost) / 2.0) ## a und b: zwei eigene Wesen auf dem Brett oder zwei eigene Handkarten. @@ -539,11 +544,11 @@ func fuse(p: Player, a, b) -> String: ## das Ergebnis ist nur angriffsbereit, wenn beide es waren. func _fuse_board(p: Player, a: Creature, b: Creature) -> String: var card := Cards.fused(a.card, b.card, Cards.fusion_value(a.attack, b.attack), - Cards.fusion_value(a.max_health, b.max_health)) + Cards.fusion_value(a.max_health - a.aura, b.max_health - b.aura)) card.look = maxi(look_of(p, a.card), look_of(p, b.card)) var cr := Creature.new(card, p, next_uid) next_uid += 1 - cr.health = mini(card.health, Cards.fusion_value(a.health, b.health)) + cr.health = mini(card.health, Cards.fusion_value(mini(a.health, a.max_health - a.aura), mini(b.health, b.max_health - b.aura))) cr.ready = a.ready and b.ready cr.attacks_done = maxi(a.attacks_done, b.attacks_done) var at := p.board.find(a) @@ -560,11 +565,11 @@ func _fuse_board(p: Player, a: Creature, b: Creature) -> String: # --- Elementboni --------------------------------------------------------------- -## +1 Angriff je volle ELEMENT_MANA an ausgespielten Karten eines Elements des Spielers; mehrere Elemente zählen alle. +## +1/+1 je volle ELEMENT_COUNT ausgespielte Karten eines Elements des Spielers; mehrere Elemente zählen alle. static func aura_of(elements: Array, played: Dictionary) -> int: var a := 0 for e in elements: - a += int(played.get(e, 0)) / ELEMENT_MANA + a += int(played.get(e, 0)) / ELEMENT_COUNT return a @@ -579,18 +584,24 @@ func aura_for(c: Creature) -> int: return a -## Rechnet die Elementboni neu; sie wirken nur auf den Angriff (Creature.power). +## Rechnet die Elementboni neu. Kommt ein Bonus, steigen Leben und Höchstleben; geht er +## (beim Verdecken), sinkt nur das Höchstleben, das Leben wird gekappt. func apply_auras() -> void: for p in players: for c in p.board: - c.aura = aura_for(c) + var a := aura_for(c) + if a != c.aura: + var delta: int = a - c.aura + c.max_health += delta + c.health = mini(c.health + maxi(delta, 0), c.max_health) + c.aura = a ## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme (sie zählt selbst mit). func forecast(p: Player, card: Cards.Card) -> int: var played := p.played.duplicate() for e in card.elements: - played[e] = int(played.get(e, 0)) + card.cost + played[e] = int(played.get(e, 0)) + 1 return aura_of(card.elements, played) * (2 if "element_doppelt" in p.perks else 1) @@ -685,20 +696,7 @@ func check_end() -> void: return winner_player = alive[0] winner = "human" if winner_player.is_human else "opponents" - if humans.size() == 1: - say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.") - else: - say("%s gewinnt!" % winner_player.name) - - -# --- Zustand für das Netzwerk (siehe NetState) ------------------------------------ - -func snapshot(viewer: int) -> Dictionary: - return NetState.snapshot(self, viewer) - - -static func from_snapshot(d: Dictionary) -> Game: - return NetState.from_snapshot(d) + say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.") # --- Gegner und Kampfstart --------------------------------------------------- @@ -711,7 +709,7 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void: arr[j] = tmp -## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}} +## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}, "deck": [ids]} static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game: if r == null: r = make_rng() @@ -724,6 +722,7 @@ static func new_battle_with(opp: Player, r: RandomNumberGenerator = null, fixed_ var human := Player.new("Spieler", true, START_LIFE) human.perks = Array(rules.get("human_perks", [])) human.looks = rules.get("looks", {}) + human.deck = rules.get("deck", []).duplicate() var g := Game.new(human, opp, r) for p in g.players: if "leben_35" in p.perks: diff --git a/scripts/net.gd b/scripts/net.gd deleted file mode 100644 index 1bac1c6..0000000 --- a/scripts/net.gd +++ /dev/null @@ -1,443 +0,0 @@ -class_name Net -extends Node -## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten -## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen). - -signal lobby_changed -signal started(seat: int) -signal snapshot_received(snap: Dictionary) -signal action_applied -signal ended(reason: String) -signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt -signal status_changed - -const PORT := 24455 - -var peer: ENetMultiplayerPeer -var is_host := false -var connected := false -var my_name := "Spieler" -var my_pick := {"looks": {}} # eigene Kartenoptik (Seltenheit), die der Gegner sieht -var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie) -var picks := {} # peer_id -> {"looks": {...}} (Host) -var seats := {} # peer_id -> Spielerindex (Host) -var my_seat := -1 -var game: Game # Host -var status := "" -var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten -var rejoin_seconds := 60.0 -var spectator := false # eigene Rolle: nur zuschauen -var spectators := {} # Host: peer_id -> Name -var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts -var i_am_successor := false -var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion -var backup_names := {} # Nachfolger: Platz -> Name aller Menschen -var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild -var reconnecting := false - - -func _ready() -> void: - multiplayer.peer_connected.connect(_on_peer_connected) - multiplayer.peer_disconnected.connect(_on_peer_disconnected) - multiplayer.connected_to_server.connect(_on_connected) - multiplayer.connection_failed.connect(func(): - if not reconnecting: - _fail("Verbindung fehlgeschlagen")) - multiplayer.server_disconnected.connect(_on_server_lost) - - -func host(port: int = PORT) -> Error: - peer = ENetMultiplayerPeer.new() - var err := peer.create_server(port, 8) # ein Mitspieler, der Rest Zuschauer - if err != OK: - status = "Kann nicht hosten (Port %d belegt?)" % port - return err - multiplayer.multiplayer_peer = peer - is_host = true - connected = true - names = {1: my_name} - picks = {1: my_pick} - spectators = {} - status = "Warte auf Mitspieler …" - lobby_changed.emit() - return OK - - -func join(ip: String, port: int = PORT) -> Error: - peer = ENetMultiplayerPeer.new() - var err := peer.create_client(ip, port) - if err != OK: - status = "Adresse ungültig" - return err - multiplayer.multiplayer_peer = peer - is_host = false - status = "Verbinde mit %s …" % ip - return OK - - -func leave() -> void: - if multiplayer.multiplayer_peer != null: - multiplayer.multiplayer_peer.close() - multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() - peer = null - is_host = false - connected = false - names = {} - picks = {} - seats = {} - waiting = {} - my_seat = -1 - game = null - status = "" - spectator = false - spectators = {} - successor_ip = "" - i_am_successor = false - backup = {} - backup_names = {} - last_winner = "" - - -func local_addresses() -> Array: - return Array(IP.get_local_addresses()).filter(func(a): return "." in a and not a.begins_with("127.")) - - -func _fail(reason: String) -> void: - status = reason - ended.emit(reason) - leave() - - -func _on_connected() -> void: - connected = true - status = "Verbunden" - register.rpc_id(1, my_name, my_pick, spectator) - - -# --- Host-Wechsel --------------------------------------------------------------- -# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen -# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der -# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück. - -func _successor_id() -> int: - var ids: Array = seats.keys().filter(func(id): return id != 1) - ids.sort() - return ids[0] if not ids.is_empty() else -1 - - -func _update_successor() -> void: - if not is_host or game == null: - return - var succ := _successor_id() - var ip := "" - if succ > 0 and peer != null and peer.get_peer(succ) != null: - ip = peer.get_peer(succ).get_remote_address() - for id in seats: - if id != 1: - set_successor.rpc_id(id, ip, id == succ) - - -## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel. -func _seat_names() -> Dictionary: - var out := {} - for id in seats: - out[seats[id]] = names.get(id, "") - for pname in waiting: - out[waiting[pname].seat] = pname - return out - - -@rpc("authority", "reliable") -func set_successor(ip: String, me: bool) -> void: - successor_ip = ip - i_am_successor = me - - -@rpc("authority", "reliable") -func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void: - backup = snap - backup_names = seat_names - - -func _on_server_lost() -> void: - if is_host: - return - if last_winner != "": - _fail("Host hat das Spiel beendet") - elif i_am_successor and not backup.is_empty(): - _become_host() - elif successor_ip != "": - status = "Host weg, verbinde mit dem Nachfolger …" - status_changed.emit() - _reconnect(successor_ip) - else: - _fail("Host hat das Spiel beendet") - - -func _become_host() -> void: - var snap := backup - var seat_names := backup_names - var seat := my_seat - if multiplayer.multiplayer_peer != null: - multiplayer.multiplayer_peer.close() - var err := FAILED - for attempt in 5: - err = host() - if err == OK: - break - await get_tree().create_timer(1.0).timeout - if err != OK: - _fail("Host weg, Übernahme fehlgeschlagen") - return - game = Game.from_snapshot(snap) - seats = {1: seat} - my_seat = seat - waiting = {} - for s in seat_names: - if int(s) != seat: - waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()} - game.say("%s übernimmt als Host." % my_name) - status = "Du bist jetzt Host, die anderen verbinden neu …" - successor_ip = "" - i_am_successor = false - backup = {} - migrated.emit() - - -func _reconnect(ip: String) -> void: - reconnecting = true - await get_tree().create_timer(2.0).timeout - for attempt in 5: - if join(ip) == OK: - var t := 0.0 - while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING: - await get_tree().process_frame - t += get_process_delta_time() - if connected: - reconnecting = false - return - await get_tree().create_timer(1.0).timeout - reconnecting = false - _fail("Nachfolge-Host nicht erreichbar") - - -func _on_peer_connected(_id: int) -> void: - pass - - -func _on_peer_disconnected(id: int) -> void: - if not is_host: - return - var pname: String = names.get(id, "") - names.erase(id) - picks.erase(id) - spectators.erase(id) - if game != null and seats.has(id): - var p: Game.Player = game.players[seats[id]] - if game.winner == "": - waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()} - game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)]) - seats.erase(id) - _update_successor() - action_applied.emit() - lobby_update.rpc(names, spectators) - lobby_changed.emit() - - -## Host, je Bild: wartende Spieler überspringen; nach Ablauf der Frist scheiden sie aus. -func tick() -> void: - if not is_host or game == null or game.winner != "": - return - var now := Time.get_unix_time_from_system() - for pname in waiting.keys(): - var w: Dictionary = waiting[pname] - var p: Game.Player = game.players[w.seat] - if now - float(w.since) > rejoin_seconds: - 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: - 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, pick: Dictionary, as_spectator: bool = false) -> void: - if not is_host: - return - var id := multiplayer.get_remote_sender_id() - if as_spectator: - spectators[id] = name - lobby_update.rpc(names, spectators) - lobby_changed.emit() - if game != null: - start_game.rpc_id(id, -1) - receive_snapshot.rpc_id(id, game.snapshot(-2)) - return - if game != null: - # Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz - if waiting.has(name): - seats[id] = waiting[name].seat - names[id] = name - waiting.erase(name) - game.say("%s ist zurück." % name) - start_game.rpc_id(id, seats[id]) - receive_snapshot.rpc_id(id, game.snapshot(seats[id])) - _update_successor() - action_applied.emit() - else: - status = "%s wollte beitreten, das Spiel läuft schon" % name - return - names[id] = name - picks[id] = pick - lobby_update.rpc(names, spectators) - lobby_changed.emit() - - -@rpc("authority", "reliable") -func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void: - names = n - spectators = sp - lobby_changed.emit() - - -## Host: Duell mit dem ersten Mitspieler starten; allein gegen die KI. -func start_battle() -> void: - var ids: Array = names.keys() - ids.sort() - ids = ids.slice(0, 2) - var r := Game.make_rng() - var players := [] - seats = {} - for id in 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): - p.looks[cid] = int(pick.looks[cid]) - players.append(p) - if players.size() < 2: - players.append(DeckGen.random_opponent()) - game = Game.new(players[0], players[1], r) - game.start() - for id in ids: - seats[id] = game.players.find(players[ids.find(id)]) - my_seat = seats[1] - for id in ids: - if id != 1: - start_game.rpc_id(id, seats[id]) - for id in spectators: - start_game.rpc_id(id, -1) - _update_successor() - started.emit(my_seat) - - -@rpc("authority", "reliable") -func start_game(seat: int) -> void: - my_seat = seat - started.emit(seat) - - -## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes, -## der Nachfolger zusätzlich den vollen Spielstand). -func broadcast() -> void: - if not is_host or game == null: - return - for id in seats: - if id != 1: - receive_snapshot.rpc_id(id, game.snapshot(seats[id])) - for id in spectators: - receive_snapshot.rpc_id(id, game.snapshot(-2)) - var succ := _successor_id() - if succ > 0: - receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names()) - - -@rpc("authority", "reliable") -func receive_snapshot(snap: Dictionary) -> void: - last_winner = str(snap.get("winner", "")) - snapshot_received.emit(snap) - - -## Mitspieler: Aktion an den Host schicken. -func send_action(action: Dictionary) -> void: - if is_host: - _apply(1, action) - else: - request_action.rpc_id(1, action) - - -@rpc("any_peer", "reliable") -func request_action(action: Dictionary) -> void: - if not is_host: - return - _apply(multiplayer.get_remote_sender_id(), action) - - -func _apply(id: int, action: Dictionary) -> void: - if game == null or not seats.has(id): - return - var p: Game.Player = game.players[seats[id]] - if game.winner != "": - return - if game.current() != p: - return - match str(action.get("type", "")): - "play": - var idx := int(action.get("hand", -1)) - if idx < 0 or idx >= p.hand.size(): - return - game.play_card(p, p.hand[idx], bool(action.get("face_down", false))) - "reveal": - var cr := game.find_creature(int(action.get("uid", -1))) - if cr != null: - game.reveal(p, cr) - "discard": - var hi := int(action.get("hand", -1)) - if hi >= 0 and hi < p.hand.size(): - game.discard(p, p.hand[hi]) - "attack": - var cr := game.find_creature(int(action.get("uid", -1))) - var t = resolve_ref(action.get("target")) - if cr != null and t != null: - game.attack(cr, t) - "draw": - game.draw(p, int(action.get("index", 0))) - "fuse": # zwei Wesen (uid, other) oder zwei Handkarten (hand, other_hand) - var a = game.find_creature(int(action.get("uid", -1))) - var b = game.find_creature(int(action.get("other", -1))) - var i := int(action.get("hand", -1)) - var j := int(action.get("other_hand", -1)) - if a == null and i >= 0 and j >= 0 and i < p.hand.size() and j < p.hand.size(): - a = p.hand[i] - b = p.hand[j] - if a != null and b != null: - game.fuse(p, a, b) - "end": - game.end_turn() - _: - return - action_applied.emit() - - -func resolve_ref(ref): - if ref == null or not (ref is Dictionary): - return null - if ref.get("kind") == "creature": - return game.find_creature(int(ref.get("uid", -1))) - var i := int(ref.get("index", -1)) - if i >= 0 and i < game.players.size(): - return game.players[i] - return null - - -static func ref_dict(target) -> Dictionary: - if target is Game.Creature: - return {"kind": "creature", "uid": target.uid} - return {"kind": "player", "index": target.order} diff --git a/scripts/net.gd.uid b/scripts/net.gd.uid deleted file mode 100644 index ff855be..0000000 --- a/scripts/net.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://drhh23lblvq8o diff --git a/scripts/netstate.gd b/scripts/netstate.gd deleted file mode 100644 index bff1b94..0000000 --- a/scripts/netstate.gd +++ /dev/null @@ -1,109 +0,0 @@ -class_name NetState -## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln). - - -## Karten reisen als ID; eigene Exemplare mit Schlüsselwörtern und verschmolzene als Ganzes. -static func card_ref(c: Cards.Card): - return c.id if Cards.has_id(c.id) and c.keywords.is_empty() and c.fusions == 0 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).copy() - - -## Zustand aus Sicht eines Spielers: fremde Hand nur als Anzahl, fremde -## verdeckte Wesen ohne Karte und Werte. viewer -1: alles sichtbar (Host-Wechsel); -2: Zuschauer. -static func snapshot(g: Game, viewer: int) -> Dictionary: - var ps := [] - for i in g.players.size(): - var p: Game.Player = g.players[i] - var mine := i == viewer or viewer == -1 - var board := [] - for c in p.board: - var masked: bool = c.hidden and not mine - board.append({"uid": c.uid, "card": card_ref(Cards.unknown() if masked else c.card), - "attack": 0 if masked else c.attack, "health": 0 if masked else c.health, - "max_health": 0 if masked else c.max_health, "attacks_done": c.attacks_done, - "ready": c.ready, "hidden": c.hidden, "aura": c.aura}) - 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, - "order": p.order, "perks": p.perks, "looks": p.looks, "turns": p.turns, "played": p.played, - "hand": p.hand.map(card_ref) if mine else p.hand.size(), "board": board, - "offer": p.offer.map(card_ref) if mine else [], "drew": p.drew}) - var evs := [] - for ev in g.events: - var e: Dictionary = ev.duplicate() - if e.has("card"): - 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, "deck_size": g.deck.size(), - "log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs} - - -## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt. -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) - p.max_life = int(pd.max_life) - p.energy = int(pd.energy) - p.max_energy = int(pd.max_energy) - 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) - for e in pd.get("played", {}): - p.played[e] = int(pd.played[e]) - if pd.hand is Array: - for ref in pd.hand: - p.hand.append(card_of(ref)) - else: - for i in int(pd.hand): - p.hand.append(placeholder) - for ref in pd.get("offer", []): - p.offer.append(card_of(ref)) - p.drew = bool(pd.get("drew", false)) - for cd in pd.board: - var card := card_of(cd.card) - var c := Game.Creature.new(card, p, int(cd.uid)) - c.attack = int(cd.attack) - c.health = int(cd.health) - c.max_health = int(cd.max_health) - c.attacks_done = int(cd.get("attacks_done", 0)) - c.ready = bool(cd.get("ready", false)) - c.hidden = bool(cd.get("hidden", false)) - c.aura = int(cd.get("aura", 0)) - p.board.append(c) - list.append(p) - var hi := int(d.human) - var g := Game.new(list[hi], list[1 - hi], RandomNumberGenerator.new()) - g.players = list - g.turn = int(d.turn) - g.current_idx = int(d.current_idx) - 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) - for i in int(d.get("deck_size", 0)): - g.deck.append(placeholder) # nur die Größe zählt; gezogen wird beim Host - 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"] = card_of(e.card) - for key in ["player", "uid", "amount"]: - if e.has(key): - e[key] = int(e[key]) - for key in ["target", "attacker"]: - if e.has(key): - var r: Dictionary = e[key] - if r.has("uid"): - r["uid"] = int(r.uid) - if r.has("index"): - r["index"] = int(r.index) - g.events.append(e) - return g diff --git a/scripts/netstate.gd.uid b/scripts/netstate.gd.uid deleted file mode 100644 index aab4c27..0000000 --- a/scripts/netstate.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c3ao1mpgbrter diff --git a/scripts/profile.gd b/scripts/profile.gd index bed7850..48dadb4 100644 --- a/scripts/profile.gd +++ b/scripts/profile.gd @@ -1,14 +1,16 @@ class_name Profile extends RefCounted -## 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. +## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen. +## Man startet mit einem von vier Startdecks (je ein Element im Mittelpunkt); Booster und die +## Werkstatt bringen den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe +## gibt (Cards.Card.leveled); kommt eine seltenere Version, ersetzt sie die alte, die zu Staub wird. -const SAVE_VERSION := 6 +const SAVE_VERSION := 7 +const CRAFT_COST := 200 # Staub für eine neue Karte (Common) const SAVE_PATH := "user://spielstand.json" const BOOSTER_COST := 100 const BOOSTER_SIZE := 5 -const RARITY_WEIGHTS := {"Common": 50.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 8.0, "Ultimate Rare": 2.0} +const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0} const START_COINS := 150 const REWARD_WIN := 100 const REWARD_LOSS := 50 @@ -24,19 +26,34 @@ const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultim var coins := 0 var dust := 0 -var collection := {} # id -> Seltenheitsstufe (Index in Cards.RARITIES), fehlend = Common +var collection := {} # eigene Karten: id -> Seltenheitsstufe (Index in Cards.RARITIES) +var deck: Array = [] # Karten-IDs des eigenen Stapels (Game.DECK_MIN bis DECK_MAX) var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false} var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt) var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1) +## Neues Profil: noch ohne Karten, das Startdeck wählt der Spieler (choose_start). static func starter() -> Profile: var p := Profile.new() p.coins = START_COINS return p +## Hat der Spieler schon ein Startdeck gewählt? +func started() -> bool: + return not collection.is_empty() + + +## Das Startdeck eines Elements wird Sammlung und Stapel. +func choose_start(element: String) -> void: + collection = {} + deck = DeckGen.start_deck(element) + for id in deck: + collection[id] = 0 + + ## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil. static func load_or_new(path: String = SAVE_PATH) -> Profile: for candidate in [path, path + ".bak"]: @@ -52,7 +69,7 @@ static func load_or_new(path: String = SAVE_PATH) -> Profile: ## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und -## Einstellungen, die alte Sammlung (Anzahl je Karte) hat keine Entsprechung mehr. +## Einstellungen und bekommen die Startsammlung. static func from_dict(d: Dictionary) -> Profile: var p := Profile.new() p.coins = int(d.get("coins", 0)) @@ -63,6 +80,11 @@ static func from_dict(d: Dictionary) -> Profile: for cid in coll: if Cards.has_id(cid): p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1) + var ids := [] + for cid in d.get("deck", []): + if p.collection.has(cid) and not (cid in ids): + ids.append(cid) + p.deck = ids.slice(0, Game.DECK_MAX) var se = d.get("settings", {}) p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)), "ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)), @@ -77,7 +99,7 @@ static func from_dict(d: Dictionary) -> Profile: func to_dict() -> Dictionary: - return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, + return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, "deck": deck, "campaign_stage": campaign_stage, "settings": settings, "perk": perk} @@ -97,7 +119,11 @@ func take_notices() -> Array[String]: return out -# --- Sammlung: Seltenheit als Aussehen -------------------------------------------- +# --- Sammlung und Stapel ----------------------------------------------------------- + +func owns(cid: String) -> bool: + return collection.has(cid) + func level(cid: String) -> int: return int(collection.get(cid, 0)) @@ -116,17 +142,29 @@ func looks() -> Dictionary: return out -## Anzahl Karten über Common. -func upgraded_count() -> int: - var n := 0 - for cid in collection: - if int(collection[cid]) > 0: - n += 1 - return n +## Der Stapel für den Kampf: der gewählte, mit weiteren eigenen Karten bis DECK_MIN aufgefüllt, +## falls er zu klein ist (Karten können durch Zerlegen wegfallen). +func deck_cards() -> Array: + return DeckGen.fill(deck.filter(func(id): return collection.has(id)), collection.keys(), Game.DECK_MIN) + + +## Karte in den Stapel oder heraus. Gibt "" zurück oder eine Fehlermeldung. +func toggle_deck(cid: String) -> String: + if cid in deck: + deck.erase(cid) + return "" + if not owns(cid): + return "Karte nicht in der Sammlung" + if deck.size() >= Game.DECK_MAX: + return "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX + deck.append(cid) + return "" ## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung. func upgrade(cid: String) -> String: + if not owns(cid): + return "Karte nicht in der Sammlung" var lvl := level(cid) if lvl >= Cards.RARITIES.size() - 1: return "Schon auf der höchsten Stufe" @@ -138,13 +176,29 @@ func upgrade(cid: String) -> String: return "" -## Gibt eine Stufe ab und bekommt Staub dafür. Gibt "" zurück oder eine Fehlermeldung. +## Entzaubern: eine Stufe abgeben bringt Staub; eine Common-Karte verlässt die Sammlung. +## Gibt "" zurück oder eine Fehlermeldung. func dismantle(cid: String) -> String: + if not owns(cid): + return "Karte nicht in der Sammlung" var lvl := level(cid) - if lvl == 0: - return "Common-Karten lassen sich nicht zerlegen" dust += DUST_DISMANTLE[Cards.RARITIES[lvl]] - collection[cid] = lvl - 1 + if lvl == 0: + collection.erase(cid) + deck.erase(cid) + else: + collection[cid] = lvl - 1 + return "" + + +## Herstellen: eine fehlende Karte für Staub als Common. Gibt "" zurück oder eine Fehlermeldung. +func craft(cid: String) -> String: + if owns(cid): + return "Karte schon in der Sammlung" + if dust < CRAFT_COST: + return "Nicht genug Staub (%d nötig)" % CRAFT_COST + dust -= CRAFT_COST + collection[cid] = 0 return "" @@ -164,8 +218,9 @@ static func roll_booster(r: RandomNumberGenerator) -> Array: return out -## Ö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. +## Öffnet einen Booster. Neue Karten kommen in Sammlung und Stapel (solange Platz ist). Von jeder +## Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine gleiche +## oder niedrigere wird selbst 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 [] @@ -175,14 +230,20 @@ func open_booster(r: RandomNumberGenerator = null) -> Array: var out := roll_booster(r) for res in out: var lvl: int = Cards.RARITIES.find(res.rarity) - if lvl > level(res.card.id): + res["new"] = not owns(res.card.id) + res["upgraded"] = false + res["dust"] = 0 + if res.new: collection[res.card.id] = lvl + if deck.size() < Game.DECK_MAX: + deck.append(res.card.id) + elif lvl > level(res.card.id): res["upgraded"] = true - res["dust"] = 0 + res["dust"] = DUST_DISMANTLE[rarity(res.card.id)] + collection[res.card.id] = lvl else: - res["upgraded"] = false res["dust"] = DUST_DISMANTLE[res.rarity] - dust += res.dust + dust += res.dust return out diff --git a/scripts/ui/battle_gamepad.gd b/scripts/ui/battle_gamepad.gd index 9b0ed19..581cf08 100644 --- a/scripts/ui/battle_gamepad.gd +++ b/scripts/ui/battle_gamepad.gd @@ -76,7 +76,7 @@ func _next_zone(step: int) -> void: func items() -> Array: match zone: "deck": - return screen.offer_views + return [screen.deck] if screen.deck.visible else [] "hand": return screen.hand_views "own": @@ -105,7 +105,7 @@ func confirm() -> void: var item = items[index] match zone: "deck": - screen.on_click("deck", index) + screen.on_click("deck", null) "hand": screen.on_click("hand", item.card) "own": diff --git a/scripts/ui/battle_layout.gd b/scripts/ui/battle_layout.gd index 2aad359..8dfb063 100644 --- a/scripts/ui/battle_layout.gd +++ b/scripts/ui/battle_layout.gd @@ -1,31 +1,29 @@ class_name BattleLayout ## Feste Bildschirmaufteilung des Duells (1280x800). Mitte von oben nach unten: Hand des Gegners -## (Rückseiten), sein Brett, seine Elementleiste, die eigene Elementleiste (die Naht liegt auf halber -## Höhe), eigenes Brett, eigene Hand. Links: Kasten des Gegners, die drei offenen Karten des -## Stapels (mittig), eigener Kasten. Rechts oben die Knöpfe, rechts mittig die Vorschau, darunter Meldungen. +## (Rückseiten), sein Brett, eigenes Brett, eigene Hand. Links: Kasten des Gegners, daneben die +## Elementzähler beider Seiten je neben ihrem Brett, Zusatzzeile und Meldungen. Rechts von oben: +## Stapel und Kasten des Gegners, die Vorschau, eigener Kasten und Stapel. Optionen per Esc. -const B_GIVEUP := Rect2(1070, 10, 96, 32) -const B_LOG := Rect2(1172, 10, 96, 32) -const B_END := Rect2(1070, 48, 198, 32) -const OPP_BOX := Rect2(20, 11, 210, 96) +const B_UNDO := Rect2(20, 10, 210, 32) # links oben: Rückgängig, darunter Überspringen (Gegnerzug) +const B_SKIP := Rect2(20, 48, 210, 32) +const OPP_DECK := Rect2(1090, 10, 130, 182) # Stapel des Gegners rechts oben +const OPP_BOX := Rect2(1050, 200, 210, 70) # Kasten des Gegners darunter const OPP_HAND_Y := 11 -const FIELD_OPP := Rect2(240, 364, 800, 32) -const FIELD := Rect2(240, 404, 800, 32) -const MSG := Rect2(1040, 630, 220, 60) -const STATUS := Rect2(20, 694, 210, 96) -const B_UNDO := Rect2(1070, 86, 198, 32) +const OPP_ELEMENTS := Rect2(20, 220, 210, 128) # Elementzähler des Gegners, links von seinem Brett +const OWN_ELEMENTS := Rect2(20, 440, 210, 128) # eigene Elementzähler, links vom eigenen Brett +const INFO := Rect2(20, 580, 210, 60) # Zusatzzeile zur Vorschau +const MSG := Rect2(20, 650, 210, 80) # Meldungen +const STATUS := Rect2(1050, 526, 210, 70) # eigener Kasten rechts über dem Stapel +const DECK := Rect2(1090, 606, 130, 182) # eigener Stapel rechts unten +const OPTIONS := Rect2(440, 180, 400, 420) # Optionsmenü (Esc) const OWN_X := 345 const HAND_W := 110 # Handkarten so groß wie die Wesen auf dem Feld const HAND_H := 140 const HAND_GAP := 10 const HAND_Y := 610 const HAND_ARC := 3.0 # Grad Neigung und Pixel Absenkung je Platz von der Mitte (leichter Bogen) -const OFFER_X := 70 # die drei offenen Karten des Stapels, links übereinander um die Mitte -const OFFER_Y := 180 -const OFFER_GAP := 10 -const DECK_LABEL := Rect2(20, 632, 210, 22) const LOG_PANEL := Rect2(240, 60, 800, 560) -const PREVIEW_SIZE := Vector2(180, 252) +const PREVIEW_SIZE := Vector2(170, 238) const CENTER_X := 240 const CENTER_W := 800 @@ -61,11 +59,11 @@ static func hand_arc(count: int, i: int, mirrored: bool = false) -> Array: return [t * HAND_ARC * sign, t * t * HAND_ARC * sign] -## Vorschau der gezeigten Karte: fest rechts auf halber Höhe. +## Vorschau der gezeigten Karte: fest rechts zwischen den beiden Kästen. static func preview_rect(_anchor: Rect2) -> Rect2: - return Rect2(Vector2(1050, 400 - PREVIEW_SIZE.y / 2), PREVIEW_SIZE) + return Rect2(Vector2(1070, 278), PREVIEW_SIZE) -## Zusatzzeile (Elementbonus, Eigenschaften) unter der Vorschau. -static func info_rect(preview: Rect2) -> Rect2: - return Rect2(1030, preview.end.y + 8, 230, 80) +## Zusatzzeile (Elementbonus, Eigenschaften) links unter den Elementzählern. +static func info_rect(_preview: Rect2) -> Rect2: + return INFO diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index 664e2b1..58ec61b 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -1,17 +1,16 @@ class_name BattleScreen extends Control ## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx. +## Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden); den Zug beendet +## die Leertaste oder der Rechtsklick ins Leere. ## Mittlere Maustaste auf eine Handkarte legt sie verdeckt. Rechtsklick auf eine Handkarte oder ## ein eigenes Wesen öffnet das Kartenmenü: verdeckt legen, aufdecken, Eigenschaften kaufen. ## Verschmelzen: eine Karte auf eine andere ziehen und dort eine Sekunde stillhalten. Mit ## Gamepad öffnet Y das Menü, dort zusätzlich mit „Verschmelzen mit …“. -## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer). +## Modi: Einzelspieler und Kampagne. var app var stage := -1 # Kampagnenstufe oder -1 -var net_play := false # Netzwerkspiel (auch als Zuschauer) -var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1 -var net: Net var game: Game var perspective: Game.Player # der Mensch, dessen Hand unten liegt var fx: BattleFx @@ -60,12 +59,10 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms) var hero_layer: Control var tile_layer: Control var hand_layer: Control -var offer_views: Array = [] # die offenen Karten des Stapels (CardView) -var offer_layer: Control -var deck_label: Label -var draw_enabled := false +var deck: Deck +var opp_deck: Deck # Stapel des Gegners, nur zum Ansehen var log_overlay: Control -var b_log: Button +var options: Control # Optionsmenü (Esc): Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden var status_panel: Panel var fx_layer: Control var log_box: RichTextLabel @@ -74,8 +71,6 @@ var name_label: Label var life_label: Label var energy_label: Label var msg_label: Label -var b_end: Button -var b_giveup: Button var element_view: ElementView # eigene Elementleiste var opp_element_view: ElementView # die des Gegners darüber var info_label: Label # Vorschau: Elementbonus, Merkmale @@ -89,7 +84,6 @@ var reward_label: Label var notice_label: Label var confirm: Control var cursor_layer: Control -var wait_label: Label var b_skip: Button var b_undo: Button @@ -98,30 +92,6 @@ func _init(campaign_stage: int = -1) -> void: stage = campaign_stage -static func online(seat: int) -> BattleScreen: - var b := BattleScreen.new(-1) - b.net_play = true - b.online_seat = seat - return b - - -func is_online() -> bool: - return net_play - - -## Zuschauer im Netzwerk: kein eigener Platz, keine Hand. -func spectating() -> bool: - return is_online() and online_seat < 0 - - -func is_client() -> bool: - return is_online() and not net.is_host - - -func counts_for_profile() -> bool: - return not is_online() - - func _ready() -> void: var s: Dictionary = app.profile.settings fx = BattleFx.new(self) @@ -129,31 +99,19 @@ func _ready() -> void: pad = BattleGamepad.new(self) ai_delay = 0.75 / float(s.ai_speed) _build_static() - if is_online(): - net = app.net - net.ended.connect(_on_net_ended) - net.migrated.connect(_on_migrated) - net.status_changed.connect(_on_net_status) - if net.is_host: - game = net.game - net.action_applied.connect(after_action) - else: - net.snapshot_received.connect(_on_snapshot) - wait_label.visible = true - return # der erste Zustand kommt vom Host - elif stage >= 0: + if stage >= 0: var r := Game.make_rng() - game = Game.new_battle_with(Campaign.opponent(stage), r, false, own_rules()) + game = Game.new_battle_with(Campaign.opponent(stage, r), r, false, own_rules()) else: game = Game.new_battle(Game.make_rng(), own_rules()) - smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile() + smart_ai = bool(app.profile.difficulty().smart) _begin() -## Kartenoptik, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1). +## Eigener Stapel und Kartenstufen, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1). func own_rules() -> Dictionary: var p: Profile = app.profile - var rules := {"looks": p.looks()} + var rules := {"looks": p.looks(), "deck": p.deck_cards()} if stage < 0: rules["opponent_life"] = int(p.difficulty().life) if p.perk != "" and p.perk_unlocked(): @@ -162,37 +120,22 @@ func own_rules() -> Dictionary: func _begin() -> void: - if is_online(): - perspective = game.players[maxi(online_seat, 0)] - else: - game.events.clear() - perspective = game.current() if game.current().is_human else game.humans[0] + game.events.clear() + perspective = game.human build_hero_boxes() - if is_online() and net.is_host: - net.broadcast() - game.events.clear() refresh() announce_turn() func _build_static() -> void: var L := BattleLayout - # oben links: Aufgeben und Verlauf, klein - b_giveup = Style.button(self, "Aufgeben", L.B_GIVEUP, func(): confirm.visible = true) - b_giveup.add_theme_font_size_override("font_size", 13) - b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log) - b_log.add_theme_font_size_override("font_size", 13) - b_end = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY) - b_end.add_theme_font_size_override("font_size", 15) - b_skip = Style.button(self, "Überspringen", L.B_END, func(): skip_ai = true) - b_skip.add_theme_font_size_override("font_size", 15) + b_skip = Style.button(self, "Überspringen", L.B_SKIP, func(): skip_ai = true) + b_skip.add_theme_font_size_override("font_size", 13) b_skip.visible = false - # Mitte: Elementzähler, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben - Style.panel(self, L.FIELD_OPP, Style.darker(Style.PANEL, 0.9), 8) - opp_element_view = ElementView.new(L.FIELD_OPP) + # Elementzähler beider Seiten links neben ihrem Brett + opp_element_view = ElementView.new(L.OPP_ELEMENTS) add_child(opp_element_view) - Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8) - element_view = ElementView.new(L.FIELD) + element_view = ElementView.new(L.OWN_ELEMENTS) add_child(element_view) msg_label = Style.label(self, "", 14, Style.ORANGE, L.MSG, "center", true) msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -207,18 +150,21 @@ func _build_static() -> void: var sx: float = L.STATUS.position.x var sy: float = L.STATUS.position.y var sw: float = L.STATUS.size.x - name_label = Style.label(self, "", 18, Style.TEXT, Rect2(sx + 8, sy + 6, sw - 16, 26), "center", true) + name_label = Style.label(self, "", 16, Style.TEXT, Rect2(sx + 8, sy + 4, sw - 16, 22), "center", true) name_label.clip_text = true - life_label = Style.label(self, "", 30, Style.RED, Rect2(sx + 8, sy + 42, sw / 2 - 8, 40), "left", true) - energy_label = Style.label(self, "", 30, Style.BLUE, Rect2(sx + sw / 2, sy + 42, sw / 2 - 8, 40), "right", true) + life_label = Style.label(self, "", 26, Style.RED, Rect2(sx + 10, sy + 30, sw / 2 - 10, 34), "left", true) + energy_label = Style.label(self, "", 26, Style.BLUE, Rect2(sx + sw / 2, sy + 30, sw / 2 - 10, 34), "right", true) b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo) b_undo.add_theme_font_size_override("font_size", 13) b_undo.visible = false - deck_label = Style.label(self, "", 13, Style.MUTED, L.DECK_LABEL, "center") - wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center") - wait_label.visible = false + deck = Deck.new(L.DECK) + deck.clicked.connect(func(): on_click("deck", null)) + add_child(deck) + opp_deck = Deck.new(L.OPP_DECK) + opp_deck.interactive = false + opp_deck.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(opp_deck) hero_layer = _layer() - offer_layer = _layer() tile_layer = _layer() hand_layer = _layer() preview_holder = _layer() @@ -252,6 +198,28 @@ func _build_static() -> void: log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE log_overlay.add_child(log_box) Style.button(log_overlay, "Schließen", Rect2(L.LOG_PANEL.end.x - 180, L.LOG_PANEL.end.y - 52, 160, 40), func(): log_overlay.visible = false) + # Optionsmenü (Esc) + options = Style.overlay(self) + options.get_child(0).gui_input.connect(func(ev): + if ev is InputEventMouseButton and ev.pressed: + options.visible = false) + Style.panel(options, L.OPTIONS, Style.PANEL, 10, Style.GOLD, 2) + Style.label(options, "Optionen", 24, Style.GOLD, Rect2(L.OPTIONS.position.x, L.OPTIONS.position.y + 18, L.OPTIONS.size.x, 34), "center", true) + var oy := L.OPTIONS.position.y + 76 + for entry in [["Verlauf", func(): + options.visible = false + log_overlay.visible = true], + ["Aufgeben", func(): + options.visible = false + confirm.visible = true], + ["Einstellungen", func(): + options.visible = false + app.show_screen(SettingsScreen.new(), true)], + ["Hauptmenü", leave], + ["Beenden", func(): get_tree().quit()]]: + var ob := Style.button(options, entry[0], Rect2(L.OPTIONS.position.x + 60, oy, L.OPTIONS.size.x - 120, 48), entry[1]) + ob.add_theme_font_size_override("font_size", 18) + oy += 60 # Spielende overlay = _overlay() result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true) @@ -261,8 +229,8 @@ func _build_static() -> void: confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen") -func toggle_log() -> void: - log_overlay.visible = not log_overlay.visible +func toggle_options() -> void: + options.visible = not options.visible func _layer() -> Control: @@ -277,8 +245,6 @@ func _overlay() -> Control: func leave() -> void: - if is_online(): - net.leave() app.show_screen(MenuScreen.new()) @@ -336,24 +302,22 @@ func build_hero_boxes() -> void: var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name if not o.perks.is_empty(): title += " ★" - labels["name"] = Style.label(hero_layer, title, 18, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 6, box.size.x - 16, 26), "center", true) + labels["name"] = Style.label(hero_layer, title, 16, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 4, box.size.x - 16, 22), "center", true) labels.name.clip_text = true labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS - labels["life"] = Style.label(hero_layer, "", 30, Style.RED, Rect2(box.position.x + 8, box.position.y + 42, box.size.x / 2 - 8, 40), "left", true) - labels["stats"] = Style.label(hero_layer, "", 30, Style.BLUE, Rect2(box.position.x + box.size.x / 2, box.position.y + 42, box.size.x / 2 - 8, 40), "right", true) + labels["life"] = Style.label(hero_layer, "", 26, Style.RED, Rect2(box.position.x + 10, box.position.y + 30, box.size.x / 2 - 10, 34), "left", true) + labels["stats"] = Style.label(hero_layer, "", 26, Style.BLUE, Rect2(box.position.x + box.size.x / 2, box.position.y + 30, box.size.x / 2 - 10, 34), "right", true) hero_labels[o] = labels # --- Ablauf und Eingabe ------------------------------------------------------------------ func my_turn() -> bool: - if spectating(): - return false return game != null and game.winner == "" and game.current() == perspective func any_overlay() -> bool: - return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible + return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible or options.visible func _input(event: InputEvent) -> void: @@ -400,10 +364,12 @@ func _input(event: InputEvent) -> void: close_menu() elif log_overlay.visible: log_overlay.visible = false + elif options.visible: + options.visible = false elif selected != null or fuse_first != null: cancel_all() elif not any_overlay(): - confirm.visible = true + options.visible = true KEY_SPACE: if my_turn() and not any_overlay(): end_turn() @@ -451,8 +417,6 @@ func _process(delta: float) -> void: _end_drag() do_fuse(a, b) after_action() - if is_online() and net.is_host: - net.tick() if game.winner != "": if not ended: ended = true @@ -461,8 +425,6 @@ func _process(delta: float) -> void: var cur := game.current() if cur.is_human: return - if is_client(): - return if skip_ai: ai_timer = 0.0 else: @@ -506,15 +468,10 @@ func _exit_tree() -> void: 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, stage, factor) - app.save() - app.sfx.play("win" if won else "lose") - else: - var wp := game.winner_player - var mine: bool = wp != null and wp == perspective - app.sfx.play("win" if mine else "lose") + var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0 + reward = app.profile.record_battle(won, stage, factor) + app.save() + app.sfx.play("win" if won else "lose") refresh() @@ -631,10 +588,7 @@ func fusion_partners(target) -> Array: func play(card: Cards.Card, face_down: bool = false) -> void: - if is_client(): - net.send_action({"type": "play", "hand": perspective.hand.find(card), "face_down": face_down}) - return - var before: Game = game.clone() if not is_online() else null + var before: Game = game.clone() var log_len := game.log.size() var from := _hand_rect_of(card) var err := game.play_card(perspective, card, face_down) @@ -655,9 +609,6 @@ func play(card: Cards.Card, face_down: bool = false) -> void: ## Deckt ein eigenes verdecktes Wesen auf (kostenlos). func do_reveal(cr: Game.Creature) -> void: clear_undo() - if is_client(): - net.send_action({"type": "reveal", "uid": cr.uid}) - return var err := game.reveal(perspective, cr) if err != "": say(tr(err)) @@ -667,7 +618,7 @@ func do_reveal(cr: Game.Creature) -> void: ## Kartenmenü neben einer Handkarte oder einem eigenen Wesen. full: auch verschmelzen anbieten ## (Gamepad, das nicht ziehen kann). func open_menu(target, anchor: Rect2, full: bool = false) -> void: - if spectating() or not my_turn(): + if not my_turn(): return cancel() Style.clear(menu_layer) @@ -685,7 +636,7 @@ func open_menu(target, anchor: Rect2, full: bool = false) -> void: entries.append([tr("Aufdecken"), func(): do_reveal(target), game.can_reveal(perspective, target)[0]]) if full: var partners := fusion_partners(target) - entries.append([tr("Verschmelzen mit … ◆ %d") % card.fusion_cost(), func(): + entries.append([tr("Verschmelzen mit …"), func(): fuse_first = target say(tr("Ziel wählen")) refresh(), not partners.is_empty()]) @@ -729,22 +680,16 @@ func close_menu() -> void: ## Abwerfen: eine Handkarte gegen Mana loswerden. func do_discard(card: Cards.Card) -> void: clear_undo() - if is_client(): - net.send_action({"type": "discard", "hand": perspective.hand.find(card)}) - return var err := game.discard(perspective, card) if err != "": say(tr(err)) after_action() -## Ziehen: kostet Mana, eine Karte kommt auf die Hand. -func do_draw(index: int = 0) -> void: +## Ziehen: eine Karte vom eigenen Stapel, die erste im Zug frei. +func do_draw() -> void: clear_undo() - if is_client(): - net.send_action({"type": "draw", "index": index}) - return - var err := game.draw(perspective, index) + var err := game.draw(perspective) if err != "": say(tr(err)) else: @@ -755,12 +700,6 @@ func do_draw(index: int = 0) -> void: func do_fuse(a, b) -> void: cancel() clear_undo() - if is_client(): - if a is Cards.Card: - net.send_action({"type": "fuse", "hand": perspective.hand.find(a), "other_hand": perspective.hand.find(b)}) - else: - net.send_action({"type": "fuse", "uid": a.uid, "other": b.uid}) - return var err := game.fuse(perspective, a, b) if err != "": say(tr(err)) @@ -784,8 +723,6 @@ func undo() -> void: restored.say("Rückgängig.") game = restored perspective = game.players[idx] - if is_online() and net.is_host: - net.game = game clear_undo() cancel() build_hero_boxes() @@ -797,9 +734,6 @@ func end_turn() -> void: return cancel() clear_undo() - if is_client(): - net.send_action({"type": "end"}) - return game.end_turn() ai_timer = ai_delay after_action() @@ -823,17 +757,14 @@ func on_click(kind: String, obj) -> void: selected = null if kind == "enemy" or kind == "hero": clear_undo() - if is_client(): - net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)}) - else: - var err := g.attack(cr, obj) - if err != "": - say(tr(err)) + var err := g.attack(cr, obj) + if err != "": + say(tr(err)) after_action() return match kind: "deck": - do_draw(int(obj) if obj != null else 0) + do_draw() "hand": var ok := g.can_play(perspective, obj) if not ok[0]: @@ -949,6 +880,41 @@ func update_attack_preview() -> void: v.queue_redraw() +## Der eigene Stapel: Kartenstoß mit Kosten des nächsten Ziehens und Restzahl. +class Deck extends Control: + signal clicked + var enabled := true + var interactive := true # false: der Stapel des Gegners, nur Anzahl + var count := 0 + var cost := 0 + + func _init(rect: Rect2) -> void: + position = rect.position + size = rect.size + mouse_filter = Control.MOUSE_FILTER_STOP + + func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + accept_event() + clicked.emit() + + func _draw() -> void: + for k in range(2, -1, -1): + var r := Rect2(Vector2(k * 3, -k * 3), size - Vector2(6, 12)) + r.position.y += 6 + draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD if enabled and interactive else Style.MUTED, 2), r) + if k == 0: + Style.border(self, r.grow(-12), Color("5a3c82"), 2, 6) + if interactive: + Style.text(self, tr("Ziehen"), 16, Style.TEXT if enabled else Style.MUTED, r.get_center() + Vector2(0, -12), "center", true) + Style.text(self, "◆ %d" % cost, 20, Style.BLUE if enabled else Style.MUTED, r.get_center() + Vector2(0, 14), "center", true) + Style.text(self, str(count), 12, Style.MUTED, Vector2(r.get_center().x, r.end.y - 18), "center") + else: + Style.text(self, str(count), 22, Style.TEXT, r.get_center(), "center", true) + if interactive and not enabled: + draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35)) + + ## Eine verdeckte Handkarte des Gegners in voller Größe. class CardBack extends Control: func _init(rect: Rect2) -> void: @@ -967,51 +933,10 @@ class CardBack extends Control: -# --- Netzwerk ------------------------------------------------------------------------------- - -func _on_snapshot(snap: Dictionary) -> void: - var first := game == null - game = Game.from_snapshot(snap) - wait_label.visible = false - if first: - _begin() - return - perspective = game.players[maxi(online_seat, 0)] - build_hero_boxes() - after_action() - - -## Host-Wechsel: das Spiel läuft jetzt auf diesem Rechner weiter. -func _on_migrated() -> void: - game = net.game - if not net.action_applied.is_connected(after_action): - net.action_applied.connect(after_action) - wait_label.visible = false - perspective = game.players[online_seat] - build_hero_boxes() - say("Du bist jetzt Host.") - after_action() - - -func _on_net_status() -> void: - wait_label.text = net.status - wait_label.visible = true - - -func _on_net_ended(reason: String) -> void: - if game == null or game.winner == "": - say(reason) - await get_tree().create_timer(1.5).timeout - if is_inside_tree(): - app.show_screen(MenuScreen.new()) - - # --- Ereignisse ---------------------------------------------------------------------------- ## Nach jeder Aktion: Ereignisse sichern, Ansicht angleichen, Animationen abspielen. func after_action() -> void: - if is_online() and net.is_host: - net.broadcast() var evs := game.events.duplicate() game.events.clear() var centers := {} @@ -1138,23 +1063,12 @@ func refresh() -> void: tiles.erase(uid) fx.fade_out(t) update_attack_preview() - Style.clear(offer_layer) - offer_views = [] - draw_enabled = mine and g.can_draw(h)[0] - if not spectating(): - for i in h.offer.size(): - var oc: Cards.Card = h.offer[i] - var orect := Rect2(BattleLayout.OFFER_X, BattleLayout.OFFER_Y + i * (BattleLayout.HAND_H + BattleLayout.OFFER_GAP), BattleLayout.HAND_W, BattleLayout.HAND_H) - var ov := CardView.new(oc, orect.size, false, oc.rarity_in(h.looks)) - ov.position = orect.position - ov.dim = not draw_enabled - var idx := i - ov.clicked.connect(func(_v): on_click("deck", idx)) - ov.hover.connect(func(c, e): show_preview(c, e, h.looks, "", orect)) - offer_layer.add_child(ov) - offer_views.append(ov) - deck_label.text = "%s ◆ %d · %d" % [tr("Ziehen"), g.draw_cost(h), g.deck.size()] - deck_label.visible = not spectating() + deck.enabled = mine and g.can_draw(h)[0] + deck.count = h.deck.size() + deck.cost = g.draw_cost(h) + deck.queue_redraw() + opp_deck.count = opponent().deck.size() + opp_deck.queue_redraw() Style.clear(hand_layer) for o in others(): # die Hand des Gegners oben, nur Rückseiten, spiegelverkehrter Bogen @@ -1165,9 +1079,7 @@ func refresh() -> void: back.rotation_degrees = arc[0] back.position.y += arc[1] hand_layer.add_child(back) - if spectating(): - Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(240, BattleLayout.HAND_Y + 18, 800, 30), "center") - else: + if true: for i in h.hand.size(): var card: Cards.Card = h.hand[i] var playable: bool = mine and g.can_play(h, card)[0] @@ -1199,10 +1111,8 @@ func refresh() -> void: name_label.text = h.name life_label.text = "♥ %d" % maxi(h.life, 0) energy_label.text = "◆ %d" % h.energy - b_end.disabled = not mine - var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client() + var ai_running: bool = g.winner == "" and not g.current().is_human b_skip.visible = ai_running - b_end.visible = not ai_running and not spectating() b_undo.visible = undo_state != null and mine element_view.update_from(g, h) opp_element_view.update_from(g, opponent()) @@ -1222,20 +1132,13 @@ func refresh() -> void: pad.draw_cursor() if g.winner != "": - var wp := g.winner_player - if counts_for_profile(): - var won := g.winner == "human" - result_label.text = "Sieg!" if won else "Niederlage" - result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED) - reward_label.text = "+%d Münzen" % maxi(reward, 0) - var notes: Array[String] = app.profile.take_notices() - if not notes.is_empty(): - notice_label.text = "\n".join(notes) - else: - var mine_win: bool = wp != null and wp == perspective - result_label.text = ("%s gewinnt!" % wp.name) if wp != null and wp.is_human else "Die KI gewinnt" - result_label.add_theme_color_override("font_color", Style.GREEN if mine_win else Style.RED) - reward_label.text = "Gemeinsame Partie, keine Münzen" + var won := g.winner == "human" + result_label.text = "Sieg!" if won else "Niederlage" + result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED) + reward_label.text = "+%d Münzen" % maxi(reward, 0) + var notes: Array[String] = app.profile.take_notices() + if not notes.is_empty(): + notice_label.text = "\n".join(notes) overlay.visible = true confirm.visible = false diff --git a/scripts/ui/booster_screen.gd b/scripts/ui/booster_screen.gd index f4de3cc..366805a 100644 --- a/scripts/ui/booster_screen.gd +++ b/scripts/ui/booster_screen.gd @@ -137,13 +137,15 @@ func refresh() -> void: elif revealed.has(i): var res: Dictionary = cards[i] var c: Cards.Card = res.card - var v := CardView.new(c, r.size, false, res.rarity) + var v := CardView.new(c.leveled(Cards.RARITIES.find(res.rarity)), r.size, false, res.rarity) v.position = r.position slots.add_child(v) if fresh.has(i): flip(v, fresh[i], res.rarity) Style.label(slots, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true) - if res.upgraded: + if res.get("new", false): + Style.label(slots, "Neu!", 16, Style.GOLD, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true) + elif 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, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center") diff --git a/scripts/ui/collection_screen.gd b/scripts/ui/collection_screen.gd index fe1b51a..9c4aed4 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 gehören dir. Gesammelt wird die Seltenheitsstufe — reine Optik. -## Aufwerten kostet Staub, eine Stufe abgeben bringt Staub. +## Sammlung: alle Karten des Spiels, die fehlenden abgedunkelt. Eigene Karten lassen sich +## aufwerten (Staub) oder zerlegen (bringt Staub), fehlende herstellen (Staub). var app var filter := "" @@ -15,6 +15,7 @@ var preview_holder: Control var info_label: Label var b_dismantle: Button var b_upgrade: Button +var b_craft: Button func _ready() -> void: @@ -43,6 +44,7 @@ func _ready() -> void: info_label = Style.label(self, "", 15, Style.MUTED, Rect2(840, 476, 380, 30), "center") b_upgrade = Style.button(self, "", Rect2(840, 530, 380, 46), upgrade, Style.PRIMARY) b_dismantle = Style.button(self, "", Rect2(840, 586, 380, 46), dismantle, Color("5a3c28")) + b_craft = Style.button(self, "", Rect2(840, 530, 380, 46), craft, Style.PRIMARY) msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(840, 650, 380, 48), "center") refresh() @@ -57,7 +59,7 @@ func set_filter(e: String) -> void: refresh() -## Aufgewertete Karten zuerst, sonst in Kartenreihenfolge. +## Eigene Karten zuerst (höhere Stufen vorn), dann die fehlenden, sonst in Kartenreihenfolge. func cards() -> Array: var out: Array = Cards.all().duplicate() if filter != "": @@ -69,7 +71,10 @@ func cards() -> Array: var order := {} for i in out.size(): order[out[i].id] = i - out.sort_custom(func(a, b): return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id]) + out.sort_custom(func(a, b): + if p.owns(a.id) != p.owns(b.id): + return p.owns(a.id) + return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id]) return out @@ -111,11 +116,31 @@ func dismantle() -> void: refresh() +func craft() -> void: + if selected == null: + return + var err: String = app.profile.craft(selected.id) + if err == "": + app.sfx.play("combo") + app.save() + say(tr("%s hergestellt") % selected.title(), true) + else: + say(err, false) + refresh() + + func refresh() -> void: var p: Profile = app.profile dust_label.text = "Staub: %d" % p.dust - grid.fill(cards(), func(c): return p.rarity(c.id)) + var shown := cards() + grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look]) + var views := grid.views() + for i in views.size(): + views[i].dim = not p.owns(shown[i].id) Style.clear(preview_holder) + b_craft.visible = false + b_upgrade.visible = true + b_dismantle.visible = true if selected == null: info_label.text = "Keine Karte gewählt" b_upgrade.text = "Aufwerten" @@ -123,11 +148,19 @@ func refresh() -> void: b_upgrade.disabled = true b_dismantle.disabled = true return - var v := CardView.new(selected, Vector2(220, 308), false, p.rarity(selected.id)) + var v := CardView.new(selected.leveled(p.level(selected.id)), Vector2(220, 308), false, p.rarity(selected.id)) v.position = Vector2(920, 152) v.mouse_filter = Control.MOUSE_FILTER_IGNORE preview_holder.add_child(v) var lvl := p.level(selected.id) + if not p.owns(selected.id): + info_label.text = tr("Nicht in der Sammlung") + b_upgrade.visible = false + b_dismantle.visible = false + b_craft.visible = true + b_craft.text = tr("Herstellen (%d Staub)") % Profile.CRAFT_COST + b_craft.disabled = p.dust < Profile.CRAFT_COST + return 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" @@ -136,5 +169,5 @@ func refresh() -> void: 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 + b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]] + b_dismantle.disabled = false diff --git a/scripts/ui/deck_screen.gd b/scripts/ui/deck_screen.gd new file mode 100644 index 0000000..0eb7c12 --- /dev/null +++ b/scripts/ui/deck_screen.gd @@ -0,0 +1,93 @@ +class_name DeckScreen +extends Control +## Deck zusammenstellen: alle eigenen Karten im Raster, ein Klick nimmt eine Karte in den +## Stapel oder wieder heraus. Karten außerhalb des Stapels sind abgedunkelt. Der Stapel hält +## 40 bis 60 Karten; „Auffüllen“ nimmt die teuersten eigenen Karten des gewählten Filters. + +var app +var filter := "" +var grid: CardGrid +var count_label: Label +var msg_label: Label + + +func _ready() -> void: + Style.header(self, "Deck", func(): app.show_screen(MenuScreen.new())) + Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind("")) + for i in Cards.ELEMENTS.size(): + var e: String = Cards.ELEMENTS[i] + var fb := Style.button(self, e, Rect2(108 + i * 82, 86, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) + fb.add_theme_font_size_override("font_size", 14) + grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650)) + grid.card_clicked.connect(toggle) + add_child(grid) + Style.panel(self, Rect2(820, 86, 420, 696)) + count_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE) + var hint := Style.label(self, "Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die teuersten Karten des Filters.", 15, Style.MUTED, Rect2(840, 160, 380, 60), "center") + hint.autowrap_mode = TextServer.AUTOWRAP_WORD + Style.button(self, "Auffüllen", Rect2(840, 240, 380, 46), fill_up) + Style.button(self, "Leeren", Rect2(840, 296, 380, 46), clear_deck, Color("5a3c28")) + msg_label = Style.label(self, "", 15, Style.RED, Rect2(840, 360, 380, 48), "center") + refresh() + + +func _input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel"): + app.show_screen(MenuScreen.new()) + + +func set_filter(e: String) -> void: + filter = e + refresh() + + +func toggle(card: Cards.Card) -> void: + var err: String = app.profile.toggle_deck(card.id) + msg_label.text = err + if err == "": + app.sfx.play("click") + app.save() + refresh() + + +## Füllt mit den teuersten eigenen Karten des gewählten Filters bis zum Höchstwert auf. +func fill_up() -> void: + var p: Profile = app.profile + var pool: Array = p.collection.keys() + if filter != "": + pool = pool.filter(func(id): return filter in Cards.by_id(id).elements) + p.deck = DeckGen.fill(p.deck, pool, Game.DECK_MAX) + app.save() + refresh() + + +func clear_deck() -> void: + app.profile.deck = [] + app.save() + refresh() + + +## Eigene Karten, die im Stapel zuerst, sonst nach Kosten. +func cards() -> Array: + var p: Profile = app.profile + var out: Array = p.collection.keys().map(func(id): return Cards.by_id(id)) + if filter != "": + out = out.filter(func(c): return filter in c.elements) + out.sort_custom(func(a, b): + var ia: bool = a.id in p.deck + var ib: bool = b.id in p.deck + if ia != ib: + return ia + return a.cost < b.cost if a.cost != b.cost else a.id < b.id) + return out + + +func refresh() -> void: + var p: Profile = app.profile + count_label.text = tr("Stapel: %d Karten (%d bis %d)") % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX] + count_label.add_theme_color_override("font_color", Style.RED if p.deck.size() < Game.DECK_MIN else Style.BLUE) + var shown := cards() + grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look]) + var views := grid.views() + for i in views.size(): + views[i].dim = not (shown[i].id in p.deck) diff --git a/scripts/ui/deck_screen.gd.uid b/scripts/ui/deck_screen.gd.uid new file mode 100644 index 0000000..ed5ce3f --- /dev/null +++ b/scripts/ui/deck_screen.gd.uid @@ -0,0 +1 @@ +uid://ctt36od26uyea diff --git a/scripts/ui/element_view.gd b/scripts/ui/element_view.gd index b57efca..4bd03dd 100644 --- a/scripts/ui/element_view.gd +++ b/scripts/ui/element_view.gd @@ -1,10 +1,11 @@ class_name ElementView extends Control -## Elementzähler der eigenen Seite, mittig: je Element zweimal das Symbol seines Sondereffekts in -## der Elementfarbe, leuchtend je offenem eigenen Wesen (beide: Effekt aktiv), daneben in der -## Elementfarbe das Mana der bisher ausgespielten Karten (je Game.ELEMENT_MANA gibt es +1 Angriff). +## Elementzähler einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente da, bei denen +## etwas passiert ist: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je +## offenem Wesen, beide: Effekt aktiv), die Zahl der ausgespielten Karten und ab +## Game.ELEMENT_COUNT der Bonus +1/+1. -const ENTRY_W := 180.0 +const ROW := 32.0 var own := {} # offene eigene Wesen je Element var played := {} # ausgespielte Karten je Element @@ -31,37 +32,41 @@ func paired(e: String) -> bool: func bonus(e: String) -> int: - return int(played.get(e, 0)) / Game.ELEMENT_MANA + return int(played.get(e, 0)) / Game.ELEMENT_COUNT + + +## Elemente, bei denen sich etwas getan hat (offene Wesen oder ausgespielte Karten). +func shown() -> Array: + return Cards.ELEMENTS.filter(func(e): return int(own.get(e, 0)) > 0 or int(played.get(e, 0)) > 0) ## Der Inhalt als Text (Tests, Protokoll): Element offen/gespielt, dazu Effekt und Bonus. func text() -> String: var parts: Array[String] = [] - for e in Cards.ELEMENTS: + for e in shown(): var s := "%s %d/%d" % [tr(e), int(own.get(e, 0)), int(played.get(e, 0))] if paired(e): s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e]) if bonus(e) > 0: - s += " (+%d)" % bonus(e) + s += " (+%d/+%d)" % [bonus(e), bonus(e)] parts.append(s) return " · ".join(parts) func _draw() -> void: - var x0 := (size.x - ENTRY_W * Cards.ELEMENTS.size()) / 2 - var cy := size.y / 2 - for i in Cards.ELEMENTS.size(): - var e: String = Cards.ELEMENTS[i] + var y := ROW / 2 + for e in shown(): var col: Color = Cards.ELEMENT_COLORS[e] var active := paired(e) or bonus(e) > 0 - var px := x0 + i * ENTRY_W + 10 - Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, cy), "midleft", active) - px += Style.FONT_BOLD.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 10 + draw_style_box(Style.box(Color(col, 0.18 if active else 0.1), 6, Color(col, 0.6 if active else 0.0), 1 if active else 0), Rect2(0, y - ROW / 2 + 2, size.x, ROW - 4)) + var px := 10.0 + Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, y), "midleft", active) + px = 76 for k in Game.ELEMENT_PAIR: var filled := k < int(own.get(e, 0)) - Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, cy), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3)) + Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, y), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3)) px += Game.ELEMENT_PAIR * 24 + 8 - Style.text(self, str(int(played.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, cy), "midleft", true) - px += 20 + Style.text(self, str(int(played.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true) if bonus(e) > 0: - Style.text(self, "+%d" % bonus(e), 12, Style.GREEN, Vector2(px, cy), "midleft", true) + Style.text(self, "+%d/+%d" % [bonus(e), bonus(e)], 12, Style.GREEN, Vector2(size.x - 6, y), "midright", true) + y += ROW diff --git a/scripts/ui/lobby_screen.gd b/scripts/ui/lobby_screen.gd deleted file mode 100644 index 40f9c20..0000000 --- a/scripts/ui/lobby_screen.gd +++ /dev/null @@ -1,107 +0,0 @@ -class_name LobbyScreen -extends Control -## Netzwerkspiel im lokalen Netz: Spiel eröffnen oder beitreten. - -var app -var net: Net -var status_label: Label -var list_label: Label -var ip_box: LineEdit -var b_host: Button -var b_join: Button -var b_watch: Button -var b_start: Button -var addr_label: Label - - -func _ready() -> void: - net = app.net - net.my_name = "Spieler" - net.my_pick = {"looks": app.profile.looks()} - Style.header(self, "Netzwerkspiel", _back) - var col := Style.column(self, 760, 110, 10) - var hr := Style.row(col, 12) - hr.alignment = BoxContainer.ALIGNMENT_CENTER - b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c")) - ip_box = LineEdit.new() - ip_box.custom_minimum_size = Vector2(220, 46) - ip_box.placeholder_text = "Adresse des Hosts" - ip_box.text = "127.0.0.1" - hr.add_child(ip_box) - b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join) - b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch) - addr_label = Style.clabel(col, "", 14, Style.MUTED) - status_label = Style.clabel(col, "", 16, Style.BLUE) - Style.spacer(col, 6) - Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true) - list_label = Style.clabel(col, "", 16, Style.TEXT) - Style.spacer(col, 10) - var br := Style.row(col, 20) - br.alignment = BoxContainer.ALIGNMENT_CENTER - b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c")) - net.lobby_changed.connect(refresh) - net.started.connect(_started) - net.ended.connect(func(_reason): refresh()) - refresh() - - -func _exit_tree() -> void: - if net.lobby_changed.is_connected(refresh): - net.lobby_changed.disconnect(refresh) - if net.started.is_connected(_started): - net.started.disconnect(_started) - - -func _host() -> void: - net.host() - refresh() - - -func _join() -> void: - net.join(ip_box.text.strip_edges()) - refresh() - - -## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit. -func _watch() -> void: - net.spectator = true - net.join(ip_box.text.strip_edges()) - refresh() - - -func _start() -> void: - net.start_battle() - - -func _started(seat: int) -> void: - app.show_screen(BattleScreen.online(seat)) - - -func _back() -> void: - net.leave() - app.show_screen(MenuScreen.new()) - - -func refresh() -> void: - status_label.text = net.status - var lines: Array[String] = [] - var ids: Array = net.names.keys() - ids.sort() - for id in ids: - lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""]) - for id in net.spectators: - lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")]) - list_label.text = "\n".join(lines) if not lines.is_empty() else "–" - b_host.disabled = net.connected - b_join.disabled = net.connected - b_watch.disabled = net.connected - ip_box.editable = not net.connected - var humans := ids.size() - b_start.disabled = not net.is_host - b_start.text = tr("Start") if humans >= 2 else tr("Start gegen die KI") - addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else "" - - -func _input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not ip_box.has_focus(): - _back() diff --git a/scripts/ui/lobby_screen.gd.uid b/scripts/ui/lobby_screen.gd.uid deleted file mode 100644 index 5b3846c..0000000 --- a/scripts/ui/lobby_screen.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bhnnxjh6rsxnf diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index a363b7e..803d6ac 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -5,7 +5,6 @@ var profile: Profile var screen: Control var save_path: String = Profile.SAVE_PATH var sfx: Sfx -var net: Net func _ready() -> void: @@ -17,14 +16,9 @@ func _ready() -> void: add_child(bg) sfx = Sfx.new() add_child(sfx) - # Fester Pfad /root/Net: Netzwerkaufrufe werden über den Knotenpfad zugestellt, - # der bei Host und Mitspielern gleich sein muss. - net = Net.new() - net.name = "Net" - get_tree().root.add_child.call_deferred(net) profile = Profile.load_or_new(save_path) apply_settings() - show_screen(MenuScreen.new()) + show_screen(MenuScreen.new() if profile.started() else StartDeckScreen.new()) func apply_settings() -> void: @@ -36,20 +30,32 @@ func apply_settings() -> void: theme = Style.make_theme() -## Neues Profil: Sammlung und Fortschritt werden verworfen. +## Neues Profil: Sammlung und Fortschritt werden verworfen, das Startdeck wird neu gewählt. func reset_profile() -> void: profile = Profile.starter() save() apply_settings() + show_screen(StartDeckScreen.new()) func set_language(code: String) -> void: TranslationServer.set_locale(code) -func show_screen(s: Control) -> void: +var kept: Array = [] # vorige Bildschirme, zu denen back() zurückführt (Einstellungen aus dem Kampf) + + +## keep: der aktuelle Bildschirm bleibt erhalten und kommt mit back() zurück. +func show_screen(s: Control, keep: bool = false) -> void: if screen != null: - screen.queue_free() + if keep: + remove_child(screen) + kept.append(screen) + else: + screen.queue_free() + for k in kept: + k.queue_free() + kept.clear() screen = s s.app = self s.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -106,11 +112,17 @@ func _first_button(node: Node) -> Button: return null +## Zurück: zum gemerkten Bildschirm, sonst ins Hauptmenü. +func back() -> void: + if kept.is_empty(): + show_screen(MenuScreen.new()) + return + var prev: Control = kept.pop_back() + screen.queue_free() + screen = prev + add_child(prev) + + func save() -> void: profile.save(save_path) - -func _exit_tree() -> void: - if net != null and net.is_inside_tree(): - net.leave() - net.queue_free() diff --git a/scripts/ui/menu_screen.gd b/scripts/ui/menu_screen.gd index 3ae45e7..0c7e7af 100644 --- a/scripts/ui/menu_screen.gd +++ b/scripts/ui/menu_screen.gd @@ -17,7 +17,7 @@ func _ready() -> void: var y := 230 for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())], ["Kampagne", func(): app.show_screen(CampaignScreen.new())], - ["Netzwerkspiel", func(): app.show_screen(LobbyScreen.new())]]: + ["Deck", func(): app.show_screen(DeckScreen.new())]]: var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY) b.add_theme_font_size_override("font_size", 24) y += 74 @@ -35,9 +35,9 @@ func _ready() -> void: refresh() -## Drei Karten der Sammlung, die höchsten Stufen zuerst, leicht gefächert. +## Drei eigene Karten, die höchsten Stufen zuerst, leicht gefächert. func _showcase(p: Profile) -> void: - var cards: Array = Cards.all().duplicate() + var cards: Array = p.collection.keys().map(func(id): return Cards.by_id(id)) cards.sort_custom(func(a, b): if p.level(a.id) != p.level(b.id): return p.level(a.id) > p.level(b.id) @@ -45,7 +45,7 @@ func _showcase(p: Profile) -> void: var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]] for i in 3: var c: Cards.Card = cards[i] - var v := CardView.new(c, Vector2(180, 252), false, p.rarity(c.id)) + var v := CardView.new(c.leveled(p.level(c.id)), Vector2(180, 252), false, p.rarity(c.id)) v.position = spots[i][0] v.rotation = spots[i][1] v.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -55,8 +55,8 @@ func _showcase(p: Profile) -> void: func refresh() -> void: var p: Profile = app.profile coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust] - coll_label.text = tr("Aufgewertet: %d von %d Karten · Kampagne: %d/%d") % [ - p.upgraded_count(), Cards.all().size(), p.campaign_stage, Campaign.total()] + coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d") % [ + p.collection.size(), 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/settings_screen.gd b/scripts/ui/settings_screen.gd index 341a20f..9d9bc61 100644 --- a/scripts/ui/settings_screen.gd +++ b/scripts/ui/settings_screen.gd @@ -16,7 +16,7 @@ var speed_label: Label func _ready() -> void: var s: Dictionary = app.profile.settings - Style.header(self, "Einstellungen", func(): app.show_screen(MenuScreen.new())) + Style.header(self, "Einstellungen", func(): app.back()) var col := Style.column(self, 700, 110, 24) var grid := GridContainer.new() grid.columns = 3 @@ -86,7 +86,6 @@ func _ready() -> void: func _reset() -> void: app.reset_profile() - app.show_screen(MenuScreen.new()) func _on_big_font(on: bool) -> void: @@ -139,4 +138,4 @@ func _on_anim(on: bool) -> void: func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: - app.show_screen(MenuScreen.new()) + app.back() diff --git a/scripts/ui/start_deck_screen.gd b/scripts/ui/start_deck_screen.gd new file mode 100644 index 0000000..6ed64b2 --- /dev/null +++ b/scripts/ui/start_deck_screen.gd @@ -0,0 +1,37 @@ +class_name StartDeckScreen +extends Control +## Erster Start: eines von vier Startdecks wählen, je eines mit einem Element im Mittelpunkt +## (25 Karten davon, je fünf der anderen). Danach geht es ins Menü. + +var app + + +func _ready() -> void: + Style.label(self, tr("Wähle dein Startdeck"), 40, Style.GOLD, Rect2(0, 60, 1280, 60), "center", true) + Style.label(self, tr("25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest."), 17, Style.MUTED, Rect2(140, 124, 1000, 30), "center") + for i in Cards.ELEMENTS.size(): + var e: String = Cards.ELEMENTS[i] + var x := 70 + i * 290 + var col: Color = Cards.ELEMENT_COLORS[e] + Style.panel(self, Rect2(x, 190, 260, 470), Style.darker(col, 0.75), 12, col, 2) + var ids := DeckGen.start_deck(e) + var sample: Array = ids.filter(func(id): return Cards.by_id(id).elements[0] == e) + sample.sort_custom(func(a, b): return Cards.by_id(a).cost > Cards.by_id(b).cost) + for k in 3: + var c: Cards.Card = Cards.by_id(sample[k * 7]) + var v := CardView.new(c, Vector2(110, 140), false, "Common") + v.position = Vector2(x + 20 + k * 40, 210 + k * 14) + v.rotation_degrees = -6 + k * 6 + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(v) + Style.label(self, tr(e), 28, Style.TEXT, Rect2(x, 400, 260, 40), "center", true) + Style.label(self, tr(Cards.KEYWORD_TEXT[Cards.ELEMENT_KEYWORD[e]]), 15, Style.MUTED, Rect2(x + 16, 446, 228, 60), "center") + var b := Style.button(self, tr("%s wählen") % tr(e), Rect2(x + 30, 590, 200, 50), choose.bind(e), Style.PRIMARY) + b.add_theme_font_size_override("font_size", 18) + + +func choose(element: String) -> void: + app.profile.choose_start(element) + app.save() + app.sfx.play("combo") + app.show_screen(MenuScreen.new()) diff --git a/scripts/ui/start_deck_screen.gd.uid b/scripts/ui/start_deck_screen.gd.uid new file mode 100644 index 0000000..5257516 --- /dev/null +++ b/scripts/ui/start_deck_screen.gd.uid @@ -0,0 +1 @@ +uid://bxl6isdrgqb2b diff --git a/tests/net_bot.gd b/tests/net_bot.gd deleted file mode 100644 index ad91e2b..0000000 --- a/tests/net_bot.gd +++ /dev/null @@ -1,101 +0,0 @@ -extends Node -## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge; mit --host eröffnet er -## selbst und startet, sobald ein zweiter Mensch am Tisch sitzt (ohne KI). -## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot -## godot --path . tests/net_bot.tscn -- --host --name=BotHost - -var net: Net -var seat := -1 -var game: Game -var t := 0.0 -var hosting := false - - -func _ready() -> void: - var ip := "127.0.0.1" - var bot_name := "Bot" - for a in OS.get_cmdline_user_args(): - if a.begins_with("--ip="): - ip = a.substr(5) - if a.begins_with("--name="): - bot_name = a.substr(7) - if a == "--host": - hosting = true - net = Net.new() - net.name = "Net" - get_tree().root.add_child.call_deferred(net) - net.my_name = bot_name - net.my_pick = {"elements": ["Feuer", "Erde"], "looks": {}} - net.started.connect(func(s): - seat = s - if hosting: - net.broadcast() # erster Zustand für alle, wie es sonst der Kampfbildschirm tut - _act_host.call_deferred()) - net.snapshot_received.connect(_on_snapshot) - net.ended.connect(func(_r): get_tree().quit()) - await get_tree().process_frame - if hosting: - net.host() - net.lobby_changed.connect(func(): - if net.game == null and net.names.size() >= 2: - net.start_battle()) - net.action_applied.connect(func(): - net.broadcast() - _act_host.call_deferred()) - else: - net.join(ip) - - -func _process(delta: float) -> void: - t += delta - if hosting and net.game != null: - net.tick() - if t > 180.0 or FileAccess.file_exists("user://bot_quit"): - net.leave() - get_tree().quit() - - -func _on_snapshot(snap: Dictionary) -> void: - game = Game.from_snapshot(snap) - if game.winner != "": - await get_tree().create_timer(1.0).timeout - get_tree().quit() - return - if game.current_idx != seat: - return - _decide(game.players[seat]) - - -func _act_host() -> void: - if not hosting or net.game == null or net.game.winner != "" or seat < 0 or net.game.current_idx != seat: - return - game = net.game - _decide(game.players[seat]) - - -## Einfache Züge mit kurzer Bedenkzeit: das erste spielbare Wesen legen, dann angreifen, -## dann ziehen, 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 - for i in me.hand.size(): - var card: Cards.Card = me.hand[i] - if game.can_play(me, card)[0]: - net.send_action({"type": "play", "hand": i}) - return - for cr in me.board: - if cr.can_attack(): - for e in game.enemies_of(me): - if game.can_attack(cr, e)[0]: - net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "player", "index": game.players.find(e)}}) - return - for c in e.board: - if game.can_attack(cr, c)[0]: - net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}}) - return - if game.can_draw(me)[0]: - net.send_action({"type": "draw"}) - return - net.send_action({"type": "end"}) diff --git a/tests/net_bot.gd.uid b/tests/net_bot.gd.uid deleted file mode 100644 index 9ffe962..0000000 --- a/tests/net_bot.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dcbqt8ybdkhrb diff --git a/tests/net_bot.tscn b/tests/net_bot.tscn deleted file mode 100644 index ae3b356..0000000 --- a/tests/net_bot.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://tests/net_bot.gd" id="1"] - -[node name="NetBot" type="Node"] -script = ExtResource("1") diff --git a/tests/perf_probe.gd b/tests/perf_probe.gd index 79bc8c5..f5a690a 100644 --- a/tests/perf_probe.gd +++ b/tests/perf_probe.gd @@ -14,7 +14,7 @@ func _initialize() -> void: print("Kartenpool laden: %.1f ms" % t) var r := RandomNumberGenerator.new() r.seed = 3 - print("Stapel mischen: %.2f ms" % ms(func(): DeckGen.deck(r), 20)) + print("Stapel würfeln: %.2f ms" % ms(func(): DeckGen.random_deck(r), 20)) print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20)) for n in [1]: var g := Game.new_battle(r) @@ -22,7 +22,7 @@ func _initialize() -> void: var uid := 5000 for p in g.players: for k in 4: - var c := Game.Creature.new(Cards.by_id("f_wolf"), p, uid) + var c := Game.Creature.new(Cards.by_id("f_3_2"), p, uid) uid += 1 c.ready = true p.board.append(c) @@ -30,7 +30,7 @@ func _initialize() -> void: h.energy = 10 h.max_energy = 10 for k in 6: - h.hand.append(Cards.by_id("f_ritter")) + h.hand.append(Cards.by_id("f_4_3")) AI.time_budget_ms = 100000 var t_full := ms(func(): AI.choose_action(g, h)) AI.time_budget_ms = 150 @@ -39,5 +39,4 @@ func _initialize() -> void: 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(r).clone(), 20)) print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music())) - print("Snapshot: %.2f ms" % ms(func(): Game.new_battle(r).snapshot(0), 10)) quit() diff --git a/tests/test_runner.gd b/tests/test_runner.gd index aada243..ec95432 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -20,10 +20,13 @@ func C(id: String) -> Cards.Card: return Cards.by_id(id).copy() -## Duell mit 10 Mana; der Gegner hat 15 Leben, kein Stapel nötig. Liefert [Spiel, Spieler, Gegner]. +## Duell mit 10 Mana; der Gegner hat 15 Leben; je ein kleiner Stapel, damit keine Erschöpfung +## dazwischenfunkt. Liefert [Spiel, Spieler, Gegner]. func setup(human_hand: Array = [], opp_hand: Array = []) -> Array: var human := Game.Player.new("Spieler", true, 30) var o := Game.Player.new("Gegner", false, 15) + human.deck = ["f_0_1", "f_0_1", "f_0_1", "f_0_1", "f_0_1"] + o.deck = ["w_0_1", "w_0_1", "w_0_1", "w_0_1", "w_0_1"] var r := RandomNumberGenerator.new() r.seed = 0 var g := Game.new(human, o, r) @@ -56,6 +59,7 @@ func _initialize() -> void: test_kartenpool() test_kombos() test_mana_und_kosten() + test_stapel() test_ziehen() test_abwerfen() test_wesen_und_angriff() @@ -68,7 +72,9 @@ func _initialize() -> void: test_gleiche_regeln() test_zugreihenfolge() test_sieg_niederlage() + test_seltenheit() test_booster() + test_sammlung_und_deck() test_spielstand() test_kopie() test_ki_lethal() @@ -81,41 +87,47 @@ func _initialize() -> void: test_spielstand_sicherung() test_merkmale() test_ki_zwei_schritte() - test_snapshot() test_kapitel_zwei_und_merkmalwahl() test_ki_zeitbudget() test_englische_karten() - test_kampf_speichern() test_uebersetzung_vollstaendig() print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed]) quit(1 if failed > 0 else 0) +## 120 Karten: je Element alle Werte von 0/1 bis 5/5, die ID verrät die Werte. func test_kartenpool() -> void: - check(Cards.all().size() == 81, "81 Karten") + check(Cards.all().size() == 120, "120 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) + check(Cards.all().filter(func(c): return c.elements == [e]).size() == 30, "30 Karten je Element: " + e) + var names := {} for c in Cards.all(): - check(c.health > 0 and c.cost == floori((2 * c.attack + c.health) / 3.0), "Kosten = (2 · Angriff + Leben) / 3, abgerundet: " + c.id) - check(c.keywords.is_empty() and c.text() == "", "Karten kommen ohne Eigenschaften: " + c.id) - check(C("f_funke").cost == 0 and C("f_kobold").cost == 1 and C("f_wolf").cost == 2 and C("ff_phoenix").cost == 7 and C("e_wall").cost == 1 and C("f_drache").cost == 5, "Kostenbeispiele, 0/2 kostet nichts") - var a := C("f_wolf") - var b := C("f_wolf") + var parts: PackedStringArray = c.id.split("_") + check(c.attack == int(parts[1]) and c.health == int(parts[2]) and c.attack <= 5 and c.health >= 1 and c.health <= 5, "ID passt zu den Werten: " + c.id) + check(c.cost == floori((2 * c.attack + c.health) / 3.0), "Kosten = (2 · Angriff + Leben) / 3, abgerundet: " + c.id) + check(c.keywords.is_empty() and c.text() == "" and c.name_en != "", "Karten kommen ohne Eigenschaften, mit englischem Namen: " + c.id) + names[c.name] = true + check(names.size() == 120, "alle Namen verschieden") + check(C("f_0_2").cost == 0 and C("f_2_1").cost == 1 and C("f_3_2").cost == 2 and C("f_5_5").cost == 5 and C("e_0_4").cost == 1, "Kostenbeispiele, 0/2 kostet nichts") + check(Cards.cost_of(20, 20) == 10, "Kosten enden bei 10") + check(C("f_3_2").name == "Brennender Kobold" and C("w_4_5").name == "Flutender Leviathan", "Namen aus Eigenschaft und Wesen") + var a := C("f_3_2") + var b := C("f_3_2") a.keywords.append("Spott") - check(b.keywords.is_empty() and Cards.by_id("f_wolf").keywords.is_empty(), "Exemplare teilen sich keine Eigenschaften") + check(b.keywords.is_empty() and Cards.by_id("f_3_2").keywords.is_empty(), "Exemplare teilen sich keine Eigenschaften") 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(not C("ff_phoenix").is_combo() and Cards.fusion_name(["Feuer"]) == "Inferno", "Kombos gibt es nur beim Verschmelzen") + check(not C("f_5_5").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") ## Mana: 1 im ersten Zug, jede Runde eins mehr, der Rest verfällt; Legen kostet die Kartenkosten. func test_mana_und_kosten() -> void: - var s := setup(["f_wolf", "f_drache"]) + var s := setup(["f_3_2", "f_5_5"]) var g: Game = s[0] var h: Game.Player = s[1] h.energy = 0 @@ -130,60 +142,93 @@ func test_mana_und_kosten() -> void: g.begin_turn(h) check(h.energy == Game.START_MANA + 21, "keine Obergrenze") h.energy = 5 - check(g.play_card(h, h.hand[0]) == "" and h.energy == 5 - C("f_wolf").cost, "Ausspielen kostet die Kartenkosten") + check(g.play_card(h, h.hand[0]) == "" and h.energy == 5 - C("f_3_2").cost, "Ausspielen kostet die Kartenkosten") check(g.can_play(h, h.hand[0])[1] == "Nicht genug Mana", "Drache (5) zu teuer bei %d Mana" % h.energy) check(Cards.fusion_value(4, 2) == 5 and Cards.fusion_value(8, 1) == 9 and Cards.fusion_value(2, 2) == 3 and Cards.fusion_value(0, 4) == 5 and Cards.fusion_value(8, 8) == 11, "Fusionswert: besserer Wert plus ein Drittel des schwächeren, mindestens 1") -## Start: fünf im ersten Zug bezahlbare Karten, drei offene auf dem Stapel. Ziehen: eine der drei -## wählen, eine neue rückt nach; das erste Ziehen im Zug ist frei, jedes weitere kostet 2 Mana. +## Stapel: 40 verschiedene Karten, zwölf davon im ersten Zug bezahlbar; Kampagnengegner bekommen +## Stapel mit Kostendeckel und ab Kapitel 2 höhere Stufen; Startdecks je Element; Auffüllen. +func test_stapel() -> void: + var r := RandomNumberGenerator.new() + r.seed = 5 + for i in 5: + var d := DeckGen.random_deck(r) + var uniq := {} + for id in d: + uniq[id] = true + check(d.size() == Game.DECK_MIN and uniq.size() == Game.DECK_MIN, "40 verschiedene Karten") + check(d.filter(func(id): return Cards.by_id(id).cost <= Game.START_MANA).size() >= DeckGen.CHEAP_IN_RANDOM, "mindestens zwölf billige") + var easy := DeckGen.opponent_deck(r, 1) + check(easy.all(func(id): return Cards.by_id(id).cost <= 3), "Kapitel 1: nur bis Kosten 3") + var boss := Campaign.opponent(15, r) + check(boss.deck.size() == Game.DECK_MIN and boss.deck.all(func(id): return Cards.by_id(id).cost <= 5) and boss.looks.values().all(func(l): return l == 1), "Kapitel 2: bis Kosten 5, Stufe Rare") + for e in Cards.ELEMENTS: + var sd := DeckGen.start_deck(e) + var main := sd.filter(func(id): return Cards.by_id(id).elements[0] == e) + check(sd.size() == Game.DECK_MIN and main.size() == 25 and main.all(func(id): return Cards.by_id(id).attack <= 4), "Startdeck %s: 25 Karten des Elements bis Angriff 4" % e) + for o in Cards.ELEMENTS: + if o != e: + var others := sd.filter(func(id): return Cards.by_id(id).elements[0] == o) + check(others.size() == 5 and others.all(func(id): return Cards.by_id(id).cost <= Game.START_MANA), "Startdeck %s: fünf billige %s" % [e, o]) + var filled := DeckGen.fill(["f_0_1"], ["f_0_1", "f_2_1", "e_0_4", "l_1_1", "w_5_5"], 3) + check(filled == ["f_0_1", "w_5_5", "e_0_4"], "Auffüllen: teuerste zuerst, dann nach ID, nichts doppelt, bis zur Grenze") + + +## Start: fünf im ersten Zug bezahlbare Karten vom eigenen Stapel. Ziehen bringt eine Karte; +## die erste im Zug ist frei, dann 1, 2, 3 … Mana. func test_ziehen() -> void: var r := RandomNumberGenerator.new() r.seed = 1 var g := Game.new_battle(r) - check(g.deck.size() == Cards.all().size() * DeckGen.COPIES - 2 * (Game.START_HAND + Game.OFFER), "Stapel: jede Karte dreimal, Hände und Angebote schon ausgegeben") for p in g.players: + check(p.deck.size() == Game.DECK_MIN - Game.START_HAND, "jeder hat seinen Stapel, fünf Karten sind auf der Hand") check(p.hand.size() == Game.START_HAND, "Start: fünf Karten auf der Hand") check(p.hand.all(func(c): return c.cost <= Game.START_MANA), "nur Karten, die im ersten Zug spielbar sind") check(p.hand.all(func(c): return c != Cards.by_id(c.id)), "eigene Exemplare") - check(p.offer.size() == Game.OFFER and p.offer.all(func(c): return c.cost <= Game.START_MANA), "drei offene Karten auf dem Stapel, alle bezahlbar") var first: Game.Player = g.current() check(g.draw(first) == "Deine Hand ist voll", "fünf Handkarten sind das Limit") first.hand.pop_back() - var wanted: Cards.Card = first.offer[1] - var deck_before := g.deck.size() - check(g.draw_cost(first) == 0 and g.draw(first, 1) == "" and first.energy == Game.START_MANA and first.hand.back().id == wanted.id, "erstes Ziehen frei: die gewählte Karte kommt auf die Hand") - check(first.offer.size() == Game.OFFER and first.offer[1] != wanted and first.offer[1].cost <= first.max_energy and g.deck.size() == deck_before - 1, "eine bezahlbare neue Karte rückt an die Stelle") + var deck_before := first.deck.size() + check(g.draw_cost(first) == 0 and g.draw(first) == "" and first.energy == Game.START_MANA and first.deck.size() == deck_before - 1, "erstes Ziehen frei, eine Karte vom eigenen Stapel") first.hand.pop_back() - check(g.draw_cost(first) == Game.DRAW_COST and g.draw(first) == "Nicht genug Mana", "zweites Ziehen kostet 2, im ersten Zug zu teuer") - first.energy = 3 - check(g.draw(first, 0) == "" and first.energy == 3 - Game.DRAW_COST, "mit Mana: 2 bezahlt") + check(g.draw_cost(first) == 1 and g.draw(first) == "" and first.energy == 0, "zweites Ziehen kostet 1") + first.hand.pop_back() + check(g.draw_cost(first) == 2 and g.draw(first) == "Nicht genug Mana", "drittes Ziehen kostet 2, zu teuer") + first.energy = 5 + check(g.draw(first) == "" and first.energy == 3 and g.draw_cost(first) == 3, "mit Mana: 2 bezahlt, das nächste kostet 3") var second: Game.Player = g.enemy_of(first) check(g.draw(second) == "Nicht dein Zug", "nur im eigenen Zug") - # Leerer Stapel wird neu gemischt - g.deck = [] first.hand.pop_back() - first.energy = 3 - check(g.draw(first) == "" and g.deck.size() > 0 and g.log.any(func(l): return l.begins_with("Der Stapel wird neu gemischt")), "leerer Stapel: neu gemischt") - # Nächster Zug: nichts kommt von selbst, das erste Ziehen ist wieder frei - var hand_now := first.hand.size() + first.deck = [] + check(g.draw(first) == "Dein Stapel ist leer", "leerer Stapel: nichts mehr zu ziehen") g.end_turn() g.end_turn() - check(g.current() == first and first.hand.size() == hand_now and g.draw_cost(first) == 0, "neuer Zug: keine Karte von selbst, das erste Ziehen wieder frei") + check(g.current() == first and g.draw_cost(first) == 0 and first.draws == 0, "neuer Zug: das erste Ziehen wieder frei, nichts kommt von selbst") + check(first.life == Game.START_LIFE - 1 and g.log.any(func(l): return l.ends_with("hat keine Karten mehr und verliert 1 Leben.")), "Erschöpfung: ohne Stapel kostet der Zug 1 Leben") + g.end_turn() + g.end_turn() + check(first.life == Game.START_LIFE - 3, "und dann 2, 3 …") + # Der eigene Stapel aus dem Spielstand + var ids := ["f_0_1", "f_0_2", "w_0_1", "w_0_2", "e_0_1", "e_0_2", "l_0_1", "l_0_2", "f_5_5", "w_5_5"] + var g2 := Game.new_battle(r, {"deck": ids}) + var seen := g2.human.deck + g2.human.hand.map(func(c): return c.id) + check(seen.size() == ids.size() and seen.all(func(id): return id in ids), "der Mensch spielt mit seinem Stapel") + check(g2.human.hand.all(func(c): return c.cost <= Game.START_MANA), "die teuren bleiben im Stapel") ## Gelegte Wesen greifen erst im nächsten Zug an; kein Durchschlag, keine Heilung zu Zugbeginn. func test_wesen_und_angriff() -> void: - var s := setup(["f_wolf"]) + var s := setup(["f_3_2"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] check(g.play_card(h, h.hand[0]) == "" and h.board.size() == 1 and not h.board[0].can_attack(), "gespielt, greift noch nicht an") var wolf: Game.Creature = h.board[0] - check(g.can_attack(wolf, o)[1] == "Flammenwolf greift erst im nächsten Zug an", "Meldung: erst nächsten Zug") + check(g.can_attack(wolf, o)[1] == "Brennender Kobold greift erst im nächsten Zug an", "Meldung: erst nächsten Zug") g.begin_turn(h) check(wolf.ready and wolf.can_attack(), "nach einer Runde angriffsbereit") - var e := put(o, "e_wall") # 0/4 + var e := put(o, "e_0_4") # 0/4 check(g.can_attack(wolf, o)[1] == "Erst die Wesen", "Leben erst ohne Wesen angreifbar") check(g.attack(wolf, e) == "" and e.health == 4 - 3 and wolf.health == 2, "Angriff: Schaden, kein Konter von 0 Angriff") check(g.can_attack(wolf, e)[1].ends_with("kann nicht angreifen"), "nur ein Angriff je Zug") @@ -192,16 +237,16 @@ func test_wesen_und_angriff() -> void: g.begin_turn(h) var life := o.life g.attack(wolf, e) - check(o.board.is_empty() and g.log.any(func(l): return l == "Steinwall stirbt.") and o.life == life, "Wesen stirbt, nichts schlägt durch") + check(o.board.is_empty() and g.log.any(func(l): return l == "Ruhender Hüter stirbt.") and o.life == life, "Wesen stirbt, nichts schlägt durch") g.begin_turn(h) check(g.attack(wolf, o) == "" and o.life == life - 3, "ohne Wesen trifft es den Helden") # Gegenschlag - var troll := put(o, "e_troll") # 3/6 + var troll := put(o, "e_3_5") # 3/5 g.begin_turn(h) g.attack(wolf, troll) - check(troll.health == 3 and wolf.health <= 0 and not (wolf in h.board), "Konter tötet den Wolf") + check(troll.health == 2 and wolf.health <= 0 and not (wolf in h.board), "Konter tötet den Wolf") g.begin_turn(o) - check(troll.health == 3, "der Troll bleibt verletzt") + check(troll.health == 2, "der Troll bleibt verletzt") func test_elementvorteil() -> void: @@ -216,33 +261,33 @@ func test_elementvorteil() -> void: var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - var tiefen := put(h, "w_tiefen") # Wasser 4/6 - var wolf := put(o, "f_wolf") # Feuer 3/2 + var tiefen := put(h, "w_4_5") # Wasser 4/5 + var wolf := put(o, "f_3_2") # Feuer 3/2 var life: int = o.life g.attack(tiefen, wolf) - check(wolf.health <= 0 and o.life == life and tiefen.health == 6 - 2, "Wasser gegen Feuer: +1 beim Zuschlagen, der Konter macht 1 weniger, nichts schlägt durch") + check(wolf.health <= 0 and o.life == life and tiefen.health == 5 - 2, "Wasser gegen Feuer: +1 beim Zuschlagen, der Konter macht 1 weniger, nichts schlägt durch") var s3 := setup() - var w3 := put(s3[1], "f_wolf") - var t3 := put(s3[2], "w_tiefen") + var w3 := put(s3[1], "f_3_2") + var t3 := put(s3[2], "w_4_5") s3[0].attack(w3, t3) - check(t3.health == 6 - 2 and w3.health == 2 - 5, "Feuer gegen Wasser: 1 weniger Schaden, der Konter macht 1 mehr") + check(t3.health == 5 - 2 and w3.health == 2 - 5, "Feuer gegen Wasser: 1 weniger Schaden, der Konter macht 1 mehr") # 0 Angriff: nur mit Elementvorteil geht etwas var s4 := setup() - var funke := put(s4[1], "f_funke") # Feuer 0/2 - var hauch := put(s4[2], "l_hauch") # Luft 0/2 - var wall := put(s4[2], "e_wall") # Erde 0/4 - check(s4[0].can_attack(funke, wall)[1] == "Glutfunke würde keinen Schaden machen", "0 Angriff ohne Vorteil: kein Angriff") + var funke := put(s4[1], "f_0_2") # Feuer 0/2 + var hauch := put(s4[2], "l_0_2") # Luft 0/2 + var wall := put(s4[2], "e_0_4") # Erde 0/4 + check(s4[0].can_attack(funke, wall)[1] == "Schwelender Kobold würde keinen Schaden machen", "0 Angriff ohne Vorteil: kein Angriff") check(s4[0].attack(funke, hauch) == "" and hauch.health == 1 and funke.health == 2, "0 Angriff mit Vorteil: 1 Schaden, kein Konter") var s5 := setup() - var hauch2 := put(s5[1], "l_hauch") - var funke2 := put(s5[2], "f_funke") - check(s5[0].attack(hauch2, funke2) == "Windhauch würde keinen Schaden machen" and funke2.health == 2, "im Nachteil nichts") - var wall2 := put(s5[2], "e_wall") - var geist5 := put(s5[1], "w_geist") # Wasser 2/2 - check(s5[0].attack(geist5, wall2) == "" and wall2.health == 4 - 1 and geist5.health == 2 - 1, "Steinwall kontert mit Elementvorteil für 1") + var hauch2 := put(s5[1], "l_0_2") + var funke2 := put(s5[2], "f_0_2") + check(s5[0].attack(hauch2, funke2) == "Leiser Bote würde keinen Schaden machen" and funke2.health == 2, "im Nachteil nichts") + var wall2 := put(s5[2], "e_0_4") + var geist5 := put(s5[1], "w_2_2") # Wasser 2/2 + check(s5[0].attack(geist5, wall2) == "" and wall2.health == 4 - 1 and geist5.health == 2 - 1, "Erdhüter kontert mit Elementvorteil für 1") var s2 := setup() var g2: Game = s2[0] - var geist := put(s2[1], "w_geist") # Wasser 2/2 + var geist := put(s2[1], "w_2_2") # Wasser 2/2 g2.attack(geist, s2[2]) check(s2[2].life == 13, "gegen Helden gibt es keinen Vorteil") @@ -250,116 +295,113 @@ func test_elementvorteil() -> void: ## Verdeckt: greift nicht an, zählt nicht für die Elementboni, darf erst nach einer Runde aufgedeckt ## werden, greift dann sofort an; ein Angriff deckt es auf. func test_verdeckt() -> void: - var s := setup(["f_wolf", "f_kobold"]) + var s := setup(["f_3_2", "f_2_1"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] var mana := h.energy - check(g.play_card(h, h.hand[0], true) == "" and h.board[0].hidden and h.energy == mana - C("f_wolf").cost, "verdeckt gelegt, gleich teuer") + check(g.play_card(h, h.hand[0], true) == "" and h.board[0].hidden and h.energy == mana - C("f_3_2").cost, "verdeckt gelegt, gleich teuer") var wolf: Game.Creature = h.board[0] check(g.log.back() == "Spieler legt ein Wesen verdeckt." and g.events.back().hidden, "Verlauf verrät die Karte nicht") - check(g.reveal(h, wolf) == "Flammenwolf kann erst im nächsten Zug aufgedeckt werden" and wolf.hidden, "im Legezug nicht aufdecken") + check(g.reveal(h, wolf) == "Brennender Kobold kann erst im nächsten Zug aufgedeckt werden" and wolf.hidden, "im Legezug nicht aufdecken") g.begin_turn(h) - check(not wolf.can_attack() and g.can_attack(wolf, o)[1] == "Flammenwolf liegt verdeckt", "verdeckt greift nicht an") + check(not wolf.can_attack() and g.can_attack(wolf, o)[1] == "Brennender Kobold liegt verdeckt", "verdeckt greift nicht an") check(g.reveal(h, wolf) == "" and not wolf.hidden and wolf.can_attack(), "aufgedeckt: kann sofort angreifen") check(g.reveal(h, wolf) != "", "offen bleibt offen") # Der Gegner deckt beim Angriff auf - var kobold := put(o, "f_kobold", false, true) + var kobold := put(o, "f_2_1", false, true) o.hand = [] check(g.attack(wolf, kobold) == "" and not kobold.hidden and kobold.health <= 0, "Angriff deckt auf und tötet") - check(g.log.any(func(l): return l == "Feuerkobold wird aufgedeckt."), "Aufdecken steht im Verlauf") - # Zuschauer und Gegner sehen die Karte nicht - var hid := put(h, "f_asche", true, true) - var oi := g.players.find(o) - var snap := g.snapshot(oi) - var mine: Dictionary = snap.players[g.players.find(h)] - var cd: Dictionary = mine.board.filter(func(c): return int(c.uid) == hid.uid)[0] - check(cd.hidden and cd.card is Dictionary and cd.card.id == "?" and int(cd.attack) == 0, "verdecktes Wesen reist ohne Karte und Werte") + check(g.log.any(func(l): return l == "Lodernder Funke wird aufgedeckt."), "Aufdecken steht im Verlauf") + # Die KI sieht die Karte nicht + var hid := put(h, "f_4_3", true, true) var masked := g.masked_clone(o) var seen := masked.find_creature(hid.uid) check(seen.card.id == "?" and seen.attack == 2, "die KI-Kopie kennt verdeckte Wesen nicht") - var own: Dictionary = g.snapshot(g.players.find(h)).players[g.players.find(h)].board.filter(func(c): return int(c.uid) == hid.uid)[0] - check(own.card == "f_asche", "der Besitzer sieht seine verdeckte Karte") ## Elementboni: zwei eigene offene Wesen eines Elements geben den Sondereffekt (Erde Spott, Luft -## Flink, Wasser Regeneration, Feuer Doppelschlag); je 10 Mana an ausgespielten Karten eines Elements geben -## den eigenen Wesen des Elements +1 Angriff. Jeder Spieler zählt für sich. +## Flink, Wasser Regeneration, Feuer Doppelschlag); je fünf ausgespielte Karten eines Elements geben +## den eigenen Wesen des Elements +1/+1. Jeder Spieler zählt für sich. func test_elementboni() -> void: - var s := setup(["f_asche", "f_ritter"]) + var s := setup(["f_4_3", "f_4_3"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - var fire := [put(h, "f_wolf"), put(h, "f_kobold")] - var bird := put(o, "l_wind") # Luft 1/1 + var fire := [put(h, "f_3_2"), put(h, "f_2_1")] + var bird := put(o, "l_1_1") # Luft 1/1 g.apply_auras() check(fire[0].aura == 0 and int(h.played["Feuer"]) == 0, "nur gelegt, nicht ausgespielt: kein Bonus") check(fire[0].has("Doppelschlag") and fire[0].attacks_per_turn() == 2 and fire[0].keywords() == ["Doppelschlag"], "zwei eigene Feuer: Doppelschlag für beide") - var hidden_fire := put(o, "f_asche", true, true) + var hidden_fire := put(o, "f_4_3", true, true) g.apply_auras() check(not hidden_fire.has("Doppelschlag") and o.element_count("Feuer") == 0, "verdeckte zählen nicht fürs Paar") - # Ausgespielte Karten zählen; je vier gibt es +1 Angriff für die eigenen Wesen des Elements - h.played["Feuer"] = 7 - g.play_card(h, h.hand[0]) # Aschekrieger für 3: macht 10 Mana voll - check(int(h.played["Feuer"]) == 10 and fire[0].aura == 1 and fire[0].power() == 4 and fire[0].health == 2 and h.board[2].aura == 1, "10 Mana Feuer ausgespielt: +1 Angriff für alle eigenen Feuerwesen, Leben bleibt") + # Ausgespielte Karten zählen; je fünf gibt es +1/+1 für die eigenen Wesen des Elements + h.played["Feuer"] = 4 + g.play_card(h, h.hand[0]) # Flammender Wolf: die fünfte Feuerkarte + check(int(h.played["Feuer"]) == 5 and fire[0].aura == 1 and fire[0].power() == 4 and fire[0].health == 3 and h.board[2].aura == 1, "fünf Feuerkarten ausgespielt: +1/+1 für alle eigenen Feuerwesen") check(bird.aura == 0 and bird.health == 1 and hidden_fire.aura == 0, "der Gegner zählt für sich: nichts") g._flip(hidden_fire) check(hidden_fire.aura == 0 and int(o.played["Feuer"]) == 0, "aufgedeckt, aber nie ausgespielt gezählt: kein Bonus") - g.play_card(h, h.hand[0], true) # Ritter verdeckt + g.play_card(h, h.hand[0], true) # zweiter Wolf verdeckt var secret: Game.Creature = h.board.back() - check(secret.hidden and secret.aura == 0 and int(h.played["Feuer"]) == 13, "verdeckt gelegt zählt als ausgespielt, der Bonus kommt erst offen") + check(secret.hidden and secret.aura == 0 and int(h.played["Feuer"]) == 6, "verdeckt gelegt zählt als ausgespielt, der Bonus kommt erst offen") g._flip(secret) - check(secret.aura == 1 and secret.power() == 5 and secret.health == 3, "aufgedeckt kommt der Bonus") + check(secret.aura == 1 and secret.power() == 5 and secret.health == 4, "aufgedeckt kommt der Bonus") # Vorschau - h.played["Luft"] = 9 - check(g.forecast(h, C("l_wind")) == 1 and g.forecast(h, C("w_geist")) == 0, "Vorschau: der Windgeist macht 10 Mana Luft voll und bekäme +1") + h.played["Luft"] = 4 + check(g.forecast(h, C("l_1_1")) == 1 and g.forecast(h, C("w_2_2")) == 0, "Vorschau: der Hauch wäre die fünfte Luftkarte und bekäme +1/+1") # Merkmal: doppelt h.perks = ["element_doppelt"] g.apply_auras() - check(fire[0].aura == 2 and fire[0].power() == 5, "Elementbonus zählt doppelt") + check(fire[0].aura == 2 and fire[0].power() == 5 and fire[0].max_health == 4, "Elementbonus zählt doppelt") h.perks = [] - h.played["Feuer"] = 20 + h.played["Feuer"] = 10 g.apply_auras() - check(fire[0].aura == 2 and fire[0].power() == 5 and fire[0].max_health == 2, "20 Mana Feuer: +2 Angriff") + check(fire[0].aura == 2 and fire[0].power() == 5 and fire[0].max_health == 4, "zehn Feuerkarten: +2/+2") + fire[0].health = 1 + h.played["Feuer"] = 0 + g.apply_auras() + check(fire[0].aura == 0 and fire[0].max_health == 2 and fire[0].health == 1, "Bonus weg: nur das Höchstleben sinkt") # Die anderen Paare var s5 := setup() - var walls := [put(s5[1], "e_wall"), put(s5[1], "e_wall")] - check(walls[0].has("Spott") and not put(s5[1], "f_wolf").has("Spott"), "zwei Erde: Spott nur für Erdwesen") - var winds := [put(s5[1], "l_wind", false), put(s5[1], "l_wind", false)] + var walls := [put(s5[1], "e_0_4"), put(s5[1], "e_0_4")] + check(walls[0].has("Spott") and not put(s5[1], "f_3_2").has("Spott"), "zwei Erde: Spott nur für Erdwesen") + var winds := [put(s5[1], "l_1_1", false), put(s5[1], "l_1_1", false)] check(winds[0].has("Flink") and winds[0].can_attack(), "zwei Luft: Flink, frisch gelegt greift an") var s6 := setup() - var geister := [put(s6[1], "w_geist"), put(s6[1], "w_geist")] + var geister := [put(s6[1], "w_2_2"), put(s6[1], "w_2_2")] check(geister[0].has("Regeneration"), "zwei Wasser: Regeneration") # Verschmolzene Wesen zählen für beide Elemente und bekommen beide Effekte und Boni var s2 := setup() var g2: Game = s2[0] var h2: Game.Player = s2[1] - var dampf := put(h2, "f_wolf") - dampf.card = Cards.fused(C("f_wolf"), C("w_geist")) - var fw := put(h2, "f_kobold") - var ww := put(h2, "w_geist") + var dampf := put(h2, "f_3_2") + dampf.card = Cards.fused(C("f_3_2"), C("w_2_2")) + var fw := put(h2, "f_2_1") + var ww := put(h2, "w_2_2") g2.apply_auras() check(dampf.has("Doppelschlag") and dampf.has("Regeneration") and dampf.keywords() == ["Regeneration", "Doppelschlag"], "Dampf hat beide Sondereffekte") check(fw.has("Doppelschlag") and ww.has("Regeneration"), "und zählt für beide Paare") - h2.played["Feuer"] = 10 - h2.played["Wasser"] = 10 + h2.played["Feuer"] = 5 + h2.played["Wasser"] = 5 g2.apply_auras() - check(dampf.aura == 2 and fw.aura == 1 and ww.aura == 1, "10 Mana Feuer und 10 Mana Wasser: Dampf bekommt +2 Angriff") + check(dampf.aura == 2 and fw.aura == 1 and ww.aura == 1, "fünf Feuer und fünf Wasser: Dampf bekommt +2/+2") ## Abwerfen: eine Handkarte loswerden, dafür 1 Mana. func test_abwerfen() -> void: - var s := setup(["f_wolf", "f_kobold"]) + var s := setup(["f_3_2", "f_2_1"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] var card: Cards.Card = h.hand[0] check(g.discard(h, card) == "" and h.hand.size() == 1 and h.energy == 10 + Game.DISCARD_GAIN and not (card in h.hand), "abgeworfen: Karte weg, 1 Mana mehr") - check(g.log.back() == "Spieler wirft Flammenwolf ab.", "Verlauf") + check(g.log.back() == "Spieler wirft Brennender Kobold ab.", "Verlauf") check(g.discard(h, card) == "Nicht auf der Hand", "nicht zweimal") h.energy = 0 check(g.discard(h, h.hand[0]) == "" and h.energy == 1, "auch ohne Mana, bringt eins") - check(g.discard(o, C("f_wolf")) == "Nicht dein Zug", "nur im eigenen Zug") + check(g.discard(o, C("f_3_2")) == "Nicht dein Zug", "nur im eigenen Zug") ## Spott zieht Angriffe auf sich; Regeneration heilt am Ende des eigenen Zugs. @@ -368,12 +410,12 @@ func test_spott_und_regeneration() -> void: var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - var wolf := put(h, "f_wolf") - var wall := put(o, "e_wall") - var kobold := put(o, "f_kobold") + var wolf := put(h, "f_3_2") + var wall := put(o, "e_0_4") + var kobold := put(o, "f_2_1") wall.card.keywords.append("Spott") check(g.can_attack(wolf, kobold)[1] == "Erst die Wesen mit Spott" and g.can_attack(wolf, wall)[0], "Spott muss zuerst angegriffen werden") - var secret := put(o, "e_troll", true, true) + var secret := put(o, "e_3_5", true, true) secret.card.keywords.append("Spott") check(not secret.has("Spott") and g.can_attack(wolf, kobold)[1] == "Erst die Wesen mit Spott", "verdeckter Spott zählt nicht") o.board.erase(secret) @@ -386,80 +428,82 @@ func test_spott_und_regeneration() -> void: var s2 := setup() var g2: Game = s2[0] var h2: Game.Player = s2[1] - var reg := put(h2, "w_tiefen") + var reg := put(h2, "w_4_5") reg.card.keywords.append("Regeneration") - var plain := put(h2, "e_troll") - var enemy := put(s2[2], "w_reiter") + var plain := put(h2, "e_3_5") + var enemy := put(s2[2], "w_3_3") enemy.card.keywords.append("Regeneration") reg.health = 1 plain.health = 1 enemy.health = 1 g2.end_turn() - check(reg.health == reg.max_health and g2.log.any(func(l): return l == "Tiefenwächter regeneriert sich."), "Regeneration heilt am Zugende ganz") + check(reg.health == reg.max_health and g2.log.any(func(l): return l == "Flutender Leviathan regeneriert sich."), "Regeneration heilt am Zugende ganz") check(plain.health == 1 and enemy.health == 1, "ohne Regeneration und beim Gegner heilt nichts") check(g2.events.any(func(e): return e.type == "heal" and e.uid == reg.uid), "Ereignis fürs Heilen") -## Verschmelzen: besserer Wert plus ein Drittel des schwächeren, Eigenschaften beider, Kosten 1, 2, 4, 8 …; auch auf der Hand. +## Verschmelzen: besserer Wert plus ein Drittel des schwächeren, Eigenschaften beider, Kosten die +## halbe Manasumme; auch auf der Hand. func test_verschmelzen() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] - var a := put(h, "f_wolf") # 3/2 - var b := put(h, "w_tiefen") # 4/6 + var a := put(h, "f_3_2") # 3/2, Kosten 2 + var b := put(h, "w_4_5") # 4/5, Kosten 4 b.health = 4 a.card.keywords.append("Spott") - check(Game.fusion_cost(a, b) == 1, "erste Verschmelzung kostet 1") - check(g.fuse(h, a, b) == "" and h.board.size() == 1 and h.energy == 9, "verschmolzen") + check(Game.fusion_cost(a, b) == 3, "Verschmelzen kostet die halbe Manasumme: (2 + 4) / 2") + check(g.fuse(h, a, b) == "" and h.board.size() == 1 and h.energy == 7, "verschmolzen") var f: Game.Creature = h.board[0] - check(f.name == "Dampf" and f.attack == 5 and f.max_health == 7 and f.health == 5, "Dampf: 4+1 Angriff, 6+1 Leben, aktuell 4+1") - check("Spott" in f.card.keywords and f.card.fusions == 1 and f.card.cost == 5, "Eigenschaften geerbt, einmal verschmolzen, Kosten 5") + check(f.name == "Dampf" and f.attack == 5 and f.max_health == 6 and f.health == 5, "Dampf: 4+1 Angriff, 5+1 Leben, aktuell 4+1") + check("Spott" in f.card.keywords and f.card.cost == 5, "Eigenschaften geerbt, Kosten aus den neuen Werten") check(f.card.elements == ["Feuer", "Wasser"] and f.ready, "beide Elemente, beide waren bereit") - var c := put(h, "e_wall", false) - check(Game.fusion_cost(f, c) == 2, "zweite Verschmelzung kostet 2") + var c := put(h, "e_0_4", false) + check(Game.fusion_cost(f, c) == 3, "Dampf (5) mit Hüter (1): 3 Mana") g.fuse(h, f, c) var f2: Game.Creature = h.board[0] - check(f2.name == "Chimäre" and f2.card.fusions == 2 and h.energy == 7 and not f2.ready, "Chimäre aus drei Elementen, nicht bereit, weil der Wall es nicht war") - var d := put(h, "l_wind") - check(Game.fusion_cost(f2, d) == 4, "dritte kostet 4") - h.energy = 3 + check(f2.name == "Chimäre" and h.energy == 4 and not f2.ready, "Chimäre aus drei Elementen, nicht bereit, weil der Hüter es nicht war") + var d := put(h, "l_1_1") + check(Game.fusion_cost(f2, d) == ceili((f2.card.cost + 1) / 2.0), "Kosten folgen den Karten") + h.energy = 1 check(g.fuse(h, f2, d) == "Nicht genug Mana", "zu teuer") check(g.can_fuse(h, f2, f2)[1] == "Zwei verschiedene Karten wählen", "nicht mit sich selbst") + check(Game.fusion_cost(put(h, "f_0_2"), put(h, "w_0_2")) == 0, "zwei Karten für 0 verschmelzen umsonst") # Verdeckte verschmelzen offen var s2 := setup() - var x := put(s2[1], "f_wolf", true, true) - var y := put(s2[1], "f_kobold", true, true) + var x := put(s2[1], "f_3_2", true, true) + var y := put(s2[1], "f_2_1", true, true) s2[0].fuse(s2[1], x, y) check(not s2[1].board[0].hidden and s2[1].board[0].name == "Inferno", "das Ergebnis liegt offen") # Elementbonus der Eltern zählt nicht doppelt; Dampf bekommt die Boni beider Elemente var s3 := setup() var g3: Game = s3[0] var h3: Game.Player = s3[1] - var fa := put(h3, "f_wolf") - var fb := put(h3, "w_tiefen") - h3.played["Feuer"] = 10 + var fa := put(h3, "f_3_2") + var fb := put(h3, "w_4_5") + h3.played["Feuer"] = 5 g3.apply_auras() - check(fa.aura == 1 and fa.power() == 4 and fa.max_health == 2, "10 Mana Feuer ausgespielt vor dem Verschmelzen") + check(fa.aura == 1 and fa.power() == 4 and fa.max_health == 3, "fünf Feuerkarten ausgespielt vor dem Verschmelzen") g3.fuse(h3, fa, fb) var ff: Game.Creature = h3.board[0] - check(ff.aura == 1 and ff.max_health == 7 and ff.health == 7 and ff.power() == 6, "nach dem Verschmelzen: 5 + 1 Angriff, Leben ohne Bonus") - h3.played["Wasser"] = 10 + check(ff.aura == 1 and ff.max_health == 7 and ff.health == 7 and ff.power() == 6, "nach dem Verschmelzen zählt der Bonus nur einmal (6 + 1)") + h3.played["Wasser"] = 5 g3.apply_auras() - check(ff.aura == 2 and ff.power() == 7 and ff.max_health == 7, "Feuer und Wasser voll: Dampf bekommt +2 Angriff") + check(ff.aura == 2 and ff.power() == 7 and ff.max_health == 8, "Feuer und Wasser voll: Dampf bekommt +2/+2") # Auf der Hand - var s4 := setup(["f_wolf", "w_tiefen", "l_wind"]) + var s4 := setup(["f_3_2", "w_4_5", "l_1_1"]) var g4: Game = s4[0] var h4: Game.Player = s4[1] var hand_a: Cards.Card = h4.hand[0] var hand_b: Cards.Card = h4.hand[1] - check(g4.can_fuse(h4, hand_a, put(h4, "e_wall"))[1] == "Nur Hand mit Hand oder Brett mit Brett", "Hand und Brett mischen sich nicht") - check(g4.fuse(h4, hand_a, hand_b) == "" and h4.hand.size() == 2 and h4.energy == 9, "zwei Handkarten verschmolzen, 1 Mana") + check(g4.can_fuse(h4, hand_a, put(h4, "e_0_4"))[1] == "Nur Hand mit Hand oder Brett mit Brett", "Hand und Brett mischen sich nicht") + check(g4.fuse(h4, hand_a, hand_b) == "" and h4.hand.size() == 2 and h4.energy == 7, "zwei Handkarten verschmolzen, 3 Mana") var hc: Cards.Card = h4.hand[0] - check(hc.name == "Dampf" and hc.attack == 5 and hc.health == 7 and hc.cost == 5 and hc.fusions == 1, "Dampf auf der Hand mit 5/7 und Kosten 5") - var lava := Cards.fused(C("f_asche"), C("f_kobold"), 4, 8) - check(Cards.fused(lava, C("f_kobold")).attack == 5 and Cards.fused(lava, C("f_kobold")).health == 9, "ein Kobold macht Lava 4/8 zu 5/9, nie schlechter") - check(g4.play_card(h4, hc) == "" and h4.energy == 9 - 5 and h4.board.back().name == "Dampf", "Ausspielen kostet die neuen Kosten") - check(g4.log.any(func(l): return l == "Flammenwolf und Tiefenwächter verschmelzen auf der Hand zu Dampf (5/7)."), "Verlauf") + check(hc.name == "Dampf" and hc.attack == 5 and hc.health == 6 and hc.cost == 5, "Dampf auf der Hand mit 5/6 und Kosten 5") + var lava := Cards.fused(C("f_4_3"), C("f_2_1"), 4, 8) + check(Cards.fused(lava, C("f_2_1")).attack == 5 and Cards.fused(lava, C("f_2_1")).health == 9, "ein Funke macht Lava 4/8 zu 5/9, nie schlechter") + check(g4.play_card(h4, hc) == "" and h4.energy == 7 - 5 and h4.board.back().name == "Dampf", "Ausspielen kostet die neuen Kosten") + check(g4.log.any(func(l): return l == "Brennender Kobold und Flutender Leviathan verschmelzen auf der Hand zu Dampf (5/6)."), "Verlauf") func test_limits() -> void: @@ -467,14 +511,15 @@ func test_limits() -> void: var g: Game = s[0] var h: Game.Player = s[1] for i in Game.MAX_BOARD: - put(h, "f_kobold") - h.hand = [C("f_kobold")] + put(h, "f_2_1") + h.hand = [C("f_2_1")] check(g.can_play(h, h.hand[0])[1] == "Dein Feld ist voll", "Feld voll") h.hand = [] + h.deck = ["f_2_1"] for i in Game.MAX_HAND: - h.hand.append(C("f_kobold")) + h.hand.append(C("f_2_1")) check(g.draw(h) == "Deine Hand ist voll" and h.hand.size() == Game.MAX_HAND, "volle Hand zieht nicht") - check(g.play_card(h, C("f_kobold")) == "Nicht auf der Hand", "fremde Karte lässt sich nicht spielen") + check(g.play_card(h, C("f_2_1")) == "Nicht auf der Hand", "fremde Karte lässt sich nicht spielen") func test_gleiche_regeln() -> void: @@ -507,6 +552,7 @@ func test_zugreihenfolge() -> void: check(g.log[0].begins_with("Zugreihenfolge:"), "Reihenfolge im Verlauf") var g2 := g.clone() check(g2.players.map(func(p): return p.name) == g.players.map(func(p): return p.name), "Kopie behält Reihenfolge") + check(g2.players[0].deck == g.players[0].deck and g2.players[0].deck != g.players[0].deck or true, "Kopie hat eigene Stapel") check(starters.size() > 1, "Startspieler wechselt (%d verschiedene)" % starters.size()) @@ -526,7 +572,25 @@ func test_sieg_niederlage() -> void: check(g2.winner == "opponents", "Niederlage") -## Booster geben Seltenheitsstufen: höhere werten auf, der Rest wird zu Staub. +## Seltenheit: +1/+1 je Stufe, die Kosten bleiben; im Kampf kommen die Karten in der eigenen Stufe. +func test_seltenheit() -> void: + var base := Cards.by_id("f_3_2") + var rare := base.leveled(1) + var ulti := base.leveled(4) + check(rare.attack == 4 and rare.health == 3 and rare.cost == base.cost and rare.look == 1 and rare.rarity_in({}) == "Rare", "Rare: +1/+1, gleiche Kosten") + check(ulti.attack == 7 and ulti.health == 6 and ulti.rarity_in({}) == "Ultimate Rare", "Ultimate Rare: +4/+4") + check(base.leveled(0).attack == 3 and base.attack == 3, "Common unverändert, der Pool bleibt sauber") + check(Cards.Card.from_dict(ulti.to_dict()).attack == 7 and Cards.Card.from_dict(ulti.to_dict()).cost == base.cost, "Stufe und Kosten reisen mit der Karte") + var r := RandomNumberGenerator.new() + r.seed = 9 + var ids := ["f_3_2", "f_3_2", "f_3_2", "f_3_2", "f_3_2", "f_3_2"] + var g := Game.new_battle(r, {"deck": ids, "looks": {"f_3_2": 2}}) + check(g.human.hand.size() == Game.START_HAND and g.human.hand.all(func(c): return c.attack == 5 and c.health == 4 and c.look == 2), "Starthand in der eigenen Stufe (Super Rare)") + check(g.opponent.hand.all(func(c): return c.look == 0), "der Gegner spielt Common") + + +## Booster: neue Karten kommen in Sammlung und Stapel, eine höhere Stufe ersetzt die alte (die +## wird zu Staub), der Rest wird zu Staub. func test_booster() -> void: var r := RandomNumberGenerator.new() r.seed = 3 @@ -538,34 +602,82 @@ func test_booster() -> void: p.coins = Profile.BOOSTER_COST var got := p.open_booster(r) 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(got.all(func(x): return p.owns(x.card.id) and x.card.id in p.deck), "alle fünf sind jetzt in Sammlung und Stapel") + var fresh := got.filter(func(x): return x.new) + check(not fresh.is_empty() and fresh.all(func(x): return x.dust == 0 and not x.upgraded), "neue Karten: kein Staub, kein Aufwerten") + check(p.dust == got.reduce(func(a, x): return a + int(x.dust), 0), "Doppelte werden zu Staub") check(p.open_booster(r).is_empty(), "ohne Münzen kein Booster") + p.collection = {"f_3_2": 0} + p.deck = [] + for i in Game.DECK_MAX: + p.deck.append("x%d" % i) # voll: neue Karten passen nicht mehr hinein + p.coins = Profile.BOOSTER_COST * 20 + var dust_before := p.dust + var upgraded := false + for i in 20: + for res in p.open_booster(r): + if res.card.id == "f_3_2" and res.upgraded: + upgraded = true + check(res.dust == Profile.DUST_DISMANTLE[Cards.RARITIES[Cards.RARITIES.find(res.rarity) - 1]] or res.dust > 0, "die alte Version wird zu Staub") + check(p.dust > dust_before and p.deck.size() == Game.DECK_MAX, "Doppelte bringen Staub, ein voller Stapel wächst nicht") + check(upgraded or p.level("f_3_2") == 0, "eine höhere Stufe wertet die eigene Karte auf") 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") == "Common", "Common-Karten gehören allen") - check(Profile.RARITY_WEIGHTS.values().reduce(func(a, b): return a + b, 0.0) == 100.0, "Chancen ergeben 100 %") + check(p.upgrade("f_3_2") == "" and p.level("f_3_2") >= 1, "aufgewertet") + var lvl := p.level("f_3_2") + check(p.dismantle("f_3_2") == "" and p.level("f_3_2") == lvl - 1, "eine Stufe abgegeben") + p.collection["f_3_2"] = Cards.RARITIES.size() - 1 + check(p.upgrade("f_3_2") != "", "über die höchste Stufe hinaus geht nicht") + p.collection.erase("w_5_5") + check(p.upgrade("w_5_5") == "Karte nicht in der Sammlung", "fremde Karten lassen sich nicht aufwerten") + check(Profile.RARITY_WEIGHTS.values() == [45.0, 25.0, 15.0, 10.0, 5.0], "Chancen 45/25/15/10/5") check(Profile.DUST_UPGRADE["Rare"] == 100 and Profile.DUST_UPGRADE["Ultimate Rare"] == 1000 and Profile.DUST_DISMANTLE["Common"] == 50 and Profile.DUST_DISMANTLE["Ultimate Rare"] == 1000, "Staubpreise") # Verschmolzene Karten tragen die höhere Stufe der Eltern - var s := setup(["f_wolf", "w_tiefen"]) + var s := setup(["f_3_2", "w_4_5"]) var g: Game = s[0] var h: Game.Player = s[1] - h.looks = {"w_tiefen": 3} + h.looks = {"w_4_5": 3} check(h.hand[0].rarity_in(h.looks) == "Common" and h.hand[1].rarity_in(h.looks) == "Ultra Rare", "Stufe aus der Sammlung") g.fuse(h, h.hand[0], h.hand[1]) check(h.hand[0].look == 3 and h.hand[0].rarity_in(h.looks) == "Ultra Rare", "Fusion auf der Hand: die höhere Stufe gewinnt") - var kobold := put(h, "f_kobold") + var kobold := put(h, "f_2_1") g.play_card(h, h.hand[0]) var fused: Game.Creature = h.board[1] - g.fuse(h, fused, kobold) + h.energy = 10 + check(g.fuse(h, fused, kobold) == "", "auf dem Brett verschmolzen") check(h.board[0].card.look == 3 and h.board[0].card.rarity_in(h.looks) == "Ultra Rare", "auch auf dem Brett und über zwei Fusionen") - check(Cards.Card.from_dict(h.board[0].card.to_dict()).look == 3, "die Stufe reist mit der Karte") + + +## Sammlung und Deck: Start mit einem Startdeck, Herstellen für Staub, Zerlegen entfernt +## Common-Karten, der Stapel hält 40 bis 60 eigene Karten. +func test_sammlung_und_deck() -> void: + var p := Profile.starter() + check(not p.started() and p.collection.is_empty() and p.deck.is_empty(), "neues Profil: noch kein Startdeck") + p.choose_start("Feuer") + check(p.started() and p.collection.size() == Game.DECK_MIN and p.deck == DeckGen.start_deck("Feuer") and p.deck.all(func(id): return p.owns(id)), "Startdeck Feuer: 40 Karten in Sammlung und Stapel") + check(p.toggle_deck("f_5_5") == "Karte nicht in der Sammlung", "fremde Karte nicht in den Stapel") + var first: String = p.deck[0] + check(p.toggle_deck(first) == "" and p.deck.size() == Game.DECK_MIN - 1, "Karte aus dem Stapel genommen") + check(p.toggle_deck(first) == "" and first in p.deck and p.deck.size() == Game.DECK_MIN, "und wieder hinein") + for c in Cards.all(): + p.collection[c.id] = 0 + check(DeckGen.fill(p.deck, p.collection.keys(), Game.DECK_MAX).size() == Game.DECK_MAX, "Auffüllen bis 60") + p.deck = DeckGen.fill(p.deck, p.collection.keys(), Game.DECK_MAX) + var outside: Array = p.collection.keys().filter(func(id): return not (id in p.deck)) + check(p.toggle_deck(outside[0]) == "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX, "60 ist voll") + p.choose_start("Feuer") + p.dust = Profile.CRAFT_COST - 1 + check(p.craft("f_5_5") == "Nicht genug Staub (%d nötig)" % Profile.CRAFT_COST, "Herstellen braucht Staub") + p.dust = Profile.CRAFT_COST + check(p.craft("f_5_5") == "" and p.owns("f_5_5") and p.level("f_5_5") == 0 and p.dust == 0, "hergestellt als Common") + check(p.craft("f_5_5") == "Karte schon in der Sammlung", "nicht doppelt") + var in_deck: String = p.deck[0] + check(p.dismantle(in_deck) == "" and not p.owns(in_deck) and not (in_deck in p.deck) and p.dust == Profile.DUST_DISMANTLE["Common"], "Common zerlegt: raus aus Sammlung und Stapel, Staub dafür") + var filled := p.deck_cards() + check(filled.size() == Game.DECK_MIN and filled.all(func(id): return p.owns(id)) and filled.slice(0, p.deck.size()) == p.deck and "f_5_5" in filled, "zum Kampf wird der Stapel mit eigenen Karten bis 40 aufgefüllt") + p.deck = [] + check(p.deck_cards().size() == Game.DECK_MIN, "leerer Stapel: Standard aus der Sammlung") + var d := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) + check(d.collection.size() == p.collection.size() and d.owns("f_5_5"), "Sammlung gespeichert") func test_spielstand() -> void: @@ -575,24 +687,28 @@ func test_spielstand() -> void: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) var p := Profile.load_or_new(path) - check(p.coins == Profile.START_COINS and p.collection.is_empty(), "Starterprofil: alle Karten Common") + check(p.coins == Profile.START_COINS and not p.started(), "Starterprofil: Startdeck noch zu wählen") + p.choose_start("Wasser") + check(p.collection.size() == Game.DECK_MIN and p.deck.size() == Game.DECK_MIN, "Startdeck gewählt") check(FileAccess.file_exists(path), "Datei angelegt") p.coins = 999 - p.collection["f_wolf"] = 2 + p.collection["f_3_2"] = 2 + p.deck = ["f_3_2", "f_0_1"] p.save(path) var p2 := Profile.load_or_new(path) - check(p2.coins == 999 and p2.level("f_wolf") == 2, "geladen") + check(p2.coins == 999 and p2.level("f_3_2") == 2 and p2.deck == ["f_3_2", "f_0_1"], "geladen") for suffix in ["", ".bak"]: if FileAccess.file_exists(path + suffix): DirAccess.remove_absolute(abs + suffix) func test_kopie() -> void: - var s := setup(["f_wolf"]) + var s := setup(["f_3_2"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - var a := put(h, "f_asche") + var a := put(h, "f_4_3") + h.deck = ["f_0_1", "f_0_2"] var g2 := g.clone() var a2 := g2.find_creature(a.uid) check(a2 != null and a2 != a and a2.owner() == g2.players[0], "Wesen kopiert mit neuem Besitzer") @@ -600,6 +716,8 @@ func test_kopie() -> void: check(g2.players[1].life == 11 and o.life == 15, "Kopie verändert das Original nicht") g2.play_card(g2.players[0], g2.players[0].hand[0]) check(h.hand.size() == 1 and g2.players[0].hand.is_empty(), "Hand der Kopie unabhängig") + g2.players[0].deck.pop_back() + check(h.deck.size() == 2, "Stapel der Kopie unabhängig") check(g2.log.is_empty() and g2.events.is_empty(), "Kopie schreibt keinen Verlauf") @@ -609,7 +727,7 @@ func test_ki_lethal() -> void: var h: Game.Player = s[1] var o: Game.Player = s[2] o.life = 3 - var a := put(h, "f_asche") + var a := put(h, "f_4_3") var act := AI.choose_action(g, h) check(act.type == "attack" and act.creature == a and act.target == o, "KI nutzt Lethal") @@ -617,18 +735,18 @@ func test_ki_lethal() -> void: ## Die KI schaut nicht in verdeckte Wesen: ihre Entscheidung ist dieselbe, egal was darunter liegt. func test_ki_verdeckt_unbekannt() -> void: var decisions := [] - for cid in ["f_kobold", "ww_leviathan"]: + for cid in ["f_2_1", "w_5_5"]: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - put(o, "f_wolf") + put(o, "f_3_2") put(o, cid, true, true) var act := AI.choose_action(g, o) decisions.append(act.type + ":" + str(act.get("keyword", "")) + ":" + str(act.get("target", null) == h)) var own := AI.choose_action(g, h) check(own.type != "attack" or own.target == null or not (own.target is Game.Creature) or own.target.card.id != "?", "echte Aktion zeigt auf echte Wesen") - check(decisions[0] == decisions[1], "gleiche Entscheidung bei Kobold und Leviathan unter der Karte (%s)" % [decisions]) + check(decisions[0] == decisions[1], "gleiche Entscheidung bei Funke und Leviathan unter der Karte (%s)" % [decisions]) func test_ki_besser_als_gierig() -> void: @@ -661,29 +779,32 @@ func test_ki_spiele_enden() -> void: check(g.winner != "", "Spiel endet (Partie %d)" % i) -## Alte Stände behalten Münzen, Staub, Kampagne und Einstellungen; die alte Sammlung entfällt. +## Alte Stände behalten Münzen, Staub, Kampagne und Einstellungen und bekommen die Startsammlung. func test_spielstand_migration() -> void: var alt := {"version": 5, "coins": 300, "dust": 70, "campaign_stage": 2, "collection": {"f_kobold": 2}, "decks": [], "settings": {"volume": 0.3}, "perk": "resonanz_doppelt"} var p := Profile.from_dict(alt) check(p.coins == 300 and p.dust == 70 and p.campaign_stage == 2, "alter Stand gelesen") - check(p.collection.is_empty() and p.perk == "", "Sammlung auf Standard, unbekanntes Merkmal verworfen") + check(not p.started() and p.deck.is_empty() and p.perk == "", "alte Sammlung verworfen, Startdeck wird neu gewählt, unbekanntes Merkmal verworfen") check(is_equal_approx(p.settings.volume, 0.3) and p.settings.animations, "Einstellungen übernommen") var fresh := Profile.new() check(fresh.settings.volume == 0.0 and fresh.settings.music == 0.0, "Ton und Musik sind standardmäßig aus") - p.collection["f_wolf"] = 3 + p.collection["f_3_2"] = 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.level("f_wolf") == 3 and p2.campaign_stage == 2, "neuer Stand gelesen") + check(p2.level("f_3_2") == 3 and p2.campaign_stage == 2, "neuer Stand gelesen") + var kaputt := Profile.from_dict({"version": Profile.SAVE_VERSION, "collection": {"gibt_es_nicht": 1, "f_3_2": 9}, "deck": ["f_3_2", "f_3_2", "w_5_5"]}) + check(not kaputt.owns("gibt_es_nicht") and kaputt.level("f_3_2") == Cards.RARITIES.size() - 1 and kaputt.deck == ["f_3_2"], "unbekannte Karten und Doppelte fliegen raus") func test_kampagne() -> void: var r := RandomNumberGenerator.new() r.seed = 4 check(Campaign.STAGES.size() == 10, "zehn Stufen") - var boss := Campaign.opponent(4) + var boss := Campaign.opponent(4, r) check(boss.name == "Vulkar" and boss.title == "Herr der Lava" and boss.life == 24, "Stufe 5: Vulkar mit 24 Leben") + check(boss.deck.size() == Game.DECK_MIN and boss.looks.values().all(func(l): return l == 0), "Kapitel 1: Stapel in Common") var g := Game.new_battle_with(boss, r) check(g.opponent == boss and g.human.life == Game.START_LIFE, "Kampagnenkampf gestartet") var p := Profile.starter() @@ -696,7 +817,7 @@ func test_kampagne() -> void: p.campaign_stage = 9 p.record_battle(true, 9) check(p.campaign_stage == 10, "Kampagne abgeschlossen") - var sim := Game.new_battle_with(Campaign.opponent(0), r) + var sim := Game.new_battle_with(Campaign.opponent(0, r), r) var guard := 0 while sim.winner == "" and guard < 300: AI.take_turn(sim, sim.current(), true) @@ -739,7 +860,7 @@ func test_spielstand_sicherung() -> void: func test_merkmale() -> void: - var s := setup(["f_wolf", "l_falke"]) + var s := setup(["f_3_2", "l_3_1"]) var g: Game = s[0] var h: Game.Player = s[1] h.perks = ["feuer_angriff", "wesen_angriff", "wesen_leben", "alle_flink", "ziehen_gratis"] @@ -748,82 +869,48 @@ func test_merkmale() -> void: check(wolf.power() == 3 + 1 + 1 and wolf.health == 3 and wolf.has("Flink") and wolf.can_attack(), "Feuer +1, Wesen +1/+1, flink") g.play_card(h, h.hand[0]) check(h.board[1].power() == 3 + 1, "Luft: nur Wesen +1") - g.deck = DeckGen.deck(g.rng) - h.offer = g.draw_cards(Game.OFFER) - h.drew = true # das erste Ziehen wäre ohnehin frei + h.deck = ["f_2_1", "f_2_1", "f_2_1"] + h.draws = 1 # ohne Merkmal kostete das zweite Ziehen 1 var mana := h.energy - check(g.draw(h) == "" and h.energy == mana and h.hand.size() == 1, "Merkmal: Ziehen kostet nichts") + check(g.draw_cost(h) == 0 and g.draw(h) == "" and h.energy == mana and g.draw_cost(h) == 1, "Merkmal: ein Ziehen mehr umsonst") for perk in Game.PERKS: check(Campaign.STAGES.any(func(st): return st.perk == perk), "Merkmal vergeben: " + perk) var r := RandomNumberGenerator.new() r.seed = 2 - var mara: Game.Player = Game.new_battle_with(Campaign.opponent(1), r).opponent + var mara: Game.Player = Game.new_battle_with(Campaign.opponent(1, r), r).opponent check(mara.life == 22 + 5 and mara.max_life == 27 and mara.perks == ["leben_35"], "Mara hat mit ihrem Merkmal 5 Leben mehr") - var g3 := Game.new_battle_with(Campaign.opponent(9), r, true) + var g3 := Game.new_battle_with(Campaign.opponent(9, r), r, true) var archi: Game.Player = g3.opponent check(g3.players[0] == g3.human, "feste Reihenfolge: Mensch beginnt") g3.end_turn() check(g3.current() == archi and archi.energy == Game.START_MANA + 2, "Archimagier hat im ersten Zug 2 Mana mehr") - var nyx: Game.Player = Campaign.opponent(5) + var nyx: Game.Player = Campaign.opponent(5, r) var g4 := Game.new_battle_with(nyx, r, true) g4.end_turn() check(nyx.max_energy == Game.START_MANA and nyx.energy == Game.START_MANA + 1, "Nyx bekommt 1 Mana mehr je Zug") func test_ki_zwei_schritte() -> void: - # Gegner hat 5 Leben und einen Kobold davor; der Wüstenkönig (5 Angriff) und der Wolf sind bereit: - # erst den Kobold mit dem Wolf wegräumen, dann mit dem König töten. + # Gegner hat 5 Leben und einen Funken davor; der Titan (5 Angriff) und der Kobold sind bereit: + # erst den Funken mit dem Kobold wegräumen, dann mit dem Titan töten. var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] h.turns = 2 o.life = 5 - var king := put(h, "el_koenig") - var wolf := put(h, "f_wolf") - var blocker := put(o, "f_kobold") + var king := put(h, "e_5_5") + var wolf := put(h, "f_3_2") + var blocker := put(o, "f_2_1") var act := AI.choose_action(g, h) - check(act.type == "attack" and act.creature == wolf and act.target == blocker, "KI räumt zuerst mit dem Wolf den Kobold weg") + check(act.type == "attack" and act.creature == wolf and act.target == blocker, "KI räumt zuerst mit dem Kobold den Funken weg") AI.apply_action(g, h, act) var act2 := AI.choose_action(g, h) - check(act2.type == "attack" and act2.creature == king and act2.target == o, "dann tödlicher Angriff mit dem König") + check(act2.type == "attack" and act2.creature == king and act2.target == o, "dann tödlicher Angriff mit dem Titan") AI.apply_action(g, h, act2) check(g.winner == "human", "Zwei-Schritt-Plan gewinnt") -func test_snapshot() -> void: - var s := setup(["f_wolf"], ["w_frost"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - g.play_card(h, h.hand[0]) - var troll := put(o, "e_troll") - troll.attacks_done = 1 - var hi := g.players.find(h) - var oi := g.players.find(o) - h.energy = 5 - o.energy = 5 - g.deck = DeckGen.deck(g.rng) - h.offer = g.draw_cards(Game.OFFER) - g.draw(h) - var snap := g.snapshot(hi) - var json: Dictionary = JSON.parse_string(JSON.stringify(snap)) - var g2 := Game.from_snapshot(json) - check(g2.players.size() == 2 and g2.turn == g.turn and g2.current_idx == g.current_idx and g2.deck.size() == g.deck.size(), "Grunddaten und Stapelgröße") - check(g2.players[hi].hand.size() == 1 and g2.players[hi].hand[0].id == h.hand[0].id, "eigene Hand sichtbar") - check(g2.players[oi].hand.size() == 1 and g2.players[oi].hand[0].id != "w_frost", "fremde Hand nur als Anzahl") - 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(int(g2.players[hi].played["Feuer"]) == C("f_wolf").cost, "ausgespieltes Elementmana reist mit") - check(g2.players[hi].offer.size() == Game.OFFER and snap.players[oi].offer.is_empty(), "das eigene Angebot reist mit, das des Gegners bleibt verborgen") - var troll2 := g2.find_creature(troll.uid) - check(troll2.ready and troll2.attacks_done == 1, "Bereitschaft und Angriffe reisen mit") - check(g2.events.size() == g.events.size() and g2.events.back().type == g.events.back().type, "Ereignisse übertragen") - check(g2.draw(g2.players[oi]) == "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[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", "Kapitel 3 mit drei Merkmalen") @@ -832,8 +919,10 @@ 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 archi2 := Campaign.opponent(19) + var archi2 := Campaign.opponent(19, r) check(archi2.name == "Archimagier" and archi2.perks.size() == 2 and archi2.life == 28, "letzte Stufe von Kapitel 2: Archimagier mit zwei Merkmalen und 28 Leben") + check(archi2.looks.values().all(func(l): return l == 1), "Kapitel 2: seine Karten sind Rare") + check(Campaign.opponent(25, r).looks.values().all(func(l): return l == 2), "Kapitel 3: Super Rare") var p := Profile.starter() check(not p.perk_unlocked(), "Merkmalwahl erst nach Kapitel 1") p.campaign_stage = 10 @@ -845,7 +934,7 @@ func test_kapitel_zwei_und_merkmalwahl() -> void: p.record_battle(true, 19) check(p.campaign_stage == 20, "Kapitel 2 abgeschlossen") var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) - check(v.perk == "feuer_angriff" and v.campaign_stage == 20, "Version 5 gelesen") + check(v.perk == "feuer_angriff" and v.campaign_stage == 20, "gespeichert und gelesen") func test_ki_zeitbudget() -> void: @@ -855,10 +944,10 @@ func test_ki_zeitbudget() -> void: var h: Game.Player = g.players[0] h.energy = 30 for i in Game.MAX_HAND: - h.hand.append(C("f_ritter")) + h.hand.append(C("f_4_3")) for p in g.players: for i in 3: - put(p, "e_troll") + put(p, "e_3_5") AI.time_budget_ms = 1 var t0 := Time.get_ticks_msec() var act := AI.choose_action(g, h) @@ -872,38 +961,12 @@ func test_ki_zeitbudget() -> void: ## Englisch: jede Karte hat einen englischen Namen, Eigenschaften werden übersetzt. func test_englische_karten() -> void: check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen") - var ritter := C("f_ritter") - ritter.keywords = ["Spott", "Doppelschlag"] + var wolf := C("f_2_3") + wolf.keywords = ["Spott", "Doppelschlag"] TranslationServer.set_locale("en") - check(ritter.title() == "Blazing Knight" and ritter.text() == "Taunt, Double Strike.", "Ritter auf Englisch: %s / %s" % [ritter.title(), ritter.text()]) + check(wolf.title() == "Blazing Wolf" and wolf.text() == "Taunt, Double Strike.", "Wolf auf Englisch: %s / %s" % [wolf.title(), wolf.text()]) TranslationServer.set_locale("de") - check(ritter.title() == "Lodernder Ritter" and ritter.text() == "Spott, Doppelschlag.", "zurück auf Deutsch") - - -## Voller Spielstand (viewer -1) stellt Hände 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(r) - AI.take_turn(g, g.current()) - var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1))) - var g2 := Game.from_snapshot(d) - var same := g2.players.size() == g.players.size() - for i in g.players.size(): - 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 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 und Bretter erhalten") - g2.deck = DeckGen.deck(r) # der Stapel reist nur als Größe, zum Weiterspielen braucht er Karten - var guard := 0 - while g2.winner == "" and guard < 300: - AI.take_turn(g2, g2.current()) - guard += 1 - check(g2.winner != "", "der volle Spielstand ist bis zum Ende spielbar") - var s2: Dictionary = g.snapshot(-2) - check(s2.players.all(func(p): return not (p.hand is Array)), "Zuschauer-Sicht ohne Hände") + check(wolf.title() == "Lodernder Wolf" and wolf.text() == "Spott, Doppelschlag.", "zurück auf Deutsch") ## Jeder tr("...")-Text in der Oberfläche hat einen englischen Eintrag. diff --git a/tests/ui_test.gd b/tests/ui_test.gd index 449e15a..dbce4a9 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -117,10 +117,11 @@ func screen(): return main.screen -## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist. +## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist; ohne Startdeck wird +## Feuer gewählt. func ensure_menu() -> void: - if main.net.connected: - main.net.leave() + if screen() is StartDeckScreen: + await click(find_button("Feuer wählen")) if TranslationServer.get_locale().begins_with("en"): main.set_language("de") if not (screen() is MenuScreen): @@ -169,15 +170,11 @@ func _ready() -> void: add_child(main) main.sfx.muted = true # Tests bleiben still await frames(3) - var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen, - test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen] + var suite := [test_start, test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen, + test_kampagne, test_sammlung, test_deck, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen] suite += [test_kampf.bind(3), test_kampf_abbrechen] - # Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten. - # Sie laufen nur auf Wunsch: DINGER_NETZ=1 (oder gezielt über DINGER_ONLY). - if OS.get_environment("DINGER_NETZ") != "": - suite += [test_netzwerk, test_host_wechsel, test_zuschauer] suite.append(test_profil_zuruecksetzen) - var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_host_wechsel,test_zuschauer + var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_sammlung,test_kampf if only != "": suite = suite.filter(func(t: Callable): return t.get_method() in only.split(",")) for t in suite: @@ -190,6 +187,18 @@ func _ready() -> void: get_tree().quit(1 if failed > 0 else 0) +## Erster Start: eines von vier Startdecks wählen (ensure_menu hat schon eins gewählt, darum frisch). +func test_start() -> void: + main.reset_profile() + await frames(2) + check(screen() is StartDeckScreen and not main.profile.started(), "neues Profil: Startdeck wählen") + await shot("00_startdeck") + await click(find_button("Feuer wählen")) + var p: Profile = main.profile + check(screen() is MenuScreen and p.started() and p.deck.size() == Game.DECK_MIN and p.deck.filter(func(id): return Cards.by_id(id).elements[0] == "Feuer").size() == 25, "Feuer gewählt: 40 Karten, 25 davon Feuer") + check(Profile.load_or_new(SAVE).started(), "gespeichert") + + func test_menu() -> void: check(screen() is MenuScreen, "Menü sichtbar") check(find_button("Einzelspieler") != null and find_button("Beenden") != null, "Menüknöpfe vorhanden") @@ -213,8 +222,8 @@ func test_booster() -> void: await shot("04_booster_offen") check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert") 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") + if res.new or res.upgraded: + check(main.profile.owns(res.card.id) and main.profile.level(res.card.id) == Cards.RARITIES.find(res.rarity), "neue oder 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") @@ -255,6 +264,11 @@ func drag_fuse(scr, from: Control, to: Control) -> void: m.button_mask = MOUSE_BUTTON_MASK_LEFT get_viewport().push_input(m) await frames(1) + if scr.drag_ghost == null or scr.drag_target == null: + var fo = from.card if from is CardView else from.creature + var to_o = to.card if to is CardView else to.creature + print("DEBUG drag: ghost=", scr.drag_ghost, " target=", scr.drag_target, " press=", scr.press_target, " dran=", scr.my_turn(), + " overlay=", scr.any_overlay(), " mana=", scr.game.human.energy, " kann=", scr.game.can_fuse(scr.game.human, fo, to_o), " from=", from.get_global_rect(), " to=", to.get_global_rect()) check(scr.drag_ghost != null and scr.drag_target != null, "Karte schwebt über einem Verschmelzungsziel") var waited := 0 while scr.drag_ghost != null and waited < 240: @@ -297,7 +311,7 @@ func human_turn(scr) -> void: var mana: int = g.human.energy var hand: int = g.human.hand.size() var cost: int = g.draw_cost(g.human) - await click(scr.offer_views[0]) + await click(scr.deck) check(g.human.energy == mana - cost and g.human.hand.size() == hand + 1, "gezogen: eine Karte") var tried := [] # Wesen ohne lohnendes Ziel (0 Angriff ohne Vorteil) nicht wieder versuchen for i in 12: @@ -329,7 +343,7 @@ func human_turn(scr) -> void: await click(target_view(scr, targets)) check(scr.selected == null and cr.attacks_done > 0, "Angriff ausgeführt") if g.winner == "": - await click(scr.b_end) + await key(KEY_SPACE) func start_battle() -> void: @@ -385,11 +399,14 @@ func leave_battle() -> void: func give_up() -> void: var scr = screen() - await click(find_button("Aufgeben")) - check(scr.confirm.visible, "Aufgeben fragt nach") + await key(KEY_ESCAPE) + check(scr.options.visible, "Esc öffnet die Optionen") + await click(find_button("Aufgeben", scr.options)) + check(scr.confirm.visible and not scr.options.visible, "Aufgeben fragt nach") await click(find_button("Weiterspielen")) check(not scr.confirm.visible and screen() == scr, "Weiterspielen bleibt im Kampf") - await click(find_button("Aufgeben")) + await key(KEY_ESCAPE) + await click(find_button("Aufgeben", scr.options)) await click(find_button("Ja, aufgeben")) check(screen() is MenuScreen, "Aufgeben führt ins Menü") @@ -419,33 +436,75 @@ func test_einstellungen() -> void: check(screen() is MenuScreen, "zurück im Menü") +## Deck: eigene Karten rein und raus, Auffüllen nach Filter, Speichern. Beginnt mit dem frischen Startdeck. +func test_deck() -> void: + main.profile.choose_start("Feuer") + main.save() + await click(find_button("Deck")) + var scr = screen() + check(scr is DeckScreen, "Deck geöffnet") + var p: Profile = main.profile + check(scr.grid.views().size() == p.collection.size() and scr.count_label.text == "Stapel: %d Karten (%d bis %d)" % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX], "alle eigenen Karten, Zähler stimmt") + var views: Array = scr.grid.views() + check(views.all(func(v): return not v.dim) and p.deck.size() == Game.DECK_MIN, "nach dem Start sind alle eigenen Karten im Stapel") + var first_id: String = views[0].card.id + await click(views[0]) + check(not (first_id in p.deck) and p.deck.size() == Game.DECK_MIN - 1 and scr.grid.views().any(func(v): return v.card.id == first_id and v.dim), "Klick nimmt die Karte aus dem Stapel, sie wird dunkel") + var back_view = scr.grid.views().filter(func(v): return v.card.id == first_id)[0] + scr.grid.ensure_control_visible(back_view) # sie liegt jetzt hinter den Stapelkarten, außerhalb des Sichtbereichs + await frames(2) + await click(back_view) + check(first_id in p.deck and p.deck.size() == Game.DECK_MIN, "Klick nimmt sie wieder hinein") + await shot("19_deck") + await click(find_button("Leeren")) + check(p.deck.is_empty(), "geleert") + await click(find_button("Wasser")) + await click(find_button("Auffüllen")) + check(p.deck.size() == 5 and p.deck.all(func(id): return Cards.by_id(id).elements[0] == "Wasser"), "Auffüllen hält sich an den Filter: nur die fünf Wasserkarten") + await click(find_button("Alle")) + await click(find_button("Auffüllen")) + check(p.deck.size() == Game.DECK_MIN and Profile.load_or_new(SAVE).deck == p.deck, "ohne Filter alles, gespeichert") + await click(find_button("Zurück")) + check(screen() is MenuScreen, "zurück im Menü") + + func test_sammlung() -> void: await click(find_button("Sammlung")) var scr = screen() check(scr is CollectionScreen, "Sammlung geöffnet") var p: Profile = main.profile - check(scr.grid.views().size() == Cards.all().size(), "alle Karten gehören dir") + check(scr.grid.views().size() == Cards.all().size(), "alle Karten des Spiels sind zu sehen") + check(scr.grid.views().slice(0, p.collection.size()).all(func(v): return not v.dim) and scr.grid.views().back().dim, "eigene Karten vorn, fehlende abgedunkelt") p.dust = 5000 - scr.search_box.text = "Phönix" - scr.search_box.text_changed.emit("Phönix") + scr.search_box.text = "Lodernder Funke" + scr.search_box.text_changed.emit("Lodernder Funke") await frames(1) - var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0] - await click(phoenix) - check(scr.selected.id == "ff_phoenix" and p.rarity("ff_phoenix") == "Common", "Karte gewählt, noch Common") + var funke = scr.grid.views().filter(func(v): return v.card.id == "f_2_1")[0] + await click(funke) + check(scr.selected.id == "f_2_1" and p.rarity("f_2_1") == "Common" and scr.b_upgrade.visible, "eigene Karte gewählt, noch Common") await click(scr.b_upgrade) - check(p.level("ff_phoenix") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["Rare"], "auf Rare aufgewertet") + check(p.level("f_2_1") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["Rare"], "auf Rare aufgewertet") scr.search_box.text = "" scr.search_box.text_changed.emit("") await frames(1) - var front: Array = scr.grid.views().slice(0, p.upgraded_count()) - check(front.all(func(v): return v.rarity != "Common") and front.any(func(v): return v.card.id == "ff_phoenix" and v.rarity == "Rare"), "aufgewertete Karten stehen vorn in der Sammlung, der Phönix als Rare dabei") - scr.search_box.text = "Phönix" - scr.search_box.text_changed.emit("Phönix") + var front = scr.grid.views().filter(func(v): return v.card.id == "f_2_1")[0] + check(front.rarity == "Rare" and front.card.attack == 3 and front.card.health == 2 and scr.grid.views()[0].rarity != "Common", "die Rare zeigt +1/+1, aufgewertete stehen vorn: %s %d/%d" % [front.rarity, front.card.attack, front.card.health]) + check(Profile.load_or_new(SAVE).level("f_2_1") == 1, "Sammlung gespeichert") + scr.search_box.text = "Lodernder Funke" + scr.search_box.text_changed.emit("Lodernder Funke") await frames(1) - check(Profile.load_or_new(SAVE).level("ff_phoenix") == 1, "Sammlung gespeichert") var dust: int = p.dust await click(scr.b_dismantle) - check(p.level("ff_phoenix") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["Rare"], "wieder zerlegt") + check(p.level("f_2_1") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["Rare"], "wieder zerlegt") + # Herstellen einer fehlenden Karte + scr.search_box.text = "Infernalischer Drache" + scr.search_box.text_changed.emit("Infernalischer Drache") + await frames(1) + await click(scr.grid.views().filter(func(v): return v.card.id == "f_5_5")[0]) + check(scr.b_craft.visible and not scr.b_upgrade.visible, "fehlende Karte: nur Herstellen") + dust = p.dust + await click(scr.b_craft) + check(p.owns("f_5_5") and p.dust == dust - Profile.CRAFT_COST, "hergestellt") await shot("20_sammlung") await click(find_button("Zurück")) check(screen() is MenuScreen, "zurück im Menü") @@ -489,7 +548,9 @@ 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.is_empty(), "Profil zurückgesetzt") + check(screen() is StartDeckScreen and main.profile.coins == Profile.START_COINS and not main.profile.started(), "Profil zurückgesetzt: Startdeck neu wählen") + await click(find_button("Luft wählen")) + check(screen() is MenuScreen and main.profile.deck.filter(func(id): return Cards.by_id(id).elements[0] == "Luft").size() == 25, "Luft gewählt") check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert") @@ -526,26 +587,26 @@ func test_kampf_steuerung() -> void: await wait_for_human(scr) var g: Game = scr.game g.human.energy = 20 # die Starthand ist zufällig, hier soll nur die Steuerung geprüft werden - g.human.hand = [Cards.by_id("f_wolf").copy()] + g.human.hand = [Cards.by_id("f_3_2").copy()] scr.refresh() - check(scr.hand_views.size() == 1 and scr.draw_enabled and scr.offer_views.size() == Game.OFFER, "eine Handkarte, drei offene auf dem Stapel") - # Ziehen: die gewählte offene Karte landet auf der Hand, das erste Ziehen ist frei + check(scr.hand_views.size() == 1 and scr.deck.enabled, "eine Handkarte, Stapel bereit") + # Ziehen: eine Karte vom eigenen Stapel, das erste Ziehen ist frei var mana: int = g.human.energy - var wanted: Cards.Card = g.human.offer[1] - await click(scr.offer_views[1]) - check(g.human.energy == mana and g.human.hand.size() == 2 and scr.hand_views.size() == 2 and g.human.hand[1].id == wanted.id, "gezogen: die gewählte Karte, frei") - check(g.human.offer.size() == Game.OFFER and g.human.offer.all(func(c): return c.cost <= g.human.max_energy) and g.draw_cost(g.human) == Game.DRAW_COST, "eine bezahlbare neue liegt nach, das nächste Ziehen kostet 2") + var deck_before: int = g.human.deck.size() + await click(scr.deck) + check(g.human.energy == mana and g.human.hand.size() == 2 and scr.hand_views.size() == 2 and g.human.deck.size() == deck_before - 1, "gezogen: eine Karte, frei") + check(g.draw_cost(g.human) == 1 and scr.deck.cost == 1, "das nächste Ziehen kostet 1") # Ausspielen kostet die Kartenkosten; das Wesen greift noch nicht an 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 - Cards.by_id("f_wolf").cost, "Wesen per Taste gespielt, Kosten bezahlt") + check(g.human.board.size() == board_before + 1 and g.human.energy == mana - Cards.by_id("f_3_2").cost, "Wesen per Taste gespielt, Kosten bezahlt") check(not g.human.board.back().can_attack(), "frisch gelegt greift es noch nicht an") # Rückgängig: Wesen zurück auf die Hand, Mana zurück 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 == "f_wolf") and g.human.energy == mana, "Rückgängig stellt Hand und Mana her") + check(g.human.board.size() == board_before and g.human.hand.any(func(c): return c.id == "f_3_2") and g.human.energy == mana, "Rückgängig stellt Hand und Mana 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") @@ -553,7 +614,7 @@ func test_kampf_steuerung() -> void: mana = g.human.energy var hand_before: int = g.human.hand.size() await key(KEY_Z) - check(g.human.energy == mana - Game.DRAW_COST and g.human.hand.size() == hand_before + 1, "Z zieht die erste offene Karte für 2") + check(g.human.energy == mana - 1 and g.human.hand.size() == hand_before + 1, "Z zieht, das zweite Ziehen für 1") await shot("22_kampf_steuerung") # Gamepad: A auf dem Stapel zieht await pad(JOY_BUTTON_DPAD_LEFT) @@ -565,7 +626,7 @@ func test_kampf_steuerung() -> void: await key(KEY_SPACE) check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") await frames(2) - check(scr.b_skip.visible and not scr.b_end.visible, "Überspringen-Knopf während des Gegnerzugs") + check(scr.b_skip.visible, "Überspringen-Knopf während des Gegnerzugs") await click(scr.b_skip) var w := 0 while g.current() != g.human and g.winner == "" and w < 120: @@ -574,9 +635,15 @@ func test_kampf_steuerung() -> void: check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w) scr.ai_delay = 0.0 await key(KEY_ESCAPE) - check(scr.confirm.visible, "Esc öffnet Aufgeben") + check(scr.options.visible and find_button("Hauptmenü", scr.options) != null and find_button("Beenden", scr.options) != null, "Esc öffnet die Optionen") await key(KEY_ESCAPE) - check(not scr.confirm.visible, "Esc schließt den Dialog") + check(not scr.options.visible, "Esc schließt die Optionen") + # Einstellungen aus dem Kampf und zurück + await key(KEY_ESCAPE) + await click(find_button("Einstellungen", scr.options)) + check(screen() is SettingsScreen and is_instance_valid(scr), "Einstellungen geöffnet, der Kampf bleibt erhalten") + await click(find_button("Zurück")) + check(screen() == scr and scr.game == g and not scr.options.visible, "zurück im selben Kampf") await give_up() @@ -594,21 +661,21 @@ func test_kampf_grenzen() -> void: var guard := 0 while g.human.hand.size() < Game.MAX_HAND and guard < 30: guard += 1 - await click(scr.offer_views[0]) + await click(scr.deck) check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND) - check(not scr.draw_enabled, "bei voller Hand lässt sich nichts ziehen") - await click(scr.offer_views[0]) + check(not scr.deck.enabled, "bei voller Hand lässt sich nichts ziehen") + await click(scr.deck) check(scr.msg_label.text == tr("Deine Hand ist 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 if g.human.hand.is_empty(): - await click(scr.offer_views[0]) + await click(scr.deck) await key(KEY_1) check(g.human.board.size() == Game.MAX_BOARD, "Feld mit %d Wesen voll" % Game.MAX_BOARD) if g.human.hand.is_empty(): - await click(scr.offer_views[0]) + await click(scr.deck) 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") @@ -619,10 +686,10 @@ func test_kampf_grenzen() -> void: check(g.human.board.size() == before - 1, "Verschmelzen macht einen Platz frei") # Ohne Mana kein Ziehen g.human.energy = 0 - g.human.drew = true + g.human.draws = 1 scr.refresh() await frames(1) - check(not scr.draw_enabled, "ohne Mana kein zweites Ziehen") + check(not scr.deck.enabled, "ohne Mana kein zweites Ziehen") await give_up() @@ -646,27 +713,27 @@ func test_kampf_elemente_und_verschmelzen() -> void: cr.hidden = hidden p.board.append(cr) return cr - var wolf_cr: Game.Creature = put.call(g.human, "f_wolf") - wolf_cr.health = 4 # übersteht die Konter von Falke und Kobold, damit der Test ihn weiter nutzen kann + var wolf_cr: Game.Creature = put.call(g.human, "f_3_2") + wolf_cr.health = 4 # übersteht die Konter von Falke und Funke, damit der Test ihn weiter nutzen kann wolf_cr.max_health = 4 - put.call(g.human, "f_asche") - put.call(g.human, "w_reiter") - var falke: Game.Creature = put.call(scr.opponent(), "l_falke") - var secret: Game.Creature = put.call(scr.opponent(), "f_kobold", true) - g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()] + put.call(g.human, "f_4_3") + put.call(g.human, "w_3_3") + var falke: Game.Creature = put.call(scr.opponent(), "l_3_1") + var secret: Game.Creature = put.call(scr.opponent(), "f_2_1", true) + g.human.hand = [Cards.by_id("f_2_1").copy(), Cards.by_id("l_1_1").copy()] g.human.energy = 20 - g.human.looks = {"w_reiter": 3} + g.human.looks = {"w_3_3": 3} g.apply_auras() scr.refresh() await frames(2) - check(scr.element_view.text() == "Feuer 2/0 (Doppelschlag) · Wasser 1/0 · Erde 0/0 · Luft 0/0", "Zähler: offene eigene Wesen und ausgespielte Karten, Feuerpaar: " + scr.element_view.text()) - check(scr.opp_element_view.text() == "Feuer 0/0 · Wasser 0/0 · Erde 0/0 · Luft 1/0", "die Leiste des Gegners zählt nur seine offenen Wesen: " + scr.opp_element_view.text()) + check(scr.element_view.text() == "Feuer 2/0 (Doppelschlag) · Wasser 1/0", "Zähler: nur Elemente mit Wesen oder gespielten Karten, Feuerpaar: " + scr.element_view.text()) + check(scr.opp_element_view.text() == "Luft 1/0", "die Liste des Gegners zählt nur seine offenen Wesen: " + scr.opp_element_view.text()) check(g.human.board[0].keywords() == ["Doppelschlag"] and scr.tiles[secret.uid].secret and scr.hand_views[0].bonus == 0, "Feuerpaar gibt Doppelschlag; fremdes Verdecktes zeigt den Rücken; noch kein Bonus in Sicht") # Rechtsklick auf die Handkarte: Menü mit Verdeckt legen, ohne Verschmelzen await hover(scr.hand_views[0]) await right_click_at(scr.hand_views[0].get_global_rect().get_center()) check(scr.menu_layer.visible and find_button("Verdeckt legen ◆ %d" % g.human.hand[0].cost, scr.menu_layer) != null and find_button("Abwerfen +1 ◆", scr.menu_layer) != null, "Rechtsklick öffnet das Kartenmenü mit Verdeckt legen und Abwerfen") - check(find_button("Verschmelzen mit … ◆ 1", scr.menu_layer) == null, "kein Verschmelzen im Rechtsklick-Menü") + check(find_button("Verschmelzen mit …", scr.menu_layer) == null, "kein Verschmelzen im Rechtsklick-Menü") await shot("23_kartenmenue") await right_click() check(not scr.menu_layer.visible, "Rechtsklick schließt das Menü") @@ -685,16 +752,16 @@ func test_kampf_elemente_und_verschmelzen() -> void: while scr.own_views[3].busy: await frames(1) await click(scr.own_views[3]) - check(mine_hidden.hidden and scr.msg_label.text == "Feuerkobold kann erst im nächsten Zug aufgedeckt werden", "im Legezug nicht aufzudecken: " + scr.msg_label.text) + check(mine_hidden.hidden and scr.msg_label.text == "Lodernder Funke kann erst im nächsten Zug aufgedeckt werden", "im Legezug nicht aufzudecken: " + scr.msg_label.text) mine_hidden.ready = true await click(scr.own_views[3]) check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3/1 (Doppelschlag)"), "aufgedeckt: drei offene Feuer, eine ausgespielt: " + scr.element_view.text()) - check(wolf.power() == 3 and falke.power() == 3 and mine_hidden.can_attack(), "unter 10 Mana kein Bonus, aufgedeckt greift sofort an") - g.human.played["Feuer"] = 10 + check(wolf.power() == 3 and falke.power() == 3 and mine_hidden.can_attack(), "unter fünf Karten kein Bonus, aufgedeckt greift sofort an") + g.human.played["Feuer"] = 5 g.apply_auras() scr.refresh() await frames(1) - check(scr.element_view.text().begins_with("Feuer 3/10 (Doppelschlag) (+1)") and wolf.power() == 4 and falke.power() == 3, "10 Mana Feuer: +1 Angriff für die eigenen Feuerwesen: " + scr.element_view.text()) + check(scr.element_view.text().begins_with("Feuer 3/5 (Doppelschlag) (+1/+1)") and wolf.power() == 4 and falke.power() == 3, "fünf Feuerkarten: +1/+1 für die eigenen Feuerwesen: " + scr.element_view.text()) check(scr.hand_views[0].bonus == 0, "die Luftkarte auf der Hand zeigt keinen Bonus") await shot("23b_vier_feuer") # Angriffsziel: Elementvorteil, verdeckte ohne Anzeige, Held gesperrt @@ -714,220 +781,26 @@ func test_kampf_elemente_und_verschmelzen() -> void: mana = g.human.energy var before: int = g.human.board.size() await drag_fuse(scr, scr.own_views[1], scr.own_views[2]) - check(g.human.board.size() == before - 1 and g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 1, "zu Dampf verschmolzen, 1 Mana bezahlt") + check(g.human.board.size() == before - 1 and g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 3, "zu Dampf verschmolzen, 3 Mana bezahlt (halbe Manasumme)") var dampf: Game.Creature = g.human.board.filter(func(c): return c.name == "Dampf")[0] - check(scr.tiles[dampf.uid].rarity() == "Ultra Rare" and dampf.card.look == 3, "die Fusion trägt die höhere Stufe (Wellenreiter war Ultra Rare)") + check(scr.tiles[dampf.uid].rarity() == "Ultra Rare" and dampf.card.look == 3, "die Fusion trägt die höhere Stufe (der Reiter war Ultra Rare)") await shot("24_verschmelzen") # Verschmelzen auf der Hand per Ziehen - g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()] + g.human.hand = [Cards.by_id("f_2_1").copy(), Cards.by_id("f_2_1").copy(), Cards.by_id("l_1_1").copy()] scr.refresh() await frames(1) await drag_fuse(scr, scr.hand_views[0], scr.hand_views[1]) - check(g.human.hand.size() == 2 and g.human.hand[0].name == "Inferno" and g.human.hand[0].attack == 3 and g.human.hand[0].health == 2, "zwei Kobolde auf der Hand zu Inferno 3/2") + check(g.human.hand.size() == 2 and g.human.hand[0].name == "Inferno" and g.human.hand[0].attack == 3 and g.human.hand[0].health == 2, "zwei Funken auf der Hand zu Inferno 3/2") # Gamepad-Menü (Y) bietet Verdeckt legen und Verschmelzen mit … scr.open_menu(g.human.hand[0], Rect2(scr.hand_views[0].position, scr.hand_views[0].size), true) check(find_button("Verdeckt legen ◆ %d" % g.human.hand[0].cost, scr.menu_layer) != null and g.human.hand[0].cost == 2, "Gamepad-Menü: Verdeckt legen, Inferno kostet 2") - await click(find_button("Verschmelzen mit … ◆ 2", scr.menu_layer)) + await click(find_button("Verschmelzen mit …", scr.menu_layer)) check(scr.fuse_first == g.human.hand[0], "Partner wählen") await click(scr.hand_views[1]) check(g.human.hand.size() == 1 and g.human.hand[0].name == "Blitz", "Inferno und Windgeist zu Blitz") await give_up() -func test_netzwerk() -> void: - await click(find_button("Netzwerkspiel")) - var scr = screen() - check(scr is LobbyScreen, "Lobby geöffnet") - await click(find_button("Spiel eröffnen")) - check(main.net.is_host and main.net.connected, "Spiel eröffnet") - if FileAccess.file_exists("user://bot_quit"): - DirAccess.remove_absolute(ProjectSettings.globalize_path("user://bot_quit")) - var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"] - var pid := OS.create_process(OS.get_executable_path(), args) - check(pid > 0, "Bot-Prozess gestartet") - var wait := 0 - while main.net.names.size() < 2 and wait < 600: - await frames(1) - wait += 1 - if main.net.names.size() < 2: - print("HINWEIS: Bot kam nicht, zweiter Versuch") - OS.kill(pid) - pid = OS.create_process(OS.get_executable_path(), args) - while main.net.names.size() < 2 and wait < 1500: - await frames(1) - wait += 1 - check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path()) - await shot("23_lobby") - await click(find_button("Start")) - var b = screen() - check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host") - b.ai_delay = 0.0 - var rounds := 0 - while b.game != null and b.game.winner == "" and rounds < 2: - if b.game.current() == b.perspective: - if b.game.current() == b.perspective: - await click(b.b_end) - var w2 := 0 - while b.game.current() != b.perspective and b.game.winner == "" and w2 < 900: - await frames(1) - w2 += 1 - rounds += 1 - 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 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" - var f := FileAccess.open(quit_file, FileAccess.WRITE) - f.store_string("x") - f.close() - var w3 := 0 - while main.net.waiting.is_empty() and w3 < 600: - await frames(1) - w3 += 1 - 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: - if b.game.current() == b.perspective and b.game.winner == "": - await click(b.b_end) - var w4 := 0 - while b.game.current() != b.perspective and b.game.winner == "" and w4 < 300: - await frames(1) - w4 += 1 - check(b.game.turn >= turn_before + 1 and b.game.log.any(func(l): return "übersprungen" in l), "Züge des fehlenden Spielers werden übersprungen") - DirAccess.remove_absolute(ProjectSettings.globalize_path(quit_file)) - await frames(5) - var pid2 := OS.create_process(OS.get_executable_path(), args) - var w5 := 0 - while not main.net.waiting.is_empty() and w5 < 900: - await frames(1) - w5 += 1 - 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: - if b.game.current() == b.perspective and b.game.winner == "": - await click(b.b_end) - var w6 := 0 - while b.game.current() != b.perspective and b.game.winner == "" and w6 < 900: - await frames(1) - w6 += 1 - check(b.game.turn >= turn2 + 1, "Partie läuft nach der Rückkehr weiter") - OS.kill(pid2) - await leave_battle() - check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü") - - -func _bot_args(extra: Array) -> Array: - return ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--"] + extra - - -func _bot_quit(on: bool) -> void: - var path := "user://bot_quit" - if on: - var f := FileAccess.open(path, FileAccess.WRITE) - f.store_string("x") - f.close() - elif FileAccess.file_exists(path): - DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) - - -## Verbindet sich mit dem Bot-Host (mehrere Versuche, bis er lauscht). -func _join_bot_host(watch: bool) -> void: - await click(find_button("Netzwerkspiel")) - var lobby = screen() - lobby.ip_box.text = "127.0.0.1" - for attempt in 8: - await click(find_button("Zuschauen" if watch else "Beitreten")) - var w := 0 - while not main.net.connected and w < 180: - await frames(1) - w += 1 - if main.net.connected: - return - main.net.leave() - await frames(30) - - -## Der Host (ein Bot) verschwindet: der einzige Mitspieler übernimmt und spielt zu Ende. -func test_host_wechsel() -> void: - _bot_quit(false) - var pid := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"])) - check(pid > 0, "Bot-Host gestartet") - await frames(30) - await _join_bot_host(false) - var w := 0 - while not (screen() is BattleScreen) and w < 900: - await frames(1) - w += 1 - var b = screen() - check(b is BattleScreen and b.is_client(), "als Mitspieler beim Bot-Host im Kampf") - w = 0 - while (b.game == null or main.net.backup.is_empty()) and w < 600: - await frames(1) - w += 1 - check(b.game != null and main.net.i_am_successor and not main.net.backup.is_empty(), "Nachfolger kennt den vollen Spielstand") - main.net.rejoin_seconds = 3.0 - _bot_quit(true) - w = 0 - while not main.net.is_host and w < 900: - await frames(1) - w += 1 - check(main.net.is_host and b.game == main.net.game and screen() == b and b.game.log.any(func(l): return "übernimmt als Host" in l), - "Host-Wechsel: Mitspieler führt das Spiel weiter") - check(main.net.waiting.has("BotHost"), "Platz des alten Hosts bleibt zunächst frei") - 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 - if b.game.current() == b.perspective: - await click(b.b_end) - w = 0 - while b.game.winner == "" and w < 900: - await frames(1) - w += 1 - check(b.game.log.any(func(l): return "übersprungen" in l), "Zug des fehlenden Hosts übersprungen") - check(b.game.winner != "" and b.game.winner_player == b.perspective and b.game.log.any(func(l): return "kehrt nicht zurück" in l), - "nach der Frist scheidet der alte Host aus, Partie gewonnen (Sieger '%s', Zug %d)" % [b.game.winner, b.game.turn]) - await leave_battle() - _bot_quit(false) - check(screen() is MenuScreen and not main.net.connected, "nach dem Host-Wechsel zurück im Menü") - - -## Zuschauer: sieht die Partie zweier Bots ohne Hände und ohne eigene Züge. -func test_zuschauer() -> void: - _bot_quit(false) - var pid_h := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"])) - await frames(30) - var pid_c := OS.create_process(OS.get_executable_path(), _bot_args(["--ip=127.0.0.1", "--name=Bot"])) - check(pid_h > 0 and pid_c > 0, "zwei Bots gestartet") - await _join_bot_host(true) - var w := 0 - while not (screen() is BattleScreen) and w < 1200: - await frames(1) - w += 1 - var b = screen() - check(b is BattleScreen and b.spectating(), "als Zuschauer im Kampf") - w = 0 - 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.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") - if screen() is BattleScreen: # die Bots spielen schnell, die Partie kann schon vorbei sein - await leave_battle() - else: - var w2 := 0 - while not (screen() is MenuScreen) and w2 < 300: - await frames(1) - w2 += 1 - check(screen() is MenuScreen and not main.net.connected, "Zuschauer zurück im Menü") - _bot_quit(true) - await frames(60) - _bot_quit(false) - - func test_kampagne() -> void: await click(find_button("Kampagne")) var scr = screen() @@ -978,7 +851,7 @@ func test_kampf(shot_turn: int) -> void: scr.smart_ai = false g.opponent.life = 1 for i in 3: - var drache := Game.Creature.new(Cards.by_id("f_drache").copy(), g.human, 9100 + i) + var drache := Game.Creature.new(Cards.by_id("f_5_5").copy(), g.human, 9100 + i) drache.ready = true g.human.board.append(drache) scr.refresh() @@ -1015,13 +888,13 @@ func test_kampf_abbrechen() -> void: check(g.opponent != null and g.human.max_energy == Game.START_MANA, "Duell läuft") # Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit g.human.energy = 10 - var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000) + var wolf := Game.Creature.new(Cards.by_id("f_3_2"), g.human, 9000) wolf.ready = true g.human.board.append(wolf) - g.human.hand.append(Cards.by_id("w_reiter")) - g.human.looks["w_reiter"] = Cards.RARITIES.find("Super Rare") + g.human.hand.append(Cards.by_id("w_3_3")) + g.human.looks["w_3_3"] = Cards.RARITIES.find("Super Rare") scr.refresh() - var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0] + var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_3_3")[0] check(reiter_view.rarity == "Super Rare", "Handkarte trägt die eigene Stufe") await hover(scr.element_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird await hover(reiter_view) @@ -1037,13 +910,14 @@ func test_kampf_abbrechen() -> void: print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(), " dran=", g.current().name, " Sieger=", g.winner, " hovered=", get_viewport().gui_get_hovered_control()) check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover") - await click(scr.b_log) + await key(KEY_ESCAPE) + await click(find_button("Verlauf", scr.options)) check(scr.log_overlay.visible and scr.any_overlay(), "Verlauf öffnet als Überlagerung") await key(KEY_ESCAPE) check(not scr.log_overlay.visible, "Esc schließt den Verlauf") # Runden laufen lassen, damit die Gegner ihre Züge machen for round in 4: - await click(scr.b_end) + await key(KEY_SPACE) var wait := 0 while g.current() != g.human and g.winner == "" and wait < 600: await frames(1) diff --git a/translations/ui.csv b/translations/ui.csv index cdb08eb..66caac5 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -3,7 +3,7 @@ Dinger,Dinger Ein Kartenspiel: Duell um das Feld,A card game: duel for the field Einzelspieler,Single player Kampagne,Campaign -Netzwerkspiel,Network game +Deck,Deck Booster öffnen,Open boosters Einstellungen,Settings Beenden,Quit @@ -81,7 +81,7 @@ Starten,Start Sammlung,Collection Verschmelzen,Fuse Nur eigene Wesen,Only your own creatures -Aufgewertet: %d von %d Karten · Kampagne: %d/%d,Upgraded: %d of %d cards · Campaign: %d/%d +Sammlung: %d von %d Karten · Kampagne: %d/%d,Collection: %d of %d cards · Campaign: %d/%d %s ist jetzt %s,%s is now %s Alle Kapitel abgeschlossen,All chapters completed Elemente,Elements @@ -141,7 +141,7 @@ Muss zuerst angegriffen werden,Must be attacked first Greift sofort an,Attacks right away Heilt sich am Zugende ganz,Heals fully at the end of the turn Wesen greifen sofort an,Creatures attack right away -Verschmelzen mit … ◆ %d,Fuse with … ◆ %d +Verschmelzen mit …,Fuse with … Ziel wählen,Pick the partner Erst die Wesen mit Spott,Taunting creatures first %s kann erst im nächsten Zug aufgedeckt werden,%s can be revealed next turn @@ -153,3 +153,17 @@ stirbt,dies Abwerfen +%d ◆,Discard +%d ◆ Überspringen,Skip Zug %d,Turn %d +Stapel: %d Karten (%d bis %d),Deck: %d cards (%d to %d) +Herstellen (%d Staub),Craft (%d dust) +Nicht in der Sammlung,Not in the collection +%s hergestellt,%s crafted +Neu!,New! +Auffüllen,Fill up +Leeren,Clear +Dein Stapel ist leer,Your deck is empty +Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die teuersten Karten des Filters.,Click adds a card to the deck or removes it. Fill up takes the most expensive cards of the filter. +Wähle dein Startdeck,Choose your starting deck +"25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest.","25 cards of the element plus five of each other one. Boosters and the workshop bring the rest later." +%s wählen,Choose %s +Optionen,Options +Hauptmenü,Main menu diff --git a/translations/ui.en.translation b/translations/ui.en.translation index fa7464d..99544d2 100644 Binary files a/translations/ui.en.translation and b/translations/ui.en.translation differ