diff --git a/README.md b/README.md index 542baa4..8326f3d 100644 --- a/README.md +++ b/README.md @@ -20,20 +20,19 @@ Der Spielstand liegt unter `user://spielstand.json` ## Spielsysteme -- 77 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos entstehen erst im Spiel (Verschmelzen, Feldkombos) -- Kein Deck und keine Starthand: jeder hat einen eigenen Laden mit fünf Angeboten. Kaufen kostet Mana, Ausspielen nichts, neu würfeln kostet 1 Mana -- Mana wächst um 1 je Zug (bis 10) und bleibt liegen, wenn du es nicht ausgibst — sparen ist eine echte Entscheidung -- Der Laden zeigt nur Karten, die höchstens dein Einkommen plus eins kosten: teure Wesen tauchen erst später auf +- 77 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 Angriff plus Leben, halbiert und aufgerundet +- Mana: 5 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen, Legen, Verbessern und Verschmelzen +- Kein Deckbau: beide ziehen vom gemeinsamen Stapel (jede Karte dreimal). Zu Beginn fünf sehen, drei behalten; Ziehen kostet 1 Mana und zeigt drei, eine bleibt; angeboten wird nur, was das Mana dieses Zugs hergibt, der Rest wandert unter den Stapel +- Verbessern: Schild (3) und Doppelschlag (4) lassen sich für Mana auf jede Hand- oder Brettkarte kaufen (Rechtsklick oder Y). Frisch gelegte Wesen greifen sofort an, zu Zugbeginn heilen die eigenen Wesen ganz +- Verdeckt legen: gleich teuer, das Wesen greift nicht an und zählt nicht für die Elementregel; aufdecken ist kostenlos, ein Angriff deckt es auf - Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik wie bei Holo-Karten: selten farbiger Rahmen, Schein und Edelstein, episch ein wandernder Lichtstreifen, legendär Funkeln, mythisch schillernde Farben; auch auf dem Feld für alle sichtbar, Werte und Text bleiben gleich - Booster (100 Münzen): fünf gewürfelte Stufen, höhere werten die Karte auf, der Rest wird zu Staub. Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück -- Resonanz: gleiche Elemente auf dem Feld verstärken sich (+1, +2, +3, +3); der Bonus steht als „+n“ am Angriff, verbundene Wesen sind unter der Reihe verlinkt -- Feldkombos: ein neues Wesen neben einem Wesen mit anderem Element löst einen Effekt aus -- Karteneffekte lösen beim Ausspielen aus (Schaden, Einfrieren, Heilen, Zurückwerfen, Verstärken, Schild, Mana); Kartenvorteil ist hier Manavorteil -- Elementvorteil im Kreis (Wasser > Feuer > Luft > Erde > Wasser): 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, auch für Karteneffekte gegen Wesen -- Gemeinsames Feld: vier gespielte Karten eines Elements kippen es (Vulkan, Flut, Fels, Wirbel), Wesen dieses Elements bekommen +1 Angriff; die Feldzeile zeigt je Element die Ladung als vier Punkte -- Vorschau: Hand- und Ladenkarten zeigen, welchen Bonus und welche Feldkombo das Ausspielen jetzt brächte; beim Zeigen ist der Kombo-Partner markiert, beim Angreifen steht der Elementvorteil (+1/−1) am Ziel -- Verschmelzen: zwei eigene Wesen werden eins (Elemente und Schlüsselwörter beider, Werte Durchschnitt plus 1, Kosten der Durchschnitt der Karten); drei oder mehr Elemente ergeben eine Chimäre -- Duell mit ausgeloster Zugreihenfolge, wer nicht beginnt, bekommt einmalig 1 Mana; die Hand fasst 8 Karten, das Feld 5 Wesen +- Vier offene Wesen eines Elements auf dem Tisch: dieses Element bekommt +1/+1, das Element, das es schlägt, −1/−1 (Wasser > Feuer > Luft > Erde > Wasser) +- Elementvorteil beim Kampf: der Angreifer macht 1 Schaden mehr im Vorteil und 1 weniger im Nachteil, der Konter bleibt gleich, gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht, auch bei der Vier-Elemente-Regel zählt sein bester Wert +- Angriffe treffen erst Wesen; das Leben ist nur ohne Wesen angreifbar. Schaden über das Leben eines Wesens hinaus schlägt auf den Besitzer durch +- Verschmelzen: zwei eigene Wesen werden eins mit dem besseren Angriff und Leben plus 1, Elementen und Eigenschaften beider; kostet 1 Mana, verdoppelt je weiterer Verschmelzung (1, 2, 4, 8); drei oder mehr Elemente ergeben eine Chimäre +- Duell mit ausgeloster Zugreihenfolge, wer nicht beginnt, bekommt einmalig 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 @@ -55,7 +54,7 @@ godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen K 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, Ladenangebot, Musik +godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Stapel, Musik godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf ``` @@ -71,14 +70,14 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe ## Aufbau - `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json` -- `scripts/game.gd` – Spielregeln, Züge, Kampf, Feldkombos, Resonanz, Feld, Verschmelzen -- `scripts/deckgen.gd` – Ladenangebot und Gegner +- `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementregel, Kampf, Verbessern, Verschmelzen +- `scripts/deckgen.gd` – gemeinsamer Stapel und Gegner - `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) - `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Booster, 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, `field_view.gd` die Feldzeile, `battle_layout.gd` die Aufteilung des Kampfes +- `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 faaa379..43252e4 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -15,15 +15,17 @@ - Gesammelt wird die Seltenheitsstufe einer Karte, und die ist reine Optik wie bei Holo-Karten: je höher die Stufe, desto mehr Glanz (Rahmen, Schein, Lichtstreifen, Funkeln, Schillern), auch im Netzwerkspiel sichtbar - Booster öffnen: höhere Stufen werten Karten auf, der Rest wird zu Staub - Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück -- Kein Deckbau und kein Nachziehstapel: fünf Angebote im Laden, Kaufen kostet Mana, Ausspielen nichts -- Nicht ausgegebenes Mana bleibt liegen, neu würfeln kostet 1 Mana (wie in TFT) +- Kein Deckbau: alle ziehen vom gemeinsamen Stapel (jede Karte dreimal); zu Beginn fünf sehen, drei behalten; Ziehen kostet 1 Mana und zeigt drei, eine bleibt; angeboten wird nur, was man sich in diesem Zug leisten kann +- Mana für Ziehen, Legen (Kosten = Angriff + Leben, halbiert), Verbessern und Verschmelzen; 5 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten +- Karten haben nur Angriff und Leben; Eigenschaften (Schild, Doppelschlag) kauft man per Rechtsklick für Mana dazu; gelegte Wesen greifen sofort an, eigene Wesen heilen zu Zugbeginn +- Wesen lassen sich verdeckt legen; aufdecken ist frei, ein Angriff deckt auf +- Leben erst ohne Wesen angreifbar; Überschuss schlägt durch - Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen ## Elemente - Jede Karte gehört zu genau einem Element -- Kombos entstehen erst im Spiel: beim Verschmelzen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) und als Feldkombo -- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus; beides ist vor dem Ausspielen auf der Karte zu sehen -- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (1 Schaden mehr im Vorteil, 1 weniger im Nachteil; Mehrfachelemente nehmen den für sie besseren Wert) -- Das Feld gehört beiden: vier gespielte Karten eines Elements kippen es, Wesen dieses Elements bekommen +1 Angriff -- Zwei eigene Wesen lassen sich für Energie verschmelzen: das neue erbt Elemente und Fähigkeiten beider, seine Werte sind der Durchschnitt plus 1 +- Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) +- Vier offene Wesen eines Elements: +1/+1 für das Element, −1/−1 für das, das es schlägt; die Karte zeigt vorher, was sie bekäme +- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (der Angreifer macht 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, der Konter bleibt gleich; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht, und nimmt auch bei der Vier-Elemente-Regel seinen besten Wert) +- Zwei eigene Wesen lassen sich für Mana verschmelzen: das neue erbt Elemente und Eigenschaften beider, seine Werte sind jeweils der bessere Wert plus 1 - 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 60d6425..53ca608 100644 --- a/data/karten.json +++ b/data/karten.json @@ -7,7 +7,6 @@ "elements": [ "Feuer" ], - "cost": 1, "attack": 2, "health": 1 }, @@ -18,7 +17,6 @@ "elements": [ "Feuer" ], - "cost": 2, "attack": 3, "health": 2 }, @@ -29,7 +27,6 @@ "elements": [ "Feuer" ], - "cost": 3, "attack": 4, "health": 3 }, @@ -40,16 +37,8 @@ "elements": [ "Feuer" ], - "cost": 5, "attack": 6, - "health": 5, - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 2 - } - ] + "health": 5 }, { "id": "w_geist", @@ -58,16 +47,8 @@ "elements": [ "Wasser" ], - "cost": 1, "attack": 2, - "health": 2, - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 1 - } - ] + "health": 2 }, { "id": "w_reiter", @@ -76,7 +57,6 @@ "elements": [ "Wasser" ], - "cost": 2, "attack": 3, "health": 3 }, @@ -87,15 +67,8 @@ "elements": [ "Wasser" ], - "cost": 3, "attack": 4, - "health": 4, - "effects": [ - { - "kind": "freeze", - "target": "enemy_creature" - } - ] + "health": 4 }, { "id": "w_kraken", @@ -104,15 +77,8 @@ "elements": [ "Wasser" ], - "cost": 5, "attack": 6, - "health": 6, - "effects": [ - { - "kind": "freeze", - "target": "all_enemy_creatures" - } - ] + "health": 6 }, { "id": "e_wall", @@ -121,12 +87,8 @@ "elements": [ "Erde" ], - "cost": 1, "attack": 0, - "health": 4, - "keywords": [ - "Wächter" - ] + "health": 4 }, { "id": "e_wurzel", @@ -135,12 +97,8 @@ "elements": [ "Erde" ], - "cost": 2, "attack": 2, - "health": 4, - "keywords": [ - "Wächter" - ] + "health": 4 }, { "id": "e_golem", @@ -149,7 +107,6 @@ "elements": [ "Erde" ], - "cost": 3, "attack": 3, "health": 5 }, @@ -160,12 +117,8 @@ "elements": [ "Erde" ], - "cost": 4, "attack": 3, - "health": 6, - "keywords": [ - "Wächter" - ] + "health": 6 }, { "id": "e_baum", @@ -174,19 +127,8 @@ "elements": [ "Erde" ], - "cost": 5, "attack": 3, - "health": 9, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "heal", - "target": "self_hero", - "amount": 5 - } - ] + "health": 9 }, { "id": "l_wind", @@ -195,12 +137,8 @@ "elements": [ "Luft" ], - "cost": 1, "attack": 1, - "health": 1, - "keywords": [ - "Flink" - ] + "health": 1 }, { "id": "l_falke", @@ -209,12 +147,8 @@ "elements": [ "Luft" ], - "cost": 2, "attack": 3, - "health": 1, - "keywords": [ - "Flink" - ] + "health": 1 }, { "id": "l_taenzer", @@ -223,12 +157,8 @@ "elements": [ "Luft" ], - "cost": 3, "attack": 3, - "health": 3, - "keywords": [ - "Flink" - ] + "health": 3 }, { "id": "l_schlange", @@ -237,12 +167,8 @@ "elements": [ "Luft" ], - "cost": 3, "attack": 2, - "health": 2, - "keywords": [ - "Doppelschlag" - ] + "health": 2 }, { "id": "l_greif", @@ -251,19 +177,8 @@ "elements": [ "Luft" ], - "cost": 5, "attack": 5, - "health": 4, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 1 - } - ] + "health": 4 }, { "id": "ff_elementar", @@ -272,16 +187,8 @@ "elements": [ "Feuer" ], - "cost": 6, "attack": 7, - "health": 5, - "effects": [ - { - "kind": "damage", - "target": "all_enemy_heroes", - "amount": 3 - } - ] + "health": 5 }, { "id": "ff_phoenix", @@ -290,19 +197,8 @@ "elements": [ "Feuer" ], - "cost": 7, "attack": 8, - "health": 6, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "damage", - "target": "all_enemies", - "amount": 4 - } - ] + "health": 6 }, { "id": "ww_schrecken", @@ -311,16 +207,8 @@ "elements": [ "Wasser" ], - "cost": 6, "attack": 7, - "health": 7, - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 2 - } - ] + "health": 7 }, { "id": "ww_leviathan", @@ -329,24 +217,8 @@ "elements": [ "Wasser" ], - "cost": 8, "attack": 8, - "health": 8, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 2 - }, - { - "kind": "heal", - "target": "self_hero", - "amount": 5 - } - ] + "health": 8 }, { "id": "ee_bastion", @@ -355,13 +227,8 @@ "elements": [ "Erde" ], - "cost": 3, "attack": 1, - "health": 7, - "keywords": [ - "Wächter", - "Schild" - ] + "health": 7 }, { "id": "ee_riese", @@ -370,12 +237,8 @@ "elements": [ "Erde" ], - "cost": 6, "attack": 6, - "health": 9, - "keywords": [ - "Wächter" - ] + "health": 9 }, { "id": "ee_titan", @@ -384,20 +247,8 @@ "elements": [ "Erde" ], - "cost": 8, "attack": 8, - "health": 12, - "keywords": [ - "Wächter", - "Schild" - ], - "effects": [ - { - "kind": "heal", - "target": "self_hero", - "amount": 6 - } - ] + "health": 12 }, { "id": "ll_adler", @@ -406,13 +257,8 @@ "elements": [ "Luft" ], - "cost": 5, "attack": 4, - "health": 4, - "keywords": [ - "Flink", - "Doppelschlag" - ] + "health": 4 }, { "id": "ll_herr", @@ -421,20 +267,8 @@ "elements": [ "Luft" ], - "cost": 7, "attack": 6, - "health": 6, - "keywords": [ - "Flink", - "Doppelschlag" - ], - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 2 - } - ] + "health": 6 }, { "id": "fw_kessel", @@ -443,21 +277,8 @@ "elements": [ "Feuer" ], - "cost": 4, "attack": 3, - "health": 3, - "effects": [ - { - "kind": "damage", - "target": "enemy_any", - "amount": 2 - }, - { - "kind": "mana", - "target": "none", - "amount": 1 - } - ] + "health": 3 }, { "id": "fw_geysir", @@ -466,20 +287,8 @@ "elements": [ "Wasser" ], - "cost": 7, "attack": 5, - "health": 6, - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 3 - }, - { - "kind": "freeze", - "target": "all_enemy_creatures" - } - ] + "health": 6 }, { "id": "fe_golem", @@ -488,16 +297,8 @@ "elements": [ "Feuer" ], - "cost": 3, "attack": 3, - "health": 3, - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 1 - } - ] + "health": 3 }, { "id": "fe_wall", @@ -506,19 +307,8 @@ "elements": [ "Erde" ], - "cost": 5, "attack": 2, - "health": 7, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 2 - } - ] + "health": 7 }, { "id": "fe_vulkan", @@ -527,19 +317,8 @@ "elements": [ "Feuer" ], - "cost": 7, "attack": 6, - "health": 8, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "damage", - "target": "enemy_any", - "amount": 4 - } - ] + "health": 8 }, { "id": "fl_vogel", @@ -548,13 +327,8 @@ "elements": [ "Luft" ], - "cost": 4, "attack": 3, - "health": 3, - "keywords": [ - "Flink", - "Doppelschlag" - ] + "health": 3 }, { "id": "fl_gott", @@ -563,19 +337,8 @@ "elements": [ "Feuer" ], - "cost": 7, "attack": 7, - "health": 6, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "damage", - "target": "all_enemy_heroes", - "amount": 3 - } - ] + "health": 6 }, { "id": "we_kroete", @@ -584,18 +347,8 @@ "elements": [ "Wasser" ], - "cost": 2, "attack": 1, - "health": 5, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "freeze", - "target": "enemy_creature" - } - ] + "health": 5 }, { "id": "we_moor", @@ -604,13 +357,8 @@ "elements": [ "Erde" ], - "cost": 5, "attack": 4, - "health": 8, - "keywords": [ - "Wächter", - "Schild" - ] + "health": 8 }, { "id": "we_koloss", @@ -619,19 +367,8 @@ "elements": [ "Erde" ], - "cost": 7, "attack": 5, - "health": 14, - "keywords": [ - "Wächter", - "Schild" - ], - "effects": [ - { - "kind": "freeze", - "target": "all_enemy_creatures" - } - ] + "health": 14 }, { "id": "wl_nebel", @@ -640,18 +377,8 @@ "elements": [ "Wasser" ], - "cost": 4, "attack": 4, - "health": 4, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "bounce", - "target": "enemy_creature" - } - ] + "health": 4 }, { "id": "wl_gischt", @@ -660,19 +387,8 @@ "elements": [ "Wasser" ], - "cost": 2, "attack": 3, - "health": 2, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 1 - } - ] + "health": 2 }, { "id": "wl_koenigin", @@ -681,18 +397,8 @@ "elements": [ "Luft" ], - "cost": 6, "attack": 6, - "health": 6, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "bounce", - "target": "all_enemy_creatures" - } - ] + "health": 6 }, { "id": "el_laeufer", @@ -701,13 +407,8 @@ "elements": [ "Luft" ], - "cost": 2, "attack": 2, - "health": 2, - "keywords": [ - "Flink", - "Schild" - ] + "health": 2 }, { "id": "el_duene", @@ -716,13 +417,8 @@ "elements": [ "Erde" ], - "cost": 4, "attack": 3, - "health": 4, - "keywords": [ - "Wächter", - "Flink" - ] + "health": 4 }, { "id": "el_koenig", @@ -731,22 +427,8 @@ "elements": [ "Erde" ], - "cost": 7, "attack": 5, - "health": 7, - "keywords": [ - "Wächter", - "Flink", - "Schild" - ], - "effects": [ - { - "kind": "buff", - "target": "all_friendly_creatures", - "amount": 1, - "amount2": 1 - } - ] + "health": 7 }, { "id": "f_elementar", @@ -755,7 +437,6 @@ "elements": [ "Feuer" ], - "cost": 4, "attack": 5, "health": 4 }, @@ -766,19 +447,8 @@ "elements": [ "Feuer" ], - "cost": 4, "attack": 4, - "health": 3, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "damage", - "target": "enemy_any", - "amount": 1 - } - ] + "health": 3 }, { "id": "w_qualle", @@ -787,16 +457,8 @@ "elements": [ "Wasser" ], - "cost": 2, "attack": 2, - "health": 3, - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 1 - } - ] + "health": 3 }, { "id": "w_tiefen", @@ -805,12 +467,8 @@ "elements": [ "Wasser" ], - "cost": 4, "attack": 4, - "health": 6, - "keywords": [ - "Wächter" - ] + "health": 6 }, { "id": "e_kiesel", @@ -819,7 +477,6 @@ "elements": [ "Erde" ], - "cost": 2, "attack": 2, "health": 3 }, @@ -830,19 +487,8 @@ "elements": [ "Erde" ], - "cost": 4, "attack": 2, - "health": 6, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "heal", - "target": "self_hero", - "amount": 3 - } - ] + "health": 6 }, { "id": "l_fuchs", @@ -851,12 +497,8 @@ "elements": [ "Luft" ], - "cost": 2, "attack": 2, - "health": 2, - "keywords": [ - "Flink" - ] + "health": 2 }, { "id": "l_laeufer", @@ -865,12 +507,8 @@ "elements": [ "Luft" ], - "cost": 4, "attack": 3, - "health": 4, - "keywords": [ - "Flink" - ] + "health": 4 }, { "id": "ff_glut", @@ -879,16 +517,8 @@ "elements": [ "Feuer" ], - "cost": 3, "attack": 3, - "health": 3, - "effects": [ - { - "kind": "damage", - "target": "all_enemy_heroes", - "amount": 2 - } - ] + "health": 3 }, { "id": "ww_abyss", @@ -897,19 +527,8 @@ "elements": [ "Wasser" ], - "cost": 5, "attack": 3, - "health": 8, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 1 - } - ] + "health": 8 }, { "id": "ee_faust", @@ -918,12 +537,8 @@ "elements": [ "Erde" ], - "cost": 4, "attack": 5, - "health": 6, - "keywords": [ - "Schild" - ] + "health": 6 }, { "id": "ll_zwilling", @@ -932,13 +547,8 @@ "elements": [ "Luft" ], - "cost": 4, "attack": 2, - "health": 3, - "keywords": [ - "Flink", - "Doppelschlag" - ] + "health": 3 }, { "id": "fw_nebel", @@ -947,20 +557,8 @@ "elements": [ "Wasser" ], - "cost": 3, "attack": 2, - "health": 4, - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 1 - }, - { - "kind": "freeze", - "target": "enemy_creature" - } - ] + "health": 4 }, { "id": "fe_schlange", @@ -969,16 +567,8 @@ "elements": [ "Feuer" ], - "cost": 4, "attack": 3, - "health": 4, - "effects": [ - { - "kind": "damage", - "target": "enemy_creature", - "amount": 2 - } - ] + "health": 4 }, { "id": "fl_bote", @@ -987,19 +577,8 @@ "elements": [ "Luft" ], - "cost": 3, "attack": 3, - "health": 3, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "damage", - "target": "enemy_any", - "amount": 1 - } - ] + "health": 3 }, { "id": "we_moorkroete", @@ -1008,19 +587,8 @@ "elements": [ "Wasser" ], - "cost": 3, "attack": 2, - "health": 6, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "heal", - "target": "self_hero", - "amount": 3 - } - ] + "health": 6 }, { "id": "wl_boen", @@ -1029,19 +597,8 @@ "elements": [ "Luft" ], - "cost": 3, "attack": 3, - "health": 4, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 1 - } - ] + "health": 4 }, { "id": "el_duenen", @@ -1050,18 +607,8 @@ "elements": [ "Luft" ], - "cost": 3, "attack": 3, - "health": 3, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "shield", - "target": "friendly_creature" - } - ] + "health": 3 }, { "id": "f_funkenwerfer", @@ -1070,16 +617,8 @@ "elements": [ "Feuer" ], - "cost": 2, "attack": 2, - "health": 3, - "effects": [ - { - "kind": "damage", - "target": "enemy_creature", - "amount": 1 - } - ] + "health": 3 }, { "id": "f_zunge", @@ -1088,7 +627,6 @@ "elements": [ "Feuer" ], - "cost": 5, "attack": 6, "health": 5 }, @@ -1099,7 +637,6 @@ "elements": [ "Wasser" ], - "cost": 3, "attack": 4, "health": 4 }, @@ -1110,16 +647,8 @@ "elements": [ "Wasser" ], - "cost": 4, "attack": 4, - "health": 5, - "effects": [ - { - "kind": "mana", - "target": "none", - "amount": 1 - } - ] + "health": 5 }, { "id": "e_lehm", @@ -1128,7 +657,6 @@ "elements": [ "Erde" ], - "cost": 2, "attack": 3, "health": 2 }, @@ -1139,12 +667,8 @@ "elements": [ "Erde" ], - "cost": 5, "attack": 4, - "health": 7, - "keywords": [ - "Wächter" - ] + "health": 7 }, { "id": "l_bote", @@ -1153,12 +677,8 @@ "elements": [ "Luft" ], - "cost": 1, "attack": 1, - "health": 2, - "keywords": [ - "Flink" - ] + "health": 2 }, { "id": "l_jaeger", @@ -1167,12 +687,8 @@ "elements": [ "Luft" ], - "cost": 5, "attack": 5, - "health": 3, - "keywords": [ - "Flink" - ] + "health": 3 }, { "id": "fff_sonne", @@ -1181,19 +697,8 @@ "elements": [ "Feuer" ], - "cost": 9, "attack": 9, - "health": 7, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "damage", - "target": "all_enemies", - "amount": 5 - } - ] + "health": 7 }, { "id": "www_ozean", @@ -1202,23 +707,8 @@ "elements": [ "Wasser" ], - "cost": 9, "attack": 7, - "health": 10, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "freeze", - "target": "all_enemy_creatures" - }, - { - "kind": "mana", - "target": "none", - "amount": 3 - } - ] + "health": 10 }, { "id": "eee_gebirge", @@ -1227,26 +717,8 @@ "elements": [ "Erde" ], - "cost": 10, "attack": 10, - "health": 15, - "keywords": [ - "Wächter", - "Schild" - ], - "effects": [ - { - "kind": "heal", - "target": "self_hero", - "amount": 10 - }, - { - "kind": "buff", - "target": "all_friendly_creatures", - "amount": 2, - "amount2": 2 - } - ] + "health": 15 }, { "id": "lll_himmel", @@ -1255,19 +727,8 @@ "elements": [ "Luft" ], - "cost": 9, "attack": 7, - "health": 7, - "keywords": [ - "Flink", - "Doppelschlag" - ], - "effects": [ - { - "kind": "bounce", - "target": "all_enemy_creatures" - } - ] + "health": 7 }, { "id": "fe_aschegolem", @@ -1276,19 +737,8 @@ "elements": [ "Feuer" ], - "cost": 6, "attack": 4, - "health": 7, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "damage", - "target": "all_enemy_creatures", - "amount": 2 - } - ] + "health": 7 }, { "id": "fl_sturmfalke", @@ -1297,13 +747,8 @@ "elements": [ "Luft" ], - "cost": 5, "attack": 4, - "health": 4, - "keywords": [ - "Flink", - "Doppelschlag" - ] + "health": 4 }, { "id": "we_schlammriese", @@ -1312,23 +757,8 @@ "elements": [ "Erde" ], - "cost": 6, "attack": 4, - "health": 9, - "keywords": [ - "Wächter" - ], - "effects": [ - { - "kind": "freeze", - "target": "enemy_creature" - }, - { - "kind": "heal", - "target": "self_hero", - "amount": 3 - } - ] + "health": 9 }, { "id": "wl_sturmgeist", @@ -1337,23 +767,8 @@ "elements": [ "Wasser" ], - "cost": 5, "attack": 5, - "health": 4, - "keywords": [ - "Flink" - ], - "effects": [ - { - "kind": "bounce", - "target": "enemy_creature" - }, - { - "kind": "mana", - "target": "none", - "amount": 1 - } - ] + "health": 4 } ] } diff --git a/design.md b/design.md index cc151e8..2d66481 100644 --- a/design.md +++ b/design.md @@ -8,9 +8,10 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt kurz. Auf dem Brett: eigene Seite unten, Gegner oben, Verlauf links, Zug beenden rechts, Wesen als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt. -- **Teamfight Tactics** – der Laden ist eine Reihe von fünf großen Karten unten, Neuwürfeln steht - mit seinem Preis direkt daneben, die Bank (bei uns die Hand) liegt als schmale Reihe darüber. - Der Laden zeigt an, was zum eigenen Brett passt (bei uns: „+n“ und die Feldkombo). +- **Hearthstones Entdecken** – drei Karten sehen, eine nehmen: Zufall mit Entscheidung. Jede + Wahl zeigt an, was zum eigenen Brett passt (Elementbonus als „+1/+1“ oder „−1/−1“). +- **Yu-Gi-Oh** – verdeckt gelegte Monster, Angriffe erst auf Monster, Kampfschaden schlägt + durch, Fusion. - **Pokémon TCG** – Seltenheit ist Optik: Folienglanz, Funkeln, Schillern. Werte und Symbole statt Sätze. - **Allgemeine Regeln für Kartenspiel-Oberflächen** (GDKeys, Medium): klar getrennte Zonen, @@ -21,45 +22,46 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw ## Karte - Kostenkristall oben links, Bild (Elementsymbol) oben, Namensband auf 36 % Höhe, darunter - Schlüsselwörter als Symbole (Mauer = Wächter, Schild, Doppelpfeil = Flink, gekreuzte Klingen = - Doppelschlag), dann der Effekttext ohne Einleitung, unten Angriff (gelb) und Leben (rot) als - Kreise, dazwischen der Edelstein der Stufe. -- Große Karten (Vorschau, Sammlung, Booster) schreiben das Wort neben das Symbol und die - Überschrift „Beim Ausspielen“ über den Effekt. Kleine Karten zeigen nur Symbole. -- Handkacheln (96×62) sind flach: Kosten, Name, Werte, Symbole oder das Kombo-Schild. Details - kommen aus der schwebenden Vorschau. + gekaufte Eigenschaften als Symbole (Schild, gekreuzte Klingen = Doppelschlag), unten Angriff (gelb) und Leben (rot) als Kreise, dazwischen der Edelstein der + Stufe. Karten haben keinen Text: nur Element, Angriff, Leben. +- Große Karten (Vorschau, Sammlung, Booster) schreiben das Wort neben das Symbol. +- Die Hand sind bis zu fünf große Karten; Details kommen aus der schwebenden Vorschau. - Seltenheit: gewöhnlich schlicht; selten Rahmen, Schein, Edelstein, stehender Lichtstreifen; episch wandernder Lichtstreifen; legendär Funkeln; mythisch schillernde Farben. -- Der Kartentext in Daten und Sammlungssuche bleibt vollständig (`Card.text()`), die Karte - zeichnet nur `effect_text()`. +- Verdeckte fremde Wesen sind ein Kartenrücken mit „?“; eigene verdeckte zeigen die Karte mit + dem Vermerk „verdeckt“. ## Kampfbildschirm (1280×800) Nur, was gerade zählt, ist zu sehen. Von unten nach oben: -- **Laden** als fünf große Karten, links daneben „Neu würfeln“ mit Preis, rechts „Zug beenden“. -- **Hand** als flache Kacheln (die Bank), für andere verdeckt. +- **Hand** als bis zu fünf große Karten, links der Zieh-Stapel mit „◆ 1“ (Klick zieht), rechts + „Zug beenden“. +- **Wahl** als Überlagerung: zu Beginn „Wähle 3 von 5“, nach dem Ziehen „Wähle 1 von 3“; solange + sie offen ist, geht nichts anderes. - **Eigenes Brett** mit fünf sichtbaren Plätzen, färbt sich mit dem aktiven Feld; links daneben die eigene Leiste (Leben, Mana, Einkommen, Zug), rechts „Verschmelzen“ und „Rückgängig“, nur wenn sie gerade möglich sind. -- **Feldband** mit vier Ladepunkten je Element. +- **Elementzähler** mit vier Punkten je Element: offene Wesen auf dem Tisch; voll heißt +1/+1, + das geschlagene Element bekommt −1/−1. +- **Kartenmenü** per Rechtsklick oder Y auf Hand- und Brettkarten: verdeckt legen, aufdecken, + Schild und Doppelschlag kaufen. - **Gegner** oben in der Mitte mit seinem Brett in voller Größe. Das Spiel ist ein Duell wie Hearthstone: mehr als zwei Bretter passen nicht lesbar auf einen Bildschirm, und ein Gegner macht Ziele, Texte und Balance eindeutig. - **Vorschau** schwebt neben der Karte, auf die man zeigt (über Ladenkarten), mit Resonanz, Feld und Feldkombo darunter. - **Verlauf** und **Aufgeben** als kleine Knöpfe oben links; der Verlauf öffnet als Überlagerung. -- Hand- und Ladenkarten tragen „+n“ (Bonus auf dem Brett) und das Kombo-Schild; beim Zeigen ist - der Partner auf dem Brett markiert; beim Angreifen steht der Elementkonter (+1/−1) am Ziel. -- Wesen mit gleichem Element sind unter der Reihe verbunden (Resonanz). +- Hand- und Wahlkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen + steht der Elementkonter (+1/−1) am Ziel, bei Verdeckten nicht. ## Bewegung und Ton -- Zugbeginn: Band „Dein Zug“ quer über die Mitte, Laden wird ausgeteilt, Ton. -- Kaufen: die Karte fliegt aus dem Laden auf die Hand, Münzton. +- Zugbeginn: Band „Dein Zug“ quer über die Mitte, Ton. +- Ziehen: die Wahl wird ausgeteilt; die genommene Karte fliegt auf die Hand. - Ausspielen: die Kachel fliegt von der Hand auf ihren Platz und federt ein. - Angriff: Ausfallschritt, roter Rahmen am Ziel, kurzes Zittern des Bretts, Schadenszahl. -- Feldwechsel: Band in Elementfarbe, das Brett färbt sich. +- Aufdecken: „Aufgedeckt!“ am Wesen; Verbessern: die Eigenschaft steigt grün auf. - Karten heben sich beim Zeigen an, Knöpfe federn beim Drücken, Bildschirme blenden ein. - Booster: Karten drehen sich auf, seltene Stufen blitzen in ihrer Farbe auf. - Alles hängt an der Einstellung „Animationen“. diff --git a/scripts/ai.gd b/scripts/ai.gd index 9d9d2bf..7814fb7 100644 --- a/scripts/ai.gd +++ b/scripts/ai.gd @@ -1,25 +1,19 @@ class_name AI -## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels -## ausgeführt und die Stellung danach bewertet; die besten Erstschritte werden um -## einen Folgeschritt vertieft. Die gierige Variante (choose_action_greedy) dient -## als Vergleich und für die Schwierigkeit Leicht. Alles hier ist zustandslos und -## darf in einem Hintergrund-Thread auf einer Spielkopie laufen. +## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels ausgeführt und die +## Stellung danach bewertet; die besten Erstschritte werden um einen Folgeschritt vertieft. +## Verdeckte gegnerische Wesen kennt die KI nicht (Game.masked_clone). Die gierige Variante +## (choose_action_greedy) dient als Vergleich und für die Schwierigkeit Leicht. Alles hier ist +## zustandslos und darf in einem Hintergrund-Thread auf einer Spielkopie laufen. # --- Bewertung ------------------------------------------------------------------- static func creature_value(c: Game.Creature) -> float: var v := float(c.power() + c.health) - if c.taunt(): - v += 2.0 if c.shield: - v += 1.5 + v += 1.0 if c.attacks_per_turn() == 2: - v += c.power() - if c.frozen: - v -= c.power() # setzt den nächsten Angriff aus: der Angriffswert zählt nicht - if not c.ready: - v -= c.power() * 0.25 + v += c.power() * 0.5 return v @@ -38,16 +32,14 @@ static func hand_value(p: Game.Player) -> float: return v -## Stellung aus Sicht von p: eigenes Leben, Feld, Hand und übriges Mana zählen positiv, -## die des Gegners negativ. Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, -## tödliche besonders. +## Stellung aus Sicht von p: eigenes Leben, Brett und Hand zählen positiv, die des Gegners +## negativ. Ohne eigene Wesen zielt gegnerische Angriffskraft aufs Leben, tödliche besonders. static func evaluate(g: Game, p: Game.Player) -> float: if not p.alive(): return -10000.0 - var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.02 + var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.01 var alive_enemies := 0 var threat := 0.0 - var leader := 0.0 for e in g.players: if e == p: continue @@ -55,59 +47,40 @@ static func evaluate(g: Game, p: Game.Player) -> float: score += 40.0 continue alive_enemies += 1 - var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2 - score -= ev - leader = maxf(leader, ev) + score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2 if e.life <= 6: score += 3.0 # fast besiegt: dranbleiben for c in e.board: - if not c.frozen: + if not c.hidden: threat += c.power() * c.attacks_per_turn() if alive_enemies == 0: return 10000.0 - score -= leader * 0.25 - var guarded := false - for c in p.board: - if c.taunt(): - guarded = true - break - if guarded: - score -= threat * 0.1 - elif threat >= p.life: - score -= 25.0 + if p.board.is_empty(): + if threat >= p.life: + score -= 25.0 + else: + score -= threat * 0.3 else: - score -= threat * 0.3 + score -= threat * 0.12 # Durchschlag: auch hinter Wesen tut Angriffskraft weh return score # --- Vorausschau --------------------------------------------------------------- -const MAX_TARGETS := 4 # je Karte bzw. Angreifer nur die aussichtsreichsten Ziele prüfen +const MAX_TARGETS := 4 # je Angreifer nur die aussichtsreichsten Ziele prüfen +static var allow_hidden := true # zum Messen: verdeckt legen erwägen +static var allow_upgrade := true # zum Messen: Verbessern erwägen const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug -## Grobe Rangfolge eines Ziels für einen Karteneffekt (nur zum Vorsortieren). -static func _target_rank(kind: String, amount: int, t) -> float: - if t is Game.Player: - return 50.0 - t.life if kind == "damage" else 0.0 - match kind: - "damage": - return 100.0 + _value(t) if not t.shield and t.health <= amount else float(_value(t)) - "freeze", "bounce": - return t.card.cost * 10.0 + t.power() - "buff", "shield": - return (100.0 if t.can_attack() else 0.0) + t.power() - return float(_value(t)) - - -## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann Helden, dann der Rest. +## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann der Rest; Durchschlag zählt mit. static func _attack_rank(cr: Game.Creature, t) -> float: if t is Game.Player: return 50.0 - t.life if _kills(cr, t): - return (100.0 if not _kills(t, cr) else 60.0) + _value(t) + return (100.0 if not _kills(t, cr) else 60.0) + _value(t) + _overflow(cr, t) return -float(t.health) @@ -121,65 +94,44 @@ static func _top(list: Array, rank: Callable, n: int) -> Array: return ranked.slice(0, n).map(func(pair): return pair[1]) -## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Karte und Angreifer. +## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Angreifer. static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array: var out := [] - var valid_cache := {} - var afford := false - var seen := {} - for i in p.shop.size(): - var offer = p.shop[i] - if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND: - afford = true - if not seen.has(offer.id): # dasselbe Angebot zweimal zu prüfen bringt nichts - seen[offer.id] = true - out.append({"type": "buy", "slot": i, "card": offer}) - # Neu würfeln lohnt nur, wenn ein Laden da ist und nichts Bezahlbares darin liegt. - if not afford and not p.shop.is_empty() and p.energy >= Game.REROLL_COST and p.hand.size() < Game.MAX_HAND: - out.append({"type": "reroll"}) for card in p.hand: if not game.can_play(p, card)[0]: continue - var tt: String = card.target_type() - if tt == "": - out.append({"type": "play", "card": card, "target": null}) - continue - if not valid_cache.has(tt): - valid_cache[tt] = game.valid_targets(p, tt) - var valid: Array = valid_cache[tt] - if valid.is_empty(): - out.append({"type": "play", "card": card, "target": null}) - continue - if prune: - var fx: Cards.Effect = null - for f in card.effects: - if f.target == tt: - fx = f - break - var kind: String = fx.kind if fx != null else "" - var amount: int = fx.amount if fx != null else 0 - var key := "%s/%s/%d" % [tt, kind, amount] - if not valid_cache.has(key): - valid_cache[key] = _top(valid, func(t): return _target_rank(kind, amount, t), MAX_TARGETS) - valid = valid_cache[key] - for t in valid: - out.append({"type": "play", "card": card, "target": t}) + out.append({"type": "play", "card": card, "face_down": false}) + if allow_hidden and game.forecast(p, card) < 0: + out.append({"type": "play", "card": card, "face_down": true}) + for cr in p.board: + if cr.hidden and game.can_reveal(p, cr)[0]: + out.append({"type": "reveal", "creature": cr}) + elif not cr.hidden and allow_upgrade: + # Nur Verbesserungen, die sich noch in diesem Zug auszahlen können: Doppelschlag für + # ein Wesen, das gleich angreift; Schild nur, wenn kein Wesen mehr spielbar ist. + var has_target := not game.enemies_of(p).is_empty() + if cr.attacks_left > 0 and has_target and game.can_upgrade(p, cr, "Doppelschlag")[0]: + out.append({"type": "upgrade", "creature": cr, "keyword": "Doppelschlag"}) + if not p.hand.any(func(c): return game.can_play(p, c)[0]) and game.can_upgrade(p, cr, "Schild")[0]: + out.append({"type": "upgrade", "creature": cr, "keyword": "Schild"}) var pairs := [] for i in p.board.size(): for j in range(i + 1, p.board.size()): if game.can_fuse(p, p.board[i], p.board[j])[0]: - pairs.append({"type": "fuse", "creature": p.board[i], "other": p.board[j]}) - if prune: # Verschmelzen lohnt vor allem bei schwachen Wesen - pairs = _top(pairs, func(a): return -float(_value(a.creature) + _value(a.other)), MAX_TARGETS) - out.append_array(pairs) + pairs.append([p.board[i], p.board[j]]) + if prune and pairs.size() > MAX_TARGETS: + pairs = _top(pairs, func(pr): return _value(pr[0]) + _value(pr[1]), MAX_TARGETS) + for pr in pairs: + out.append({"type": "fuse", "creature": pr[0], "other": pr[1]}) for cr in p.board: if not cr.can_attack(): continue var targets := [] for e in game.enemies_of(p): - for t in e.board + [e]: - if game.can_attack(cr, t)[0]: - targets.append(t) + targets.append_array(e.board) + if e.board.is_empty(): + targets.append(e) + targets = targets.filter(func(t): return game.can_attack(cr, t)[0]) if prune: targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS) for t in targets: @@ -190,20 +142,17 @@ static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) - ## Führt eine Aktion des Originals auf der Kopie aus. static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void: var p2: Game.Player = g2.players[game.current_idx] - var target = null - if action.get("target") != null: - target = map_target(g2, game, action.target) match action.type: "play": - g2.play_card(p2, action.card, target) - "buy": - g2.buy(p2, int(action.slot)) - "reroll": - g2.reroll(p2) + g2.play_card(p2, action.card, bool(action.get("face_down", false))) + "reveal": + g2.reveal(p2, g2.find_creature(action.creature.uid)) + "upgrade": + g2.upgrade(p2, g2.find_creature(action.creature.uid), action.keyword) "fuse": g2.fuse(p2, g2.find_creature(action.creature.uid), g2.find_creature(action.other.uid)) _: - g2.attack(g2.find_creature(action.creature.uid), target) + g2.attack(g2.find_creature(action.creature.uid), map_target(g2, game, action.target)) static func map_target(g2: Game, game: Game, t): @@ -224,21 +173,24 @@ static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary return out -## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer -## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte -## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug. +## Beste Aktion nach einer Vorausschau von zwei Schritten auf einer Kopie, die verdeckte +## gegnerische Wesen nicht kennt. Bei Zeitmangel gilt der beste bisherige Zug. static func choose_action(game: Game, p: Game.Player) -> Dictionary: if game.winner != "" or game.enemies_of(p).is_empty(): return {"type": "end"} + if not p.offer.is_empty(): + return {"type": "pick", "index": pick_index(game, p)} + var view := game.masked_clone(p) + var me: Game.Player = view.players[game.players.find(p)] var t0 := Time.get_ticks_msec() - var base := evaluate(game, p) + 0.01 + var base := evaluate(view, me) + 0.01 var firsts := [] # {action, game, score} - for action in candidate_actions(game, p, true): + for action in candidate_actions(view, me, true): if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms: break - var g2 := game.clone() - apply_on_clone(g2, game, action) - firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])}) + var g2 := view.clone() + apply_on_clone(g2, view, action) + firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[view.current_idx])}) firsts.sort_custom(func(a, b): return a.score > b.score) var best := {"type": "end"} var best_score := base @@ -247,7 +199,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary: var g2: Game = f.game var score: float = f.score if g2.winner == "": - var p2: Game.Player = g2.players[game.current_idx] + var p2: Game.Player = g2.players[view.current_idx] var count := 0 for nxt in candidate_actions(g2, p2, true): if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms: @@ -255,7 +207,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary: count += 1 var g3 := g2.clone() apply_on_clone(g3, g2, nxt) - score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001) + score = maxf(score, evaluate(g3, g3.players[view.current_idx]) - 0.001) if score > best_score: best_score = score best = f.action @@ -264,57 +216,81 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary: if firsts[i].score > best_score: best_score = firsts[i].score best = firsts[i].action - return best + if best.type == "end": + if game.can_draw(p)[0]: + return {"type": "draw"} # Mana übrig und Platz auf der Hand: lieber ziehen als passen + return best + return map_action(game, view, best) -# --- Gierige Vergleichs-KI ------------------------------------------------------- +# --- Einfache Heuristiken ---------------------------------------------------------- static func _kills(attacker, defender) -> bool: - return not defender.shield and defender.health <= attacker.power() + Game.matchup_bonus(attacker, defender) + if defender is Game.Player: + return attacker.power() >= defender.life + if defender.shield: + return false + return attacker.power() + Cards.matchup(attacker.card.elements, defender.card.elements) >= defender.health + + +## Schaden, der beim Angriff auf das Wesen auf dessen Besitzer durchschlägt. +static func _overflow(attacker, defender) -> int: + if defender is Game.Player or defender.shield: + return 0 + return maxi(0, attacker.power() + Cards.matchup(attacker.card.elements, defender.card.elements) - defender.health) static func _value(c) -> int: - return c.power() + c.health + (2 if c.taunt() else 0) + if c is Game.Player: + return 1000 + return c.power() + c.health -static func _best(arr: Array, key: Callable): +static func _best(list: Array, key: Callable): var best = null - var best_v := 0 - for x in arr: - var v: int = key.call(x) - if best == null or v > best_v: - best = x + var best_v := -INF + for t in list: + var v := float(key.call(t)) + if v > best_v: best_v = v + best = t return best -static func _threat_score(h) -> int: - var board_attack := 0 - for c in h.board: - board_attack += c.power() - return h.life - board_attack +## Wert einer angebotenen Karte für p: Werte je Mana, jetzt spielbar zählt extra, der +## Elementbonus und ein Vorteil gegen das gegnerische Brett auch; viel zu teuer zählt ab. +static func offer_score(game: Game, p: Game.Player, c: Cards.Card) -> float: + var score := float(c.attack + c.health) / maxf(1.0, c.cost) * 2.0 + c.attack + c.health + if c.cost <= p.energy: + score += 3.0 + if c.cost > p.max_energy + 2: + score -= 2.0 * (c.cost - p.max_energy - 2) + score += 2.0 * game.forecast(p, c) + for e in game.enemies_of(p): + for o in e.board: + if not o.hidden: + score += 1.0 * Cards.matchup(c.elements, o.card.elements) + return score -static func _weakest(heroes: Array): - var best = null - for h in heroes: - if best == null or _threat_score(h) < _threat_score(best): - best = h +## Welche der angebotenen Karten p nimmt. +static func pick_index(game: Game, p: Game.Player) -> int: + var best := 0 + var best_score := -INF + for i in p.offer.size(): + var s := offer_score(game, p, p.offer[i]) + if s > best_score: + best_score = s + best = i return best static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: if game.winner != "" or game.enemies_of(p).is_empty(): return {"type": "end"} - # Gierig: erst das teuerste Bezahlbare kaufen, dann ausspielen, dann angreifen. - var best := -1 - for k in p.shop.size(): - var offer = p.shop[k] - if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND: - if best < 0 or offer.cost > p.shop[best].cost: - best = k - if best >= 0: - return {"type": "buy", "slot": best} + if not p.offer.is_empty(): + return {"type": "pick", "index": pick_index(game, p)} + # Gierig: erst das teuerste Spielbare legen, dann aufdecken und angreifen, dann ziehen. var playable := p.hand.filter(func(c): return game.can_play(p, c)[0]) if not playable.is_empty(): Game.shuffle(playable, game.rng) @@ -322,71 +298,35 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: for c in playable: if c.cost > card.cost: card = c - return {"type": "play", "card": card, "target": pick_target(game, p, card)} + return {"type": "play", "card": card, "face_down": false} + for cr in p.board: + if cr.hidden and game.can_reveal(p, cr)[0]: + return {"type": "reveal", "creature": cr} for cr in p.board: if cr.can_attack(): var t = pick_attack_target(game, p, cr) if t != null: return {"type": "attack", "creature": cr, "target": t} + if game.can_draw(p)[0]: + return {"type": "draw"} return {"type": "end"} -static func pick_target(game: Game, p: Game.Player, card: Cards.Card): - var tt := card.target_type() - if tt == "": - return null - var valid := game.valid_targets(p, tt) - if valid.is_empty(): - return null - var fx: Cards.Effect = null - for f in card.effects: - if f.target == tt: - fx = f - break - var creatures := valid.filter(func(t): return t is Game.Creature) - var heroes := valid.filter(func(t): return t is Game.Player) - match fx.kind: - "damage": - var killable := creatures.filter(func(c): return not c.shield and c.health <= fx.amount) - if not killable.is_empty(): - return _best(killable, Callable(AI, "_value")) - if not heroes.is_empty(): - return _weakest(heroes) - return _best(creatures, Callable(AI, "_value")) - "freeze", "bounce": - return _best(creatures, func(c): return c.card.cost * 100 + c.power()) - "buff", "shield": - return _best(creatures, func(c): return (100 if c.can_attack() else 0) + c.power()) - return valid[0] - - +## 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): - var enemies := game.enemies_of(p) - var total := 0 - for c in p.board: - if c.can_attack(): - total += c.power() * c.attacks_left - var options := [] - for e in enemies: - var taunts: Array = e.board.filter(func(c): return c.taunt()) - if not taunts.is_empty(): - options.append_array(taunts) - else: - if total >= e.life: - return e - options.append_array(e.board) - options.append(e) - var creatures := options.filter(func(t): return t is Game.Creature) - var heroes := options.filter(func(t): return t is Game.Player) - var good := creatures.filter(func(c): return _kills(cr, c) and not _kills(c, cr)) - if not good.is_empty(): - return _best(good, Callable(AI, "_value")) - var trade := creatures.filter(func(c): return _kills(cr, c) and _value(c) > _value(cr)) - if not trade.is_empty(): - return _best(trade, Callable(AI, "_value")) - if not heroes.is_empty(): - return _weakest(heroes) - if not creatures.is_empty(): + for e in game.enemies_of(p): + if e.board.is_empty(): + return e if game.can_attack(cr, e)[0] else null + var creatures: Array = e.board.filter(func(c): return game.can_attack(cr, c)[0]) + if creatures.is_empty(): + continue + var good: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and not _kills(c, cr)) + if not good.is_empty(): + return _best(good, Callable(AI, "_value")) + var trade: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and _value(c) > _value(cr)) + if not trade.is_empty(): + return _best(trade, Callable(AI, "_value")) return _best(creatures, func(c): return 1000 - c.health) return null @@ -396,11 +336,15 @@ static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature): static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void: match action.type: "play": - game.play_card(p, action.card, action.target) - "buy": - game.buy(p, int(action.slot)) - "reroll": - game.reroll(p) + game.play_card(p, action.card, bool(action.get("face_down", false))) + "pick": + game.pick(p, int(action.index)) + "draw": + game.draw(p) + "reveal": + game.reveal(p, action.creature) + "upgrade": + game.upgrade(p, action.creature, action.keyword) "attack": game.attack(action.creature, action.target) "fuse": @@ -416,3 +360,4 @@ static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: i apply_action(game, p, action) if action.type == "end" or game.winner != "": return + game.end_turn() diff --git a/scripts/campaign.gd b/scripts/campaign.gd index 372f331..4edff67 100644 --- a/scripts/campaign.gd +++ b/scripts/campaign.gd @@ -1,47 +1,48 @@ class_name Campaign -## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Leben und Merkmalen. +## Kampagne: zehn benannte Magier in drei Kapiteln. Die Bosse haben weniger Leben als du, +## dafür Merkmale; je Kapitel kommt eins dazu. const STAGES := [ - {"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "life": 30, "reward": 100, "perk": "feuer_angriff"}, - {"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "life": 30, "reward": 100, "perk": "leben_35"}, - {"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "life": 31, "reward": 150, "perk": "waechter_leben"}, - {"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "life": 31, "reward": 150, "perk": "flink_angriff"}, - {"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "life": 32, "reward": 200, "perk": "wesen_leben"}, - {"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 200, "perk": "extra_mana"}, - {"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "life": 33, "reward": 250, "perk": "resonanz_doppelt"}, - {"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "life": 33, "reward": 250, "perk": "schild_wesen"}, - {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 300, "perk": "alle_flink"}, - {"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 500, "perk": "start_energie"}, + {"name": "Fenn", "title": "Kobold-Zähmer", "life": 22, "reward": 100, "perk": "feuer_angriff"}, + {"name": "Mara", "title": "Gezeitenhexe", "life": 22, "reward": 100, "perk": "leben_35"}, + {"name": "Borin", "title": "Steinfaust", "life": 24, "reward": 150, "perk": "wesen_angriff"}, + {"name": "Liska", "title": "Windläuferin", "life": 24, "reward": 150, "perk": "ziehen_gratis"}, + {"name": "Vulkar", "title": "Herr der Lava", "life": 24, "reward": 200, "perk": "wesen_leben"}, + {"name": "Nyx", "title": "Sturmruferin", "life": 26, "reward": 200, "perk": "extra_mana"}, + {"name": "Ignis", "title": "Inferno-Priester", "life": 26, "reward": 250, "perk": "element_doppelt"}, + {"name": "Thalassa", "title": "Tiefseekönigin", "life": 26, "reward": 250, "perk": "schild_wesen"}, + {"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 300, "perk": "verbessern_billig"}, + {"name": "Archimagier", "title": "Meister der Elemente", "life": 28, "reward": 500, "perk": "start_energie"}, ] ## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Leben zurück. const STAGES2 := [ - {"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "life": 30, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, - {"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "life": 30, "reward": 300, "perks": ["leben_35", "extra_mana"]}, - {"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "life": 31, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]}, - {"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "life": 31, "reward": 350, "perks": ["flink_angriff", "alle_flink"]}, - {"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "life": 32, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]}, - {"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 400, "perks": ["extra_mana", "flink_angriff"]}, - {"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "life": 33, "reward": 450, "perks": ["resonanz_doppelt", "wesen_leben"]}, - {"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "life": 33, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, - {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 500, "perks": ["alle_flink", "start_energie"]}, - {"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_mana"]}, + {"name": "Fenn", "title": "Flammenzähmer", "life": 22, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, + {"name": "Mara", "title": "Herrin der Gezeiten", "life": 22, "reward": 300, "perks": ["leben_35", "extra_mana"]}, + {"name": "Borin", "title": "Fels der Ewigkeit", "life": 24, "reward": 350, "perks": ["wesen_angriff", "schild_wesen"]}, + {"name": "Liska", "title": "Sturmläuferin", "life": 24, "reward": 350, "perks": ["ziehen_gratis", "verbessern_billig"]}, + {"name": "Vulkar", "title": "Aschekönig", "life": 24, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]}, + {"name": "Nyx", "title": "Gewitterruferin", "life": 26, "reward": 400, "perks": ["extra_mana", "ziehen_gratis"]}, + {"name": "Ignis", "title": "Inferno-Erzpriester", "life": 26, "reward": 450, "perks": ["element_doppelt", "wesen_leben"]}, + {"name": "Thalassa", "title": "Kaiserin der Tiefe", "life": 26, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, + {"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 500, "perks": ["verbessern_billig", "start_energie"]}, + {"name": "Archimagier", "title": "Herr aller Elemente", "life": 28, "reward": 800, "perks": ["start_energie", "element_doppelt"]}, ] ## Kapitel 3: drei Merkmale und volle Tische. const STAGES3 := [ - {"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "life": 32, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]}, - {"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "life": 32, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]}, - {"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "life": 33, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]}, - {"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "life": 33, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_mana"]}, - {"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "life": 34, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "waechter_leben"]}, - {"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "life": 34, "reward": 800, "perks": ["extra_mana", "flink_angriff", "resonanz_doppelt"]}, - {"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "life": 35, "reward": 900, "perks": ["resonanz_doppelt", "wesen_leben", "start_energie"]}, - {"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "life": 35, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]}, - {"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "life": 36, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]}, - {"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "life": 36, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_mana", "leben_35"]}, + {"name": "Fenn", "title": "Aschefürst", "life": 20, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]}, + {"name": "Mara", "title": "Sturmflut", "life": 20, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]}, + {"name": "Borin", "title": "Berg selbst", "life": 22, "reward": 700, "perks": ["wesen_angriff", "schild_wesen", "leben_35"]}, + {"name": "Liska", "title": "Windbraut", "life": 22, "reward": 700, "perks": ["ziehen_gratis", "verbessern_billig", "extra_mana"]}, + {"name": "Vulkar", "title": "Magmakaiser", "life": 22, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "wesen_angriff"]}, + {"name": "Nyx", "title": "Orkanmutter", "life": 24, "reward": 800, "perks": ["extra_mana", "ziehen_gratis", "element_doppelt"]}, + {"name": "Ignis", "title": "Sonnenpriester", "life": 24, "reward": 900, "perks": ["element_doppelt", "wesen_leben", "start_energie"]}, + {"name": "Thalassa", "title": "Herz der Tiefe", "life": 24, "reward": 900, "perks": ["schild_wesen", "leben_35", "element_doppelt"]}, + {"name": "Zephyra", "title": "Gewitterkaiserin", "life": 26, "reward": 1000, "perks": ["verbessern_billig", "start_energie", "wesen_leben"]}, + {"name": "Archimagier", "title": "Ewiger Meister", "life": 26, "reward": 1200, "perks": ["start_energie", "element_doppelt", "leben_35"]}, ] const CHAPTERS := [STAGES, STAGES2, STAGES3] @@ -63,9 +64,9 @@ static func stage(i: int) -> Dictionary: return CHAPTERS[chapter(i) - 1][i % STAGES.size()] -## Boss einer Stufe: handelt nur mit seinen Elementen, mit dem Leben und den Merkmalen der Stufe. +## Boss einer Stufe mit dem Leben und den Merkmalen der Stufe. static func opponent(stage_index: int) -> Game.Player: var st := stage(stage_index) - var boss := Game.Player.new(st.name, false, int(st.life), st.title, st.elements) + var boss := Game.Player.new(st.name, false, int(st.life), st.title) boss.perks = Array(st.perks) return boss diff --git a/scripts/cards.gd b/scripts/cards.gd index da04744..e5dbe38 100644 --- a/scripts/cards.gd +++ b/scripts/cards.gd @@ -1,5 +1,5 @@ class_name Cards -## Elemente, Kombos und der Kartenpool. +## Elemente, Kombonamen, Schlüsselwörter und der Kartenpool. const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"] const ELEMENT_COLORS := { @@ -8,8 +8,7 @@ const ELEMENT_COLORS := { "Erde": Color("73552d"), "Luft": Color("78aac8"), } -# Jede Karte hat genau ein Element. Kombos entstehen erst im Spiel: beim Verschmelzen -# (der Name des neuen Wesens) und als Feldkombo zwischen benachbarten Elementen. +# Jede Karte hat genau ein Element. Kombos sind die Namen verschmolzener Wesen. const COMBOS := { "Feuer+Feuer": "Inferno", "Wasser+Wasser": "Tiefsee", @@ -22,26 +21,8 @@ const COMBOS := { "Wasser+Luft": "Sturm", "Erde+Luft": "Sand", } -# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird. -const FIELD_COMBO_TEXT := { - "Dampf": "Friere das stärkste gegnerische Wesen ein", - "Lava": "1 Schaden an das stärkste gegnerische Wesen", - "Blitz": "2 Schaden an den Gegner", - "Schlamm": "Heile dich um 3", - "Sturm": "Erhalte 1 Mana", - "Sand": "Das neue Wesen erhält +1/+1", -} # Elementkreis: Wasser löscht Feuer, Feuer verbrennt Luft, Luft trägt Erde ab, Erde saugt Wasser auf. const BEATS := {"Wasser": "Feuer", "Feuer": "Luft", "Luft": "Erde", "Erde": "Wasser"} -# Das Feld gehört allen am Tisch: jede gespielte Karte lädt ihre Elemente, bei FIELD_CHARGE kippt es. -const FIELD_CHARGE := 4 -const FIELD_NAMES := {"Feuer": "Vulkan", "Wasser": "Flut", "Erde": "Fels", "Luft": "Wirbel"} -const FIELD_TEXT := { - "Feuer": "Feuerwesen haben +1 Angriff", - "Wasser": "Wasserwesen haben +1 Angriff", - "Erde": "Erdwesen haben +1 Angriff", - "Luft": "Luftwesen haben +1 Angriff", -} # Seltenheit ist reine Optik: jede Karte gibt es in jeder Stufe, die Werte bleiben gleich. const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"] const RARITY_COLORS := { @@ -51,8 +32,10 @@ const RARITY_COLORS := { "legendär": Color("f0aa28"), "mythisch": Color("ff5a8c"), } -const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"] -const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"] +# Schlüsselwörter haben Karten nicht von Haus aus; sie werden für Mana dazugekauft (Game.upgrade). +const KEYWORDS: Array[String] = ["Schild", "Doppelschlag"] +const KEYWORD_COST := {"Schild": 3, "Doppelschlag": 4} +const KEYWORD_TEXT := {"Schild": "Blockt den ersten Schaden", "Doppelschlag": "Greift zweimal je Zug"} const DATA_PATH := "res://data/karten.json" @@ -70,20 +53,49 @@ static func combo_name(elements: Array) -> String: ## Elementvorteil aus Sicht von own: +1 im Vorteil, -1 im Nachteil, sonst 0. -## Mehrfachelemente nehmen den für sie besseren Wert. +## Hat ein Wesen mehrere Elemente, kämpft es mit dem, das gegen die Gegenseite am besten steht. static func matchup(own: Array, other: Array) -> int: - for e in own: - if BEATS.get(e, "") in other: - return 1 - for e in other: - if BEATS.get(e, "") in own: - return -1 + var mine := best_element(own, other) + var theirs := best_element(other, own) + if mine == "" or theirs == "": + return 0 + return duel(mine, theirs) + + +## +1, wenn a b schlägt, -1, wenn b a schlägt, sonst 0. +static func duel(a: String, b: String) -> int: + if BEATS.get(a, "") == b: + return 1 + if BEATS.get(b, "") == a: + return -1 return 0 -## Mana für eine Verschmelzung: Durchschnitt der Kartenkosten, aufgerundet. -static func fusion_cost(a: Card, b: Card) -> int: - return ceili((a.cost + b.cost) / 2.0) +## Das Element aus own, das gegen die Elemente in other am besten steht. +static func best_element(own: Array, other: Array) -> String: + var best := "" + var best_score := -9 + for e in own: + var score := 0 + for o in other: + score += duel(e, o) + if score > best_score: + best = e + best_score = score + return best + + +## Welches Element ein Element schlägt (und von welchem es geschlagen wird). +static func beaten_by(element: String) -> String: + for e in BEATS: + if BEATS[e] == element: + return e + return "" + + +## Kosten einer Karte: Angriff plus Leben, halbiert und aufgerundet. +static func cost_of(attack: int, health: int) -> int: + return ceili((attack + health) / 2.0) ## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt). @@ -91,8 +103,8 @@ static func fusion_name(elements: Array) -> String: return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]]) -## Karte des verschmolzenen Wesens: Elemente und Schlüsselwörter beider Karten, -## Werte kommen vom Spiel (Durchschnitt plus 1, siehe Game.fuse). +## Karte des verschmolzenen Wesens: Elemente und Schlüsselwörter beider Karten, die Werte +## bestimmt Game.fuse, eine Verschmelzung mehr als der erfahrenere Elternteil. static func fused(a: Card, b: Card, attack: int, health: int) -> Card: var els := [] for e in a.elements + b.elements: @@ -103,99 +115,27 @@ static func fused(a: Card, b: Card, attack: int, health: int) -> Card: c.id = "fus_%s_%s" % [a.id, b.id] c.name = fusion_name(els) c.elements = els - c.cost = fusion_cost(a, b) c.attack = attack c.health = health + c.cost = cost_of(attack, health) 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 return c -static func field_combo_effect(name: String) -> Effect: - match name: - "Dampf": - return Effect.new("freeze", "strongest_enemy_creature") - "Lava": - return Effect.new("damage", "strongest_enemy_creature", 1) - "Blitz": - return Effect.new("damage", "weakest_enemy_hero", 2) - "Schlamm": - return Effect.new("heal", "self_hero", 3) - "Sturm": - return Effect.new("mana", "none", 1) - "Sand": - return Effect.new("buff", "self_creature", 1, 1) - return null - - -class Effect extends RefCounted: - var kind: String # damage, heal, mana, freeze, bounce, buff, shield - var target: String # siehe CHOSEN_TARGETS und Game.resolve() - var amount: int - var amount2: int - - func _init(k: String, t: String, a: int = 0, a2: int = 0) -> void: - kind = k - target = t - amount = a - amount2 = a2 - - func to_dict() -> Dictionary: - var d := {"kind": kind, "target": target} - if amount != 0: - d["amount"] = amount - if amount2 != 0: - d["amount2"] = amount2 - return d - - static func from_dict(d: Dictionary) -> Effect: - return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0))) - - ## Kartentext in der aktuellen Sprache. - func text() -> String: - var n := str(amount) - match kind: - "damage": - match target: - "enemy_creature": - return tr("%s Schaden an ein gegnerisches Wesen") % n - "enemy_any": - return tr("%s Schaden an ein Ziel") % n - "all_enemy_creatures": - return tr("%s Schaden an alle gegnerischen Wesen") % n - "all_enemy_heroes": - return tr("%s Schaden an den Gegner") % n - "all_enemies": - return tr("%s Schaden an den Gegner und seine Wesen") % n - "heal": - return tr("Heile dich um %s") % n - "mana": - if amount == 1: - return tr("Erhalte 1 Mana") - return tr("Erhalte %s Mana") % n - "freeze": - if target == "enemy_creature": - return tr("Friere ein gegnerisches Wesen ein") - if target == "strongest_enemy_creature": - return tr("Friere das stärkste gegnerische Wesen ein") - return tr("Friere alle gegnerischen Wesen ein") - "bounce": - if target == "enemy_creature": - return tr("Ein gegnerisches Wesen zurück auf die Hand") - return tr("Alle gegnerischen Wesen zurück auf die Hand") - "buff": - var b := "+%d/+%d" % [amount, amount2] - if target == "friendly_creature": - return tr("Ein befreundetes Wesen erhält %s") % b - return tr("Alle befreundeten Wesen erhalten %s") % b - "shield": - if target == "friendly_creature": - return tr("Ein befreundetes Wesen erhält Schild") - if target == "self_creature": - return tr("Erhält Schild") - return tr("Alle befreundeten Wesen erhalten Schild") - return "" +## Platzhalter für ein verdecktes gegnerisches Wesen: unbekanntes Element, geschätzte Werte. +static func unknown() -> Card: + var c := Card.new() + c.id = "?" + c.name = "Verdeckt" + c.name_en = "Face-down" + c.elements = [] + c.attack = 2 + c.health = 3 + c.cost = cost_of(2, 3) + return c class Card extends RefCounted: @@ -203,19 +143,18 @@ class Card extends RefCounted: var name: String # deutscher Name (auch im Verlauf und in Tests) var name_en := "" # englischer Name var elements: Array - var cost: int var attack: int = 0 var health: int = 0 - var keywords: Array = [] - var effects: Array = [] + var cost: int = 0 # abgeleitet: (Angriff + Leben) / 2, aufgerundet + var keywords: Array = [] # dazugekauft, je Exemplar + var fusions := 0 # wie oft verschmolzen (Verschmelzen kostet 1, 2, 4, 8 …) func to_dict() -> Dictionary: - var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, - "attack": attack, "health": health} + 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 not effects.is_empty(): - d["effects"] = effects.map(func(e): return e.to_dict()) + if fusions > 0: + d["fusions"] = fusions return d static func from_dict(d: Dictionary) -> Card: @@ -224,14 +163,17 @@ class Card extends RefCounted: c.name = d.name c.name_en = str(d.get("name_en", "")) c.elements = Array(d.elements) - c.cost = int(d.cost) c.attack = int(d.get("attack", 0)) c.health = int(d.get("health", 0)) + c.cost = Cards.cost_of(c.attack, c.health) c.keywords = Array(d.get("keywords", [])) - for e in d.get("effects", []): - c.effects.append(Effect.from_dict(e)) + c.fusions = int(d.get("fusions", 0)) return c + ## Eigenes Exemplar (Hand, Brett): Schlüsselwörter gehören dem Exemplar, nicht dem Pool. + func copy() -> Card: + return Card.from_dict(to_dict()) + ## Anzeigename in der aktuellen Sprache. func title() -> String: if name_en != "" and TranslationServer.get_locale().begins_with("en"): @@ -244,35 +186,19 @@ class Card extends RefCounted: func combo() -> String: return Cards.combo_name(elements) - func target_type() -> String: - for fx in effects: - if fx.target in Cards.CHOSEN_TARGETS: - return fx.target - return "" - - ## Nur die Effekte (die Karte zeigt Schlüsselwörter als Symbole). - func effect_text() -> String: - if effects.is_empty(): - return "" - var fx: Array[String] = [] - for f in effects: - fx.append(f.text()) - return ", ".join(fx) + "." + ## Mana für die nächste Verschmelzung mit dieser Karte. + func fusion_cost() -> int: + return 1 << fusions + ## Kartentext: die gekauften Schlüsselwörter. func text() -> String: - var parts: Array[String] = [] - if not keywords.is_empty(): - var kws: Array[String] = [] - for k in Cards.KEYWORDS: - if k in keywords: - kws.append(tr(k)) - parts.append(", ".join(kws) + ".") - if not effects.is_empty(): - var fx: Array[String] = [] - for f in effects: - fx.append(f.text()) - parts.append(tr("Beim Ausspielen: %s") % (", ".join(fx) + ".")) - return " ".join(parts) + if keywords.is_empty(): + return "" + var kws: Array[String] = [] + for k in Cards.KEYWORDS: + if k in keywords: + kws.append(tr(k)) + return ", ".join(kws) + "." static var _all: Array = [] diff --git a/scripts/deckgen.gd b/scripts/deckgen.gd index 2ee5e1a..ddcd155 100644 --- a/scripts/deckgen.gd +++ b/scripts/deckgen.gd @@ -1,35 +1,18 @@ class_name DeckGen -## Ladenangebot und Gegner (ausgelagert aus den Spielregeln). -## Es gibt keine Decks: jeder kauft jede Runde aus seinem eigenen Laden. +## Der gemeinsame Stapel (jede Karte dreimal) und Gegner. -# Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner). -const RESONANCE_STEPS := [0, 1, 2, 3, 3] +const COPIES := 3 -## Karten, die im Laden auftauchen dürfen: höchstens Einkommen plus eins teuer, -## bei gesetzten Elementen nur aus diesen. -static func pool(income: int, elements: Array = []) -> Array: - var limit := income + 1 - return Cards.all().filter(func(c): - return c.cost <= limit and (elements.is_empty() or c.elements.all(func(e): return e in elements))) - - -## Angebot des Ladens: n zufällige Karten (Wiederholungen erlaubt, wie in TFT). -## Mindestens eine Karte ist vom Einkommen allein bezahlbar, sonst wäre der Zug tot. -static func shop_cards(r: RandomNumberGenerator, income: int, elements: Array = [], n: int = Game.SHOP_SIZE) -> Array: - var p := pool(income, elements) - if p.is_empty(): - p = pool(Game.MAX_ENERGY, elements) - assert(not p.is_empty(), "kein Angebot für " + str(elements)) +## Frisch gemischter Stapel; das Ende des Arrays ist oben. +static func deck(r: RandomNumberGenerator) -> Array: var out := [] - for i in n: - out.append(p[r.randi_range(0, p.size() - 1)]) - var cheap := p.filter(func(c): return c.cost <= income) - if not cheap.is_empty() and not out.any(func(c): return c.cost <= income): - out[r.randi_range(0, out.size() - 1)] = cheap[r.randi_range(0, cheap.size() - 1)] + for c in Cards.all(): + for i in COPIES: + out.append(c) + Game.shuffle(out, r) return out -## Gegner mit eigenem Laden über alle Elemente. static func random_opponent(life: int = Game.START_LIFE) -> Game.Player: return Game.Player.new("Gegner", false, life) diff --git a/scripts/game.gd b/scripts/game.gd index fee572f..bd53dbf 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -1,27 +1,30 @@ class_name Game extends RefCounted -## Spielregeln: Spieler, Wesen, Züge, Angriffe, Effekte, Feldkombos, Resonanz. +## Spielregeln: Duell, Mana je Zug, Ziehen aus dem gemeinsamen Stapel, verdeckte Wesen, +## Vier-Elemente-Regel, Verschmelzen und Verbessern. const MAX_BOARD := 5 -const MAX_HAND := 8 -const MAX_ENERGY := 10 # Einkommen je Zug, höher steigt es nicht -const SHOP_SIZE := 5 # so viele Karten liegen im Laden -const REROLL_COST := 1 -# Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben. +const MAX_HAND := 5 +const START_MANA := 5 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt +const DRAW_COST := 1 # Ziehen: drei Karten sehen, eine behalten +const OFFER_SIZE := 3 +const START_OFFER := 5 # zu Beginn fünf sehen, drei behalten +const START_PICKS := 3 const START_LIFE := 30 +const ELEMENT_STACK := 4 # so viele offene Wesen eines Elements auf dem Tisch: +1/+1, das geschlagene Element -1/-1 # Merkmale der Kampagnen-Bosse (Player.perks). const PERKS := { "feuer_angriff": "Feuerwesen haben +1 Angriff", - "flink_angriff": "Flinke Wesen haben +1 Angriff", - "waechter_leben": "Wächter kommen mit +2 Leben", + "ziehen_gratis": "Ziehen kostet kein Mana", + "wesen_angriff": "Wesen kommen mit +1 Angriff", "wesen_leben": "Wesen kommen mit +1 Leben", "extra_mana": "Bekommt jeden Zug 1 Mana mehr", "leben_35": "Beginnt mit 5 Leben mehr", "schild_wesen": "Wesen kommen mit Schild", - "alle_flink": "Alle Wesen sind flink", - "start_energie": "Beginnt mit 3 Mana", - "resonanz_doppelt": "Resonanz zählt doppelt", + "verbessern_billig": "Eigenschaften kosten 1 weniger", + "start_energie": "Hat im ersten Zug 2 Mana mehr", + "element_doppelt": "Elementbonus zählt doppelt", } ## Für wiederholbare Tests: 0 = echter Zufall, sonst Startwert (zählt je Aufruf hoch). @@ -38,21 +41,18 @@ static func make_rng() -> RandomNumberGenerator: return r - class Creature extends RefCounted: var card: Cards.Card var owner_ref: WeakRef # schwache Referenz: kein Zyklus Spieler <-> Wesen var name: String var uid: int var attack: int - var resonance := 0 # +1, wenn ein anderes eigenes Wesen ein Element teilt - var field_bonus := 0 # +1, wenn das Feld auf dem eigenen Element steht var health: int var max_health: int var shield: bool - var frozen := false - var ready: bool var attacks_left: int + var hidden := false # verdeckt gelegt: greift nicht an, zählt nicht für die Elementregel + var aura := 0 # Vier-Elemente-Regel: +1 oder -1 auf den Angriff (Leben wird direkt angepasst) func _init(c: Cards.Card, o, id: int) -> void: card = c @@ -63,43 +63,35 @@ class Creature extends RefCounted: health = c.health max_health = c.health shield = "Schild" in c.keywords - ready = "Flink" in c.keywords - attacks_left = attacks_per_turn() if ready else 0 + attacks_left = attacks_per_turn() # frisch gelegte Wesen greifen sofort an func owner(): return owner_ref.get_ref() - ## Angriffswert inklusive Resonanz, Feld und Boss-Merkmalen. + ## Angriffswert inklusive Elementbonus und Boss-Merkmalen. func power() -> int: - var v := attack + resonance + field_bonus + var v := attack + aura var o = owner() if o != null: if "feuer_angriff" in o.perks and "Feuer" in card.elements: v += 1 - if "flink_angriff" in o.perks and "Flink" in card.keywords: - v += 1 return v func attacks_per_turn() -> int: return 2 if "Doppelschlag" in card.keywords else 1 - func taunt() -> bool: - return "Wächter" in card.keywords - func can_attack() -> bool: - return ready and attacks_left > 0 and not frozen and power() > 0 + return attacks_left > 0 and not hidden and power() > 0 func clone(new_owner) -> Creature: var c := Creature.new(card, new_owner, uid) c.attack = attack - c.resonance = resonance - c.field_bonus = field_bonus c.health = health c.max_health = max_health c.shield = shield - c.frozen = frozen - c.ready = ready c.attacks_left = attacks_left + c.hidden = hidden + c.aura = aura return c @@ -109,33 +101,33 @@ class Player extends RefCounted: var is_human: bool var life: int var max_life: int - var shop: Array = [] # SHOP_SIZE Plätze, gekaufte sind null - var hand: Array = [] + var offer: Array = [] # offene Wahl: angebotene Karten (Start: 5, Ziehen: 3) + var picks_left := 0 # wie viele davon noch zu nehmen sind + var hand: Array = [] # eigene Exemplare (Cards.Card.copy) var board: Array = [] - var energy := 0 # Mana; nicht ausgegebenes bleibt liegen - var max_energy := 0 # Einkommen je Zug + var energy := 0 # Mana in diesem Zug; der Rest verfällt + var max_energy := 0 # Mana zu Zugbeginn (START_MANA + eigene Züge) var start_bonus := 0 # einmaliges Extra-Mana für den späteren Start var order := 0 # Platz in der Zugreihenfolge var perks: Array = [] # Boss-Merkmale (siehe PERKS) - var elements: Array = [] # Laden nur aus diesen Elementen (leer = alle) var looks := {} # Karten-ID -> Seltenheitsstufe, reine Optik (siehe Profile) var turns := 0 # begonnene eigene Züge - func _init(n: String, h: bool, l: int, t: String = "", els: Array = []) -> void: + func _init(n: String, h: bool, l: int, t: String = "") -> void: name = n title = t is_human = h life = l max_life = l - elements = els.duplicate() func alive() -> bool: return life > 0 func clone() -> Player: - var p := Player.new(name, is_human, life, title, elements) + var p := Player.new(name, is_human, life, title) p.max_life = max_life - p.shop = shop.duplicate() + p.offer = offer.duplicate() + p.picks_left = picks_left p.hand = hand.duplicate() p.energy = energy p.max_energy = max_energy @@ -155,6 +147,7 @@ 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] = [] @@ -162,8 +155,6 @@ var events: Array = [] # strukturierte Ereignisse für Animationen var winner := "" # "", "human" oder "opponents" var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse var next_uid := 1 -var field_element := "" # gemeinsames Feld aller am Tisch, "" = neutral -var field_charge := {} # Element -> gespielte Karten seit dem letzten Wechsel func _init(h: Player, o: Player, r: RandomNumberGenerator = null) -> void: @@ -174,8 +165,6 @@ func _init(h: Player, o: Player, r: RandomNumberGenerator = null) -> void: if r == null: r = make_rng() rng = r - for e in Cards.ELEMENTS: - field_charge[e] = 0 func current() -> Player: @@ -198,7 +187,7 @@ func ref_of(target) -> Dictionary: return {"kind": "player", "index": players.find(target)} -## Der Gegner von p, solange er lebt (als Liste, damit Effekte einheitlich bleiben). +## Der Gegner von p, solange er lebt (als Liste, für Schleifen über Ziele). func enemies_of(p: Player) -> Array: return players.filter(func(o): return o != p and o.alive()) @@ -215,7 +204,7 @@ func find_creature(uid: int) -> Creature: return null -## Unabhängige Kopie des Spielzustands (für die KI-Vorausschau). +## Unabhängige Kopie des Spielzustands (für Rückgängig und die KI-Vorausschau). func clone() -> Game: var copies := [] for p in players: @@ -223,27 +212,46 @@ 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 g.next_uid = next_uid - g.field_element = field_element - g.field_charge = field_charge.duplicate() g.quiet = true return g +## Kopie aus Sicht von viewer: verdeckte gegnerische Wesen sind unbekannt (für die KI). +func masked_clone(viewer: Player) -> Game: + var g := clone() + var me: Player = g.players[players.find(viewer)] + for p in g.players: + if p == me: + continue + for c in p.board: + if c.hidden: + c.card = Cards.unknown() + c.name = c.card.name + c.attack = c.card.attack + c.health = c.card.health + c.max_health = c.card.health + c.shield = false + return g + + # --- Zugablauf --------------------------------------------------------------- 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 # Wer nicht beginnt, bekommt im ersten Zug einmalig 1 Mana extra. p.start_bonus = 0 if i == 0 else 1 - roll_shop(p) + p.offer = draw_cards(START_OFFER, START_MANA + p.start_bonus) + p.picks_left = START_PICKS turn = 1 current_idx = 0 say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name))) @@ -253,62 +261,102 @@ func start(fixed_order: bool = false) -> void: begin_turn(current()) -## Füllt den Laden neu: SHOP_SIZE Karten, höchstens so teuer wie das Einkommen plus eins. -func roll_shop(p: Player) -> void: - p.shop = DeckGen.shop_cards(rng, p.max_energy, p.elements) +## Nimmt n Karten vom Stapel, die höchstens max_cost kosten (was man sich in diesem Zug leisten +## kann); zu teure wandern unter den Stapel. Ist der Stapel leer, kommt ein frischer. +func draw_cards(n: int, max_cost: int) -> 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 -## Kauft eine Karte aus dem Laden auf die Hand. Gibt "" zurück oder eine Fehlermeldung. -func buy(p: Player, slot: int) -> String: +## Solange eine Wahl offen ist, geht nichts anderes. +func blocked(p: Player) -> String: + return "Erst wählen" if not p.offer.is_empty() else "" + + +## Ziehen: kostet DRAW_COST, zeigt OFFER_SIZE Karten, eine davon kommt auf die Hand (siehe pick). +func can_draw(p: Player) -> Array: if winner != "" or p != current(): - return "Nicht dein Zug" - if slot < 0 or slot >= p.shop.size() or p.shop[slot] == null: - return "Kein Angebot" - var card: Cards.Card = p.shop[slot] - if p.energy < card.cost: - return "Nicht genug Mana" + return [false, "Nicht dein Zug"] + if blocked(p) != "": + return [false, blocked(p)] if p.hand.size() >= MAX_HAND: - return "Hand voll" - p.energy -= card.cost - p.shop[slot] = null - p.hand.append(card) - say("%s kauft %s." % [p.name, card.name]) - emit({"type": "buy", "player": players.find(p), "card": card}) + return [false, "Deine Hand ist voll"] + if p.energy < draw_cost(p): + return [false, "Nicht genug Mana"] + return [true, ""] + + +func draw_cost(p: Player) -> int: + return 0 if "ziehen_gratis" in p.perks else DRAW_COST + + +## Preis einer Eigenschaft für p. +func upgrade_cost(p: Player, keyword: String) -> int: + var c := int(Cards.KEYWORD_COST[keyword]) + return maxi(1, c - 1) if "verbessern_billig" in p.perks else c + + +func draw(p: Player) -> String: + var ok := can_draw(p) + if not ok[0]: + return ok[1] + p.energy -= draw_cost(p) + p.offer = draw_cards(OFFER_SIZE, p.max_energy) + p.picks_left = 1 + say("%s zieht." % p.name) + emit({"type": "draw", "player": players.find(p)}) return "" -## Würfelt den Laden neu. Gibt "" zurück oder eine Fehlermeldung. -func reroll(p: Player) -> String: - if winner != "" or p != current(): - return "Nicht dein Zug" - if p.energy < REROLL_COST: - return "Nicht genug Mana" - p.energy -= REROLL_COST - roll_shop(p) - say("%s würfelt den Laden neu." % p.name) - emit({"type": "reroll", "player": players.find(p)}) +## Nimmt eine der angebotenen Karten auf die Hand; jederzeit erlaubt, auch außerhalb des Zugs. +## Was liegen bleibt, wandert unter den Stapel. +func pick(p: Player, index: int) -> String: + if winner != "": + return "Vorbei" + if p.offer.is_empty(): + return "Keine Wahl offen" + if index < 0 or index >= p.offer.size(): + return "Keine solche Karte" + var card: Cards.Card = p.offer[index] + p.hand.append(card.copy()) + p.offer.remove_at(index) + p.picks_left -= 1 + if p.picks_left <= 0 or p.hand.size() >= MAX_HAND: + for rest in p.offer: + deck.insert(0, rest) + p.offer = [] + p.picks_left = 0 + say("%s nimmt eine Karte." % p.name) + emit({"type": "pick", "player": players.find(p)}) return "" func begin_turn(p: Player) -> void: - p.max_energy = mini(MAX_ENERGY, p.max_energy + 1) - # Nicht ausgegebenes Mana bleibt liegen. - p.energy += p.max_energy + (1 if "extra_mana" in p.perks else 0) + p.max_energy = START_MANA + p.turns + p.energy = p.max_energy + (1 if "extra_mana" in p.perks else 0) # der Rest vom letzten Zug ist verfallen if p.turns == 0 and "start_energie" in p.perks: - p.energy += 2 # einmalige Starthilfe, kein dauerhafter Vorsprung + p.energy += 2 if p.start_bonus > 0: p.energy += p.start_bonus say("%s erhält %d Mana extra für den späteren Start." % [p.name, p.start_bonus]) p.start_bonus = 0 - roll_shop(p) for c in p.board: - c.ready = true - if c.frozen: - c.frozen = false - c.attacks_left = 0 - say("%s taut auf und setzt aus." % c.name) - else: - c.attacks_left = c.attacks_per_turn() + c.health = c.max_health # Wesen erholen sich zu Zugbeginn ganz + c.attacks_left = c.attacks_per_turn() p.turns += 1 say("--- Zug %d: %s ---" % [turn, p.name]) emit({"type": "turn", "player": players.find(p)}) @@ -317,6 +365,8 @@ func begin_turn(p: Player) -> void: func end_turn() -> void: if winner != "": return + while not current().offer.is_empty(): # offene Wahl: die erste Karte, damit nichts verfällt + pick(current(), 0) var n := players.size() for i in range(1, n + 1): var idx := (current_idx + i) % n @@ -330,302 +380,296 @@ func end_turn() -> void: # --- Karten spielen ---------------------------------------------------------- -## Ausspielen kostet nichts mehr: bezahlt wird beim Kauf im Laden. +## Ausspielen kostet die Kartenkosten; verdeckt gelegt ist es gleich teuer. func can_play(p: Player, card: Cards.Card) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] + if blocked(p) != "": + return [false, blocked(p)] if not (card in p.hand): return [false, "Nicht auf der Hand"] if p.board.size() >= MAX_BOARD: return [false, "Dein Feld ist voll"] + if p.energy < card.cost: + return [false, "Nicht genug Mana"] return [true, ""] -func valid_targets(p: Player, target_type: String) -> Array: - var enemies := enemies_of(p) - var creatures := [] - for e in enemies: - creatures.append_array(e.board) - match target_type: - "enemy_creature": - return creatures - "enemy_any": - return creatures + enemies - "friendly_creature": - return p.board.duplicate() - return [] - - -## Spielt eine Karte. Gibt "" zurück oder eine Fehlermeldung. -func play_card(p: Player, card: Cards.Card, target = null) -> String: +## Spielt eine Karte, offen oder verdeckt. Gibt "" zurück oder eine Fehlermeldung. +func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String: var ok := can_play(p, card) if not ok[0]: return ok[1] - var tt := card.target_type() - if tt != "": - var valid := valid_targets(p, tt) - if not valid.is_empty() and not (target in valid): - return "Ungültiges Ziel" - if valid.is_empty(): - target = null p.hand.erase(card) - say("%s spielt %s." % [p.name, card.name]) - var cr := _enter(p, card) - for fx in card.effects: - resolve(p, fx, target, cr, card.elements) - if cr in p.board: - trigger_field_combos(p, cr) - charge_field(card) - update_resonance() - cleanup() - check_end() - return "" - - -## Jede gespielte Karte lädt ihre Elemente. Wer FIELD_CHARGE erreicht, kippt das -## gemeinsame Feld auf sein Element; danach beginnen alle Zähler von vorn. -func charge_field(card: Cards.Card) -> void: - for e in card.elements: - field_charge[e] = int(field_charge.get(e, 0)) + 1 - var best := "" - for e in Cards.ELEMENTS: - if e == field_element or int(field_charge.get(e, 0)) < Cards.FIELD_CHARGE: - continue - if best == "" or int(field_charge[e]) > int(field_charge[best]): - best = e - if best == "": - return - field_element = best - for e in Cards.ELEMENTS: - field_charge[e] = 0 - say("Das Feld wird zu %s: %s." % [Cards.FIELD_NAMES[best], Cards.FIELD_TEXT[best]]) - emit({"type": "field", "element": best}) - update_resonance() # der Feldbonus gilt sofort - - -## Feldkombo: das neue Wesen bildet mit dem ersten eigenen Wesen aus anderem -## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus. -func trigger_field_combos(p: Player, cr: Creature) -> void: - for other in p.board: - if other == cr: - continue - for e1 in cr.card.elements: - for e2 in other.card.elements: - if e1 == e2: - continue - var name := Cards.combo_name([e1, e2]) - say("Feldkombo %s: %s." % [name, Cards.FIELD_COMBO_TEXT[name]]) - emit({"type": "combo", "name": name, "player": players.find(p), "uid": cr.uid}) - resolve(p, Cards.field_combo_effect(name), null, cr) - return - - -## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt. -func _enter(p: Player, card: Cards.Card) -> Creature: + p.energy -= card.cost var cr := Creature.new(card, p, next_uid) next_uid += 1 - if "waechter_leben" in p.perks and cr.taunt(): - cr.health += 2 - cr.max_health += 2 + cr.hidden = face_down + if "wesen_angriff" in p.perks: + cr.attack += 1 if "wesen_leben" in p.perks: cr.health += 1 cr.max_health += 1 if "schild_wesen" in p.perks: cr.shield = true - if "alle_flink" in p.perks: - cr.ready = true - cr.attacks_left = cr.attacks_per_turn() p.board.append(cr) - emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card}) - return cr + if face_down: + say("%s legt ein Wesen verdeckt." % p.name) + else: + say("%s spielt %s." % [p.name, card.name]) + emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card, "hidden": face_down}) + apply_auras() + cleanup() + check_end() + return "" -## Prüft eine Verschmelzung: zwei eigene Wesen, genug Mana. -func can_fuse(p: Player, a: Creature, b: Creature) -> Array: +## Ein eigenes verdecktes Wesen aufdecken: kostenlos, im eigenen Zug. +func can_reveal(p: Player, cr: Creature) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] - if a == null or b == null or a == b: - return [false, "Zwei verschiedene Wesen wählen"] - if not (a in p.board) or not (b in p.board): + if blocked(p) != "": + return [false, blocked(p)] + if cr == null or not (cr in p.board): return [false, "Nur eigene Wesen"] - if p.energy < Cards.fusion_cost(a.card, b.card): + if not cr.hidden: + return [false, "%s liegt schon offen" % cr.name] + return [true, ""] + + +func reveal(p: Player, cr: Creature) -> String: + var ok := can_reveal(p, cr) + if not ok[0]: + return ok[1] + _flip(cr) + cleanup() + check_end() + return "" + + +## Deckt ein Wesen auf; die Elementregel gilt sofort. +func _flip(cr: Creature) -> void: + cr.hidden = false + say("%s wird aufgedeckt." % cr.name) + emit({"type": "reveal", "uid": cr.uid, "card": cr.card}) + apply_auras() + + +# --- Verbessern: Schlüsselwörter für Mana ----------------------------------- + +## target: eigenes Wesen auf dem Brett oder eigene Handkarte. +func can_upgrade(p: Player, target, keyword: String) -> Array: + if winner != "" or p != current(): + return [false, "Nicht dein Zug"] + if blocked(p) != "": + return [false, blocked(p)] + if not Cards.KEYWORD_COST.has(keyword): + return [false, "Unbekannte Eigenschaft"] + var card: Cards.Card = target.card if target is Creature else target + if target is Creature: + if not (target in p.board): + return [false, "Nur eigene Wesen"] + elif not (target in p.hand): + return [false, "Nicht auf der Hand"] + if keyword in card.keywords: + return [false, "%s hat schon %s" % [card.name, keyword]] + if p.energy < upgrade_cost(p, keyword): return [false, "Nicht genug Mana"] return [true, ""] -## Verschmilzt zwei eigene Wesen zu einem. Es erbt Elemente und Schlüsselwörter beider -## Karten, seine Werte sind der abgerundete Durchschnitt plus 1. Kosten: Durchschnitt der -## Kartenkosten, aufgerundet. Eine Verschmelzung ist kein Ausspielen: sie löst keine -## Feldkombo aus und lädt das Feld nicht. +func upgrade(p: Player, target, keyword: String) -> String: + var ok := can_upgrade(p, target, keyword) + if not ok[0]: + return ok[1] + p.energy -= upgrade_cost(p, keyword) + var card: Cards.Card = (target.card if target is Creature else target).copy() # Kopien (KI) teilen sich Karten + card.keywords.append(keyword) + if target is Creature: + target.card = card + else: + p.hand[p.hand.find(target)] = card + if target is Creature: + match keyword: + "Schild": + target.shield = true + "Doppelschlag": + target.attacks_left = mini(target.attacks_per_turn(), target.attacks_left + 1) + say("%s bekommt %s." % [card.name, keyword]) + emit({"type": "upgrade", "player": players.find(p), "keyword": keyword, "uid": target.uid if target is Creature else -1}) + return "" + + +# --- Verschmelzen ------------------------------------------------------------ + +## Kosten: 1 Mana, verdoppelt je Verschmelzung, die schon in einem der beiden steckt. +static func fusion_cost(a: Creature, b: Creature) -> int: + return 1 << maxi(a.card.fusions, b.card.fusions) + + +func can_fuse(p: Player, a: Creature, b: Creature) -> Array: + if winner != "" or p != current(): + return [false, "Nicht dein Zug"] + if blocked(p) != "": + return [false, blocked(p)] + if a == null or b == null or a == b: + return [false, "Zwei verschiedene Wesen wählen"] + if not (a in p.board) or not (b in p.board): + return [false, "Nur eigene Wesen"] + if p.energy < fusion_cost(a, b): + return [false, "Nicht genug Mana"] + return [true, ""] + + +## Verschmilzt zwei eigene Wesen zu einem offenen Wesen: Elemente und Schlüsselwörter beider, +## jeweils der bessere Wert (Angriff, Leben, Lebenshöchstwert) plus 1. Der Elementbonus der +## Eltern wird herausgerechnet, das neue Wesen bekommt seinen eigenen. func fuse(p: Player, a: Creature, b: Creature) -> String: var ok := can_fuse(p, a, b) if not ok[0]: return ok[1] - p.energy -= Cards.fusion_cost(a.card, b.card) - var card := Cards.fused(a.card, b.card, (a.attack + b.attack) / 2 + 1, (a.health + b.health) / 2 + 1) + p.energy -= fusion_cost(a, b) + var card := Cards.fused(a.card, b.card, maxi(a.attack, b.attack) + 1, + maxi(a.max_health - a.aura, b.max_health - b.aura) + 1) var cr := Creature.new(card, p, next_uid) next_uid += 1 + cr.health = maxi(a.health - a.aura, b.health - b.aura) + 1 cr.shield = cr.shield or a.shield or b.shield - cr.frozen = a.frozen or b.frozen - cr.ready = a.ready and b.ready - cr.attacks_left = 0 if not cr.ready else mini(a.attacks_left, b.attacks_left) + cr.attacks_left = mini(a.attacks_left, b.attacks_left) var at := p.board.find(a) p.board.erase(a) p.board.erase(b) p.board.insert(mini(at, p.board.size()), cr) say("%s und %s verschmelzen zu %s (%d/%d)." % [a.name, b.name, cr.name, cr.attack, cr.health]) emit({"type": "fuse", "player": players.find(p), "uid": cr.uid, "gone": [a.uid, b.uid]}) - update_resonance() + apply_auras() cleanup() check_end() return "" -## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3. -static func resonance_for(partners: int) -> int: - return DeckGen.RESONANCE_STEPS[mini(partners, DeckGen.RESONANCE_STEPS.size() - 1)] +# --- Vier-Elemente-Regel ----------------------------------------------------- - -## Vorschau fürs Ausspielen: Angriffsbonus aus Resonanz und Feld sowie die Feldkombo, -## die die Karte mit dem ersten eigenen Wesen aus anderem Element auslösen würde. -func forecast(p: Player, card: Cards.Card) -> Dictionary: - var partners := 0 - var combo := "" - var partner: Creature = null - for o in p.board: - if o.card.elements.any(func(e): return e in card.elements): - partners += 1 - if combo == "": - for e1 in card.elements: - for e2 in o.card.elements: - if e1 != e2 and combo == "": - combo = Cards.combo_name([e1, e2]) - partner = o - var resonance := resonance_for(partners) * (2 if "resonanz_doppelt" in p.perks else 1) - var field := 1 if field_element != "" and field_element in card.elements else 0 - return {"resonance": resonance, "field": field, "combo": combo, "partner": partner} - - -## Resonanz: abnehmender Angriffsbonus je Partner mit gleichem Element (siehe resonance_for). -func update_resonance() -> void: +## Offene Wesen je Element auf dem ganzen Tisch. +func element_counts() -> Dictionary: + var counts := {} + for e in Cards.ELEMENTS: + counts[e] = 0 for p in players: for c in p.board: - var bonus := 0 - for o in p.board: - if o != c and o.card.elements.any(func(e): return e in c.card.elements): - bonus += 1 - c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1) - c.field_bonus = 1 if field_element != "" and field_element in c.card.elements else 0 + if not c.hidden: + for e in c.card.elements: + counts[e] += 1 + return counts -func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, elements: Array = []) -> void: - var enemies := enemies_of(p) - var enemy_creatures := [] - for e in enemies: - enemy_creatures.append_array(e.board) - var targets := [] - if fx.target in Cards.CHOSEN_TARGETS: - targets = [target] if target != null else [] - elif fx.target == "all_enemy_creatures": - targets = enemy_creatures - elif fx.target == "all_enemy_heroes": - targets = enemies.duplicate() - elif fx.target == "all_enemies": - targets = enemy_creatures + enemies - elif fx.target == "all_friendly_creatures": - targets = p.board.duplicate() - elif fx.target == "self_hero": - targets = [p] - elif fx.target == "self_creature": - targets = [source] if source != null else [] - elif fx.target == "strongest_enemy_creature": - var best: Creature = null - for c in enemy_creatures: - if best == null or c.power() > best.power(): - best = c - targets = [best] if best != null else [] - elif fx.target == "weakest_enemy_hero": - var weakest: Player = null - for e in enemies: - if weakest == null or e.life < weakest.life: - weakest = e - targets = [weakest] if weakest != null else [] - else: - targets = [null] - for t in targets: - match fx.kind: - "damage": - damage(t, fx.amount + (Cards.matchup(elements, t.card.elements) if t is Creature else 0)) - "heal": - t.life = mini(t.max_life, t.life + fx.amount) - say("%s heilt sich um %d." % [t.name, fx.amount]) - emit({"type": "heal", "target": ref_of(t), "amount": fx.amount}) - "mana": - p.energy += fx.amount - say("%s erhält %d Mana." % [p.name, fx.amount]) - emit({"type": "mana", "player": players.find(p), "amount": fx.amount}) - "freeze": - t.frozen = true - t.attacks_left = 0 - say("%s ist eingefroren." % t.name) - emit({"type": "freeze", "target": ref_of(t)}) - "bounce": - var o = t.owner() - o.board.erase(t) - emit({"type": "bounce", "target": ref_of(t)}) - if o.hand.size() < MAX_HAND: - o.hand.append(t.card) - say("%s kehrt auf die Hand zurück." % t.name) - else: - say("%s wird weggeweht (Hand voll)." % t.name) - "buff": - t.attack += fx.amount - t.health += fx.amount2 - t.max_health += fx.amount2 - say("%s erhält +%d/+%d." % [t.name, fx.amount, fx.amount2]) - emit({"type": "buff", "target": ref_of(t), "text": "+%d/+%d" % [fx.amount, fx.amount2]}) - "shield": - t.shield = true - say("%s erhält Schild." % t.name) - emit({"type": "buff", "target": ref_of(t), "text": "Schild"}) +## Bonus aus der Elementregel für diese Elemente: +1, wenn ELEMENT_STACK offene Wesen des +## Elements stehen, -1, wenn das Element, das es schlägt, den Stapel voll hat. +## Mehrere Elemente nehmen den besten Wert. +static func aura_of(elements: Array, counts: Dictionary) -> int: + var best := 0 + for i in elements.size(): + var e: String = elements[i] + var a := 0 + if int(counts[e]) >= ELEMENT_STACK: + a += 1 + var predator := Cards.beaten_by(e) + if predator != "" and int(counts[predator]) >= ELEMENT_STACK: + a -= 1 + best = a if i == 0 else maxi(best, a) + return best + + +## Bonus eines offenen Wesens aus der Elementregel; verdeckte zählen nicht. +func aura_for(c: Creature, counts: Dictionary) -> int: + if c.hidden: + return 0 + var a := aura_of(c.card.elements, counts) + var o = c.owner() + if o != null and "element_doppelt" in o.perks: + a *= 2 + return a + + +## Rechnet die Elementregel neu; Leben folgt dem Bonus direkt (+1/+1 bzw. -1/-1). +func apply_auras() -> void: + var counts := element_counts() + for p in players: + for c in p.board: + var a := aura_for(c, counts) + if a != c.aura: + var delta: int = a - c.aura + c.health += delta + c.max_health += delta + c.aura = a + + +## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme. +func forecast(p: Player, card: Cards.Card) -> int: + var counts := element_counts() + for e in card.elements: + counts[e] += 1 + return aura_of(card.elements, counts) * (2 if "element_doppelt" in p.perks else 1) # --- Kampf ------------------------------------------------------------------- +## Leben lassen sich erst angreifen, wenn der Gegner kein Wesen mehr hat. func can_attack(cr: Creature, target) -> Array: if winner != "" or cr.owner() != current(): return [false, "Nicht dein Zug"] + if blocked(cr.owner()) != "": + return [false, blocked(cr.owner())] + if cr.hidden: + return [false, "%s liegt verdeckt" % cr.name] if not cr.can_attack(): return [false, "%s kann nicht angreifen" % cr.name] var tgt_owner = target.owner() if target is Creature else target if not (tgt_owner in enemies_of(cr.owner())): return [false, "Kein gültiges Ziel"] - var has_taunt := false - for c in tgt_owner.board: - if c.taunt(): - has_taunt = true - if has_taunt and not (target is Creature and target.taunt()): - return [false, "Ein Wächter muss zuerst angegriffen werden"] + if not (target is Creature) and not tgt_owner.board.is_empty(): + return [false, "Erst die Wesen"] return [true, ""] -## Greift an. Gibt "" zurück oder eine Fehlermeldung. +## Greift an. Ein verdecktes Ziel wird zuerst aufgedeckt. Schaden über das Leben eines Wesens +## hinaus schlägt auf seinen Besitzer durch (sonst wäre eine Mauer unverwundbar). +## Gibt "" zurück oder eine Fehlermeldung. func attack(cr: Creature, target) -> String: var ok := can_attack(cr, target) if not ok[0]: return ok[1] cr.attacks_left -= 1 + if target is Creature and target.hidden: + _flip(target) + if target.health <= 0: # der Elementbonus hat es schon erledigt + cleanup() + check_end() + return "" say("%s greift %s an." % [cr.name, target.name]) emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)}) - damage(target, cr.power() + matchup_bonus(cr, target)) - if target is Creature and target.power() > 0: # wer nichts austeilt, kontert auch nicht - damage(cr, target.power() + matchup_bonus(target, cr)) + var dealt := cr.power() + matchup_bonus(cr, target) + if target is Creature: + var had_shield: bool = target.shield + var hp_before: int = target.health + damage(target, dealt) + if not had_shield and dealt > hp_before: + var o = target.owner() + var excess := dealt - hp_before + o.life -= excess + say("%d Schaden schlagen durch auf %s." % [excess, o.name]) + emit({"type": "damage", "target": ref_of(o), "amount": excess}) + if target.power() > 0: # wer nichts austeilt, kontert auch nicht; der Konter kennt keinen Elementvorteil + damage(cr, target.power()) + else: + damage(target, dealt) cleanup() check_end() return "" -## Elementvorteil beim Zuschlagen: gegen Helden gibt es keinen. +## Elementvorteil beim Zuschlagen: +1 im Vorteil, -1 im Nachteil (die Verteidigung des +## Ziels), gegen Helden gibt es keinen. static func matchup_bonus(source: Creature, target) -> int: if not (target is Creature): return 0 @@ -648,21 +692,24 @@ func damage(target, amount: int) -> void: emit({"type": "damage", "target": ref_of(target), "amount": amount}) +## Räumt Tote ab; danach gilt die Elementregel neu, was weitere Tote nach sich ziehen kann. func cleanup() -> void: - var died := false - for p in players: - for c in p.board.duplicate(): - # Wer besiegt ist, nimmt seine Wesen mit vom Tisch. - if c.health <= 0 or not p.alive(): - p.board.erase(c) - if c.health > 0: - say("%s verschwindet mit %s." % [c.name, p.name]) - else: - say("%s stirbt." % c.name) - emit({"type": "death", "target": ref_of(c)}) - died = true - if died: - update_resonance() + for round in 8: + var died := false + for p in players: + for c in p.board.duplicate(): + # Wer besiegt ist, nimmt seine Wesen mit vom Tisch. + if c.health <= 0 or not p.alive(): + p.board.erase(c) + if c.health > 0: + say("%s verschwindet mit %s." % [c.name, p.name]) + else: + say("%s stirbt." % c.name) + emit({"type": "death", "target": ref_of(c)}) + died = true + if not died: + return + apply_auras() ## Ende: einer von beiden hat kein Leben mehr. diff --git a/scripts/net.gd b/scripts/net.gd index df09ef0..b5d9d9f 100644 --- a/scripts/net.gd +++ b/scripts/net.gd @@ -384,23 +384,37 @@ 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.current() != p or game.winner != "": + if game.winner != "": + return + if str(action.get("type", "")) == "pick": # wählen geht jederzeit, auch außerhalb des Zugs + game.pick(p, int(action.get("index", -1))) + action_applied.emit() + 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], resolve_ref(action.get("target"))) + 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) + "upgrade": + var cr := game.find_creature(int(action.get("uid", -1))) + var hi := int(action.get("hand", -1)) + var target = cr if cr != null else (p.hand[hi] if hi >= 0 and hi < p.hand.size() else null) + if target != null: + game.upgrade(p, target, str(action.get("keyword", ""))) "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) - "buy": - game.buy(p, int(action.get("slot", -1))) - "reroll": - game.reroll(p) + "draw": + game.draw(p) "fuse": var a := game.find_creature(int(action.get("uid", -1))) var b := game.find_creature(int(action.get("other", -1))) diff --git a/scripts/netstate.gd b/scripts/netstate.gd index 27ea8c8..54f2e4d 100644 --- a/scripts/netstate.gd +++ b/scripts/netstate.gd @@ -2,17 +2,17 @@ class_name NetState ## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln). -## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände nur als Anzahl, fremde Läden gar nicht. -## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer). -## Karten reisen als ID; verschmolzene stehen nicht im Pool und gehen als Ganzes mit. +## 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) else c.to_dict() + 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) + 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 Wahl gar nicht, 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(): @@ -20,15 +20,16 @@ static func snapshot(g: Game, viewer: int) -> Dictionary: var mine := i == viewer or viewer == -1 var board := [] for c in p.board: - board.append({"uid": c.uid, "card": card_ref(c.card), - "attack": c.attack, "resonance": c.resonance, "field_bonus": c.field_bonus, - "health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen, - "ready": c.ready, "attacks_left": c.attacks_left}) + 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, "shield": c.shield and not masked, + "attacks_left": c.attacks_left, "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, "start_bonus": p.start_bonus, - "order": p.order, "perks": p.perks, "elements": p.elements, "looks": p.looks, "turns": p.turns, + "order": p.order, "perks": p.perks, "looks": p.looks, "turns": p.turns, "hand": p.hand.map(card_ref) if mine else p.hand.size(), - "shop": p.shop.map(func(c): return c.id if c != null else "") if mine else [], + "offer": p.offer.map(func(c): return c.id) if mine else [], "picks_left": p.picks_left, "board": board}) var evs := [] for ev in g.events: @@ -38,7 +39,7 @@ static func snapshot(g: Game, viewer: int) -> Dictionary: 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, "field_element": g.field_element, "field_charge": g.field_charge, + "next_uid": g.next_uid, "deck_size": g.deck.size(), "log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs} @@ -47,7 +48,7 @@ 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, Array(pd.get("elements", []))) + 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) @@ -63,20 +64,19 @@ static func from_snapshot(d: Dictionary) -> Game: else: for i in int(pd.hand): p.hand.append(placeholder) - for cid in pd.get("shop", []): - p.shop.append(Cards.by_id(cid) if str(cid) != "" else null) + for cid in pd.get("offer", []): + p.offer.append(Cards.by_id(cid)) + p.picks_left = int(pd.get("picks_left", 0)) 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.resonance = int(cd.resonance) - c.field_bonus = int(cd.get("field_bonus", 0)) c.health = int(cd.health) c.max_health = int(cd.max_health) c.shield = bool(cd.shield) - c.frozen = bool(cd.frozen) - c.ready = bool(cd.ready) c.attacks_left = int(cd.attacks_left) + 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) @@ -87,9 +87,8 @@ static func from_snapshot(d: Dictionary) -> Game: g.winner = d.winner g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null g.next_uid = int(d.next_uid) - g.field_element = str(d.get("field_element", "")) - for e in Cards.ELEMENTS: - g.field_charge[e] = int(d.get("field_charge", {}).get(e, 0)) + for 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: diff --git a/scripts/ui/battle_fx.gd b/scripts/ui/battle_fx.gd index 4e108ef..107a882 100644 --- a/scripts/ui/battle_fx.gd +++ b/scripts/ui/battle_fx.gd @@ -32,32 +32,21 @@ func play_events(evs: Array, centers: Dictionary) -> void: "damage": sfx("damage") popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay) - "heal": - sfx("heal") - popup("+%d" % ev.amount, Style.GREEN, center_of(ev.target, centers), delay) - "buff": - popup(ev.text, Style.GREEN, center_of(ev.target, centers), delay) "blocked": popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay) - "freeze": - popup("Eis", Color("aaddff"), center_of(ev.target, centers), delay) - "combo": - sfx("combo") - var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid}) - if where.x < 0: - where = screen.target_center({"kind": "player", "index": ev.player}) - popup("%s!" % tr(ev.name), Style.GOLD, where + Vector2(0, -30), 0.1) "play": - sfx("play") + sfx("click" if ev.get("hidden", false) else "play") + "reveal": + sfx("combo") + popup(tr("Aufgedeckt!"), Style.GOLD, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0) + "upgrade": + sfx("heal") + if ev.uid >= 0: + popup("+" + tr(ev.keyword), Style.GREEN, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0) "fuse": sfx("combo") popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0) - "field": - sfx("combo") - banner("%s: %s" % [tr(Cards.FIELD_NAMES[ev.element]), tr(Cards.FIELD_TEXT[ev.element])], Cards.ELEMENT_COLORS[ev.element]) - "buy": - sfx("click") # die Karte landet sichtbar auf der Hand, das reicht - "reroll": + "draw": sfx("click") diff --git a/scripts/ui/battle_gamepad.gd b/scripts/ui/battle_gamepad.gd index ff7e959..fae319a 100644 --- a/scripts/ui/battle_gamepad.gd +++ b/scripts/ui/battle_gamepad.gd @@ -1,12 +1,12 @@ class_name BattleGamepad extends RefCounted -## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Laden, Hand, eigene Wesen -## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, -## X macht rückgängig. +## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Stapel, Hand, eigene Wesen +## und Ziele (bei offener Wahl über deren Karten), A bestätigt, B bricht ab, Start beendet den +## Zug, Y öffnet das Kartenmenü (im Gegnerzug: überspringen), X macht rückgängig. var screen # BattleScreen var active := false -var zone := "shop" +var zone := "deck" var index := 0 var targets: Array = [] @@ -19,6 +19,12 @@ func button(button: int) -> void: active = true if screen.game == null: return + if not screen.pick_views.is_empty() and zone != "pick": + zone = "pick" + index = 0 + elif screen.pick_views.is_empty() and zone == "pick": + zone = "hand" + index = 0 match button: JOY_BUTTON_START: if screen.my_turn() and not screen.any_overlay(): @@ -27,10 +33,20 @@ func button(button: int) -> void: if screen.confirm.visible: screen.confirm.visible = false screen.cancel_all() - zone = "shop" + zone = "deck" JOY_BUTTON_Y: if screen.b_skip.visible: screen.skip_ai = true + elif screen.menu_layer.visible: + screen.close_menu() + else: + var items := items() + if not items.is_empty() and zone in ["hand", "own"]: + var item = items[clampi(index, 0, items.size() - 1)] + if zone == "hand": + screen.open_menu(item.card, Rect2(item.position, item.size)) + else: + screen.open_menu(item.creature, Rect2(item.slot, item.size)) JOY_BUTTON_X: if screen.b_undo.visible: screen.undo() @@ -49,7 +65,7 @@ func button(button: int) -> void: ## Wechselt den Bereich und überspringt leere (eine leere Hand ist keine Station). ## step 0: beim aktuellen Bereich bleiben, falls er etwas enthält. func _next_zone(step: int) -> void: - var order := ["shop", "hand", "own"] + var order := ["deck", "hand", "own"] var at := order.find(zone) index = 0 if step == 0 and not items().is_empty(): @@ -64,8 +80,10 @@ func _next_zone(step: int) -> void: func items() -> Array: match zone: - "shop": - return screen.shop_views + "deck": + return [screen.deck] if screen.deck.visible else [] + "pick": + return screen.pick_views "hand": return screen.hand_views "own": @@ -95,24 +113,13 @@ func confirm() -> void: index = clampi(index, 0, items.size() - 1) var item = items[index] match zone: - "shop": - screen.on_click("shop", int(item.get_meta("slot", 0))) + "deck": + screen.on_click("deck", null) + "pick": + screen.do_pick(index) + index = 0 "hand": screen.on_click("hand", item.card) - if screen.pending != null: - targets = [] - var valid: Array = screen.game.valid_targets(screen.perspective, screen.pending.target_type()) - for v in screen.enemy_views: - if v.creature in valid: - targets.append(v) - for v in screen.own_views: - if v.creature in valid: - targets.append(v) - for o in screen.others(): - if o in valid: - targets.append(screen.hero_panels[o]) - zone = "target" - index = 0 "own": screen.on_click("own", item.creature) if screen.selected != null: diff --git a/scripts/ui/battle_layout.gd b/scripts/ui/battle_layout.gd index 887aef6..0ba1571 100644 --- a/scripts/ui/battle_layout.gd +++ b/scripts/ui/battle_layout.gd @@ -1,7 +1,7 @@ class_name BattleLayout -## Feste Bildschirmaufteilung des Duells (1280x800), von unten nach oben: Laden mit Neuwürfeln -## und Zug beenden, Hand, eigenes Brett (mit eigener Leiste links), Feldband, oben der Gegner -## mit seinem Brett. +## Feste Bildschirmaufteilung des Duells (1280x800), von unten nach oben: Hand mit dem +## Zieh-Stapel links und Zug beenden rechts, eigenes Brett (mit eigener Leiste links), +## Feldband, oben der Gegner mit seinem Brett. Eine offene Wahl liegt als Überlagerung darüber. const B_GIVEUP := Rect2(10, 10, 96, 32) const B_LOG := Rect2(112, 10, 96, 32) @@ -13,15 +13,14 @@ const STATUS := Rect2(20, 288, 210, 160) const B_FUSE := Rect2(1050, 298, 210, 40) const B_UNDO := Rect2(1050, 346, 210, 40) const OWN_X := 345 -const HAND_W := 96 -const HAND_H := 62 -const HAND_GAP := 4 -const HAND_Y := 458 -const SHOP_Y := 532 -const SHOP_W := 150 -const SHOP_H := 205 -const B_REROLL := Rect2(20, 606, 200, 52) +const HAND_W := 150 +const HAND_H := 205 +const HAND_GAP := 10 +const HAND_Y := 532 +const DECK := Rect2(50, 540, 130, 182) const B_END := Rect2(1060, 600, 200, 60) +const PICK_PANEL := Rect2(120, 150, 1040, 440) +const PICK_CARD := Vector2(180, 252) const LOG_PANEL := Rect2(240, 60, 800, 560) const PREVIEW_SIZE := Vector2(180, 252) const CENTER_X := 240 @@ -37,12 +36,15 @@ static func opp_slot(j: int) -> Rect2: return Rect2(OWN_X + j * 120, 76, 110, 140) -## Laden: fünf große Angebote unten, wie in TFT. -static func shop_rect(i: int) -> Rect2: - return Rect2(245 + i * (SHOP_W + 10), SHOP_Y, SHOP_W, SHOP_H) +## Angebotene Karten einer Wahl, mittig im Wahlfenster. +static func pick_rect(count: int, i: int) -> Rect2: + var gap := 20 + var total := count * PICK_CARD.x + maxi(0, count - 1) * gap + var x := PICK_PANEL.position.x + (PICK_PANEL.size.x - total) / 2 + return Rect2(Vector2(x + i * (PICK_CARD.x + gap), PICK_PANEL.position.y + 96), PICK_CARD) -## Hand: flache Kacheln zwischen Brett und Laden (die Bank). +## Hand: bis zu fünf große Karten unten, mittig. static func hand_rect(count: int, i: int) -> Rect2: var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP var x := CENTER_X + (CENTER_W - total) / 2 diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd index 35021cb..53f211d 100644 --- a/scripts/ui/battle_screen.gd +++ b/scripts/ui/battle_screen.gd @@ -1,6 +1,8 @@ class_name BattleScreen extends Control ## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx. +## Rechtsklick (oder Y) auf eine Handkarte oder ein eigenes Wesen öffnet das Kartenmenü: +## verdeckt legen, aufdecken, Eigenschaften kaufen. ## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer). var app @@ -12,7 +14,6 @@ var game: Game var perspective: Game.Player # der Mensch, dessen Hand unten liegt var fx: BattleFx var selected = null # Game.Creature -var pending = null # Cards.Card, wartet auf Ziel var fusing := false # Verschmelzen gewählt: zwei eigene Wesen anklicken var fuse_first = null # Game.Creature, erstes Wesen der Verschmelzung var msg_t := 0.0 @@ -39,7 +40,7 @@ var undo_t := 0.0 # Ansichten (auch für Tests) var tiles := {} var hand_views: Array = [] -var shop_views: Array = [] +var pick_views: Array = [] # Karten der offenen Wahl var own_views: Array = [] var enemy_views: Array = [] var hero_panels := {} # Player -> Panel @@ -49,7 +50,10 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms) var hero_layer: Control var tile_layer: Control var hand_layer: Control -var shop_layer: Control +var pick_layer: Control # Überlagerung der offenen Wahl +var pick_box: Control # ihr Inhalt (wird je Wahl neu aufgebaut) +var pick_key := "" # welche Wahl gerade aufgebaut ist +var deck: Deck var log_overlay: Control var b_log: Button var status_panel: Panel @@ -64,15 +68,14 @@ var status_label: Label var msg_label: Label var b_end: Button var b_fuse: Button -var b_reroll: Button var b_giveup: Button -var field_view: FieldView -var link_view: Links # Resonanzlinien unter den Feldreihen -var info_label: Label # Vorschau: Resonanz, Feld, Feldkombo -var board_panel: Panel # eigenes Brett, färbt sich mit dem Feld +var element_view: ElementView +var info_label: Label # Vorschau: Elementbonus, Merkmale +var board_panel: Panel # eigenes Brett +var menu_layer: Control # Kartenmenü (Rechtsklick) +var menu_target = null # Karte oder Wesen, für das das Menü offen ist var arrivals := {} # uid -> Rect2: Wesen, das gerade aus der Hand aufs Brett fliegt var banner_turn := -1 # Zug, für den die Zugansage schon lief -var deal_shop := false # nächster Aufbau teilt den Laden animiert aus var overlay: Control var result_label: Label var reward_label: Label @@ -172,10 +175,10 @@ func _build_static() -> void: b_giveup.add_theme_font_size_override("font_size", 13) b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log) b_log.add_theme_font_size_override("font_size", 13) - # Mitte: Feldband, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben + # Mitte: Elementzähler, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8) - field_view = FieldView.new(L.FIELD) - add_child(field_view) + element_view = ElementView.new(L.FIELD) + add_child(element_view) msg_label = Style.label(self, "", 15, Style.ORANGE, L.MSG, "center", true) board_panel = Style.panel(self, L.OWN_PANEL, Style.darker(Style.PANEL, 0.8), 10) var slots := Control.new() @@ -200,9 +203,10 @@ func _build_static() -> void: b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo) b_undo.add_theme_font_size_override("font_size", 15) b_undo.visible = false - # unten: Laden mit Neuwürfeln links und Zug beenden rechts - b_reroll = Style.button(self, "", L.B_REROLL, do_reroll, Color("285a5a")) - b_reroll.add_theme_font_size_override("font_size", 15) + # unten: Zieh-Stapel links, Hand in der Mitte, Zug beenden rechts + deck = Deck.new(L.DECK) + deck.clicked.connect(func(): on_click("deck", null)) + add_child(deck) b_end = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY) b_end.add_theme_font_size_override("font_size", 20) b_skip = Style.button(self, "Gegnerzüge überspringen", L.B_END, func(): skip_ai = true) @@ -211,16 +215,27 @@ func _build_static() -> void: wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center") wait_label.visible = false hero_layer = _layer() - link_view = Links.new() - add_child(link_view) tile_layer = _layer() hand_layer = _layer() - shop_layer = _layer() preview_holder = _layer() info_label = Style.label(self, "", 13, Style.TEXT, Rect2(0, 0, 220, 60), "center") info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART info_label.visible = false fx_layer = _layer() + # offene Wahl: drei sehen, eine nehmen (zu Beginn fünf sehen, drei nehmen) + pick_layer = Style.overlay(self) + pick_box = Control.new() + pick_box.mouse_filter = Control.MOUSE_FILTER_IGNORE + pick_layer.add_child(pick_box) + # Kartenmenü: durchsichtiger Fänger, der das Menü beim Klick daneben schließt + menu_layer = Control.new() + menu_layer.set_anchors_preset(Control.PRESET_FULL_RECT) + menu_layer.mouse_filter = Control.MOUSE_FILTER_STOP + menu_layer.visible = false + menu_layer.gui_input.connect(func(ev): + if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT: + close_menu()) + add_child(menu_layer) cursor_layer = _layer() # Verlauf als Überlagerung log_overlay = Style.overlay(self) @@ -339,14 +354,23 @@ func my_turn() -> bool: func any_overlay() -> bool: - return confirm.visible or overlay.visible or log_overlay.visible + return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible func _input(event: InputEvent) -> void: if game == null: return if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: - cancel_all() + var under := get_viewport().gui_get_hovered_control() + if menu_layer.visible: + close_menu() + elif under is CardView and under in hand_views and my_turn(): + open_menu(under.card, Rect2(under.position, under.size)) + elif under is CreatureView and under in own_views and my_turn(): + open_menu(under.creature, Rect2(under.slot, under.size)) + else: + cancel_all() + get_viewport().set_input_as_handled() # sonst schließt derselbe Klick das Menü gleich wieder return if event is InputEventJoypadButton and event.pressed: pad.button(event.button_index) @@ -357,9 +381,11 @@ func _input(event: InputEvent) -> void: KEY_ESCAPE: if confirm.visible: confirm.visible = false + elif menu_layer.visible: + close_menu() elif log_overlay.visible: log_overlay.visible = false - elif selected != null or pending != null or fusing: + elif selected != null or fusing: cancel_all() elif not any_overlay(): confirm.visible = true @@ -375,15 +401,20 @@ func _input(event: InputEvent) -> void: if overlay.visible: leave() _: - if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay(): + if event.keycode >= KEY_1 and event.keycode <= KEY_9 and not spectating() and not any_overlay(): var idx: int = event.keycode - KEY_1 - if idx < perspective.hand.size(): + if not perspective.offer.is_empty(): + do_pick(idx) + elif my_turn() and idx < perspective.hand.size(): on_click("hand", perspective.hand[idx]) + elif event.keycode == KEY_Z and my_turn() and not any_overlay(): + on_click("deck", null) func cancel_all() -> void: if confirm.visible: confirm.visible = false + close_menu() cancel() refresh() @@ -438,7 +469,7 @@ func _process(delta: float) -> void: func _start_ai(cur: Game.Player) -> void: - ai_copy = game.clone() + ai_copy = game.masked_clone(cur) # verdeckte Wesen des Menschen kennt die KI nicht var copy := ai_copy var idx := game.current_idx var box := ai_box @@ -472,25 +503,24 @@ func say(m: String) -> void: func cancel() -> void: selected = null - pending = null fusing = false fuse_first = null -func play(card: Cards.Card, target) -> void: +func play(card: Cards.Card, face_down: bool = false) -> void: if is_client(): - net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null}) + 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 log_len := game.log.size() var from := _hand_rect_of(card) - var err := game.play_card(perspective, card, target) + var err := game.play_card(perspective, card, face_down) if err != "": say(tr(err)) return if from.size.x > 0 and not perspective.board.is_empty() and perspective.board[-1].card == card: arrivals[perspective.board[-1].uid] = from - # Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte und keine Kombos) + # Rückgängig nur, wenn nichts weiter passiert ist (kein Wesen gestorben) if before != null and game.events.size() == 1 and game.events[0].type == "play": undo_state = before undo_log_len = log_len @@ -499,6 +529,84 @@ func play(card: Cards.Card, target) -> void: clear_undo() +## 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)) + after_action() + + +## Kauft eine Eigenschaft für eine Handkarte oder ein eigenes Wesen. +func do_upgrade(target, keyword: String) -> void: + clear_undo() + if is_client(): + if target is Game.Creature: + net.send_action({"type": "upgrade", "uid": target.uid, "keyword": keyword}) + else: + net.send_action({"type": "upgrade", "hand": perspective.hand.find(target), "keyword": keyword}) + return + var err := game.upgrade(perspective, target, keyword) + if err != "": + say(tr(err)) + after_action() + + +## Kartenmenü neben einer Handkarte oder einem eigenen Wesen. +func open_menu(target, anchor: Rect2) -> void: + if spectating() or not my_turn(): + return + Style.clear(menu_layer) + menu_target = target + var entries := [] # [Text, Callable, aktiv] + var is_card: bool = target is Cards.Card + var card: Cards.Card = target if is_card else target.card + if is_card: + var ok := game.can_play(perspective, card) + entries.append([tr("Verdeckt legen ◆ %d") % card.cost, func(): + play(card, true) + after_action(), ok[0]]) + elif target.hidden: + entries.append([tr("Aufdecken"), func(): do_reveal(target), game.can_reveal(perspective, target)[0]]) + for kw in Cards.KEYWORDS: + var k: String = kw + var ok := game.can_upgrade(perspective, target, k) + var text := "%s ◆ %d" % [tr(k), game.upgrade_cost(perspective, k)] + if k in card.keywords: + text = "%s ✓" % tr(k) + entries.append([text, func(): do_upgrade(target, k), ok[0]]) + var w := 230.0 + var h := 18.0 + entries.size() * 44 + 34 + var x := clampf(anchor.end.x + 8, 10, 1270 - w) + if x + w > anchor.position.x and x < anchor.end.x: + x = clampf(anchor.position.x - 8 - w, 10, 1270 - w) + var y := clampf(anchor.position.y, 10, 790 - h) + var box := Rect2(x, y, w, h) + Style.panel(menu_layer, box, Style.PANEL, 10, Style.GOLD, 2) + Style.label(menu_layer, card.title(), 15, Style.GOLD, Rect2(box.position.x + 12, box.position.y + 8, w - 24, 22), "left", true) + var by := box.position.y + 36 + for e in entries: + var act: Callable = e[1] + var b := Style.button(menu_layer, e[0], Rect2(box.position.x + 12, by, w - 24, 38), func(): + close_menu() + act.call()) + b.add_theme_font_size_override("font_size", 15) + b.disabled = not e[2] + by += 44 + menu_layer.visible = true + + +func close_menu() -> void: + if menu_layer.visible: + menu_layer.visible = false + Style.clear(menu_layer) + menu_target = null + + ## Verschmelzen an- und abwählen (zwei eigene Wesen, siehe Game.fuse). func toggle_fuse() -> void: var on := not fusing @@ -508,37 +616,36 @@ func toggle_fuse() -> void: after_action() -## Kauft ein Angebot aus dem Laden auf die Hand. -func do_buy(slot: int) -> void: +## Ziehen: kostet Mana, öffnet die Wahl aus drei Karten. +func do_draw() -> void: clear_undo() if is_client(): - net.send_action({"type": "buy", "slot": slot}) + net.send_action({"type": "draw"}) return - var from := BattleLayout.shop_rect(slot) - var offer = perspective.shop[slot] if slot < perspective.shop.size() else null - var err := game.buy(perspective, slot) - if err != "": - say(tr(err)) - elif offer != null: - fx.sfx("buy") - fx.fly_card(offer, from, BattleLayout.hand_rect(perspective.hand.size(), perspective.hand.size() - 1), - Cards.RARITIES[int(perspective.looks.get(offer.id, 0))]) - - -## Würfelt den Laden neu (kostet Mana). -func do_reroll() -> void: - if not my_turn() or any_overlay(): - return - clear_undo() - if is_client(): - net.send_action({"type": "reroll"}) - after_action() - return - var err := game.reroll(perspective) + var err := game.draw(perspective) if err != "": say(tr(err)) else: - deal_shop = true + fx.sfx("buy") + + +## Nimmt eine Karte der offenen Wahl auf die Hand (jederzeit, auch außerhalb des Zugs). +func do_pick(index: int) -> void: + if spectating() or index < 0 or index >= pick_views.size(): + return + clear_undo() + var from := Rect2(pick_views[index].position, pick_views[index].size) + var card: Cards.Card = pick_views[index].card + if is_client(): + net.send_action({"type": "pick", "index": index}) + return + var err := game.pick(perspective, index) + if err != "": + say(tr(err)) + return + fx.sfx("play") + fx.fly_card(card, from, BattleLayout.hand_rect(perspective.hand.size(), perspective.hand.size() - 1), + Cards.RARITIES[int(perspective.looks.get(card.id, 0))]) after_action() @@ -609,15 +716,6 @@ func on_click(kind: String, obj) -> void: do_fuse(fuse_first, obj) after_action() return - if pending != null: - var card = pending - pending = null - if obj in g.valid_targets(perspective, card.target_type()): - play(card, obj) - else: - say(tr("Abgebrochen")) - after_action() - return if selected != null: var cr = selected selected = null @@ -632,18 +730,18 @@ func on_click(kind: String, obj) -> void: after_action() return match kind: - "shop": - do_buy(int(obj)) + "deck": + do_draw() "hand": var ok := g.can_play(perspective, obj) if not ok[0]: say(tr(ok[1])) - elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty(): - pending = obj - say(tr("Wähle ein Ziel")) else: - play(obj, null) + play(obj) "own": + if obj.hidden: + do_reveal(obj) + return if obj.can_attack(): selected = obj say(tr("Wähle ein Angriffsziel")) @@ -669,8 +767,6 @@ func announce_turn() -> void: banner_turn = game.turn fx.banner(tr("Dein Zug"), Style.GOLD) fx.sfx("turn") - deal_shop = true - refresh() func _hand_rect_of(card: Cards.Card) -> Rect2: @@ -696,87 +792,109 @@ func show_preview(card, entered: bool, owner_looks: Dictionary = {}, info: Strin info_label.size = ir.size -## Eigene Hand- und Ladenkarte: zeigt, was das Ausspielen jetzt brächte, und markiert den Kombo-Partner. +## Eigene Hand- und Wahlkarte: zeigt den Elementbonus, den sie offen gelegt bekäme. func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void: - var f := game.forecast(perspective, card) - var lines: Array[String] = [] - var parts: Array[String] = [] - if f.resonance > 0: - parts.append(tr("Resonanz +%d") % f.resonance) - if f.field > 0: - parts.append(tr("Feld +%d") % f.field) - if not parts.is_empty(): - lines.append(" · ".join(parts)) - if f.combo != "": - lines.append(tr("Feldkombo %s mit %s: %s") % [tr(f.combo), f.partner.card.title(), tr(Cards.FIELD_COMBO_TEXT[f.combo])]) - for t in tiles.values(): - t.mark = "" - t.queue_redraw() - if entered and f.partner != null and tiles.has(f.partner.uid): - tiles[f.partner.uid].mark = f.combo - tiles[f.partner.uid].queue_redraw() - show_preview(card, entered, perspective.looks, "\n".join(lines), anchor) + var a := game.forecast(perspective, card) + var info := "" + if a > 0: + info = tr("Elementbonus +%d/+%d") % [a, a] + elif a < 0: + info = tr("Elementbonus %d/%d") % [a, a] + show_preview(card, entered, perspective.looks, info, anchor) -## Hand- und Ladenkarte tragen ihren Bonus und ihre Feldkombo als Vorschau. +## Offene Wahl als Überlagerung: nur neu aufbauen, wenn sich das Angebot geändert hat. +func build_pick() -> void: + var h := perspective + var key := "" + if not spectating() and not h.offer.is_empty(): + key = "%d:%s" % [h.picks_left, ",".join(h.offer.map(func(c): return c.id))] + if key == pick_key: + return + pick_key = key + Style.clear(pick_box) + pick_views = [] + if key == "": + pick_layer.visible = false + return + var box := BattleLayout.PICK_PANEL + Style.panel(pick_box, box, Style.PANEL, 12, Style.GOLD, 2) + Style.label(pick_box, tr("Wähle %d von %d") % [h.picks_left, h.offer.size()], 26, Style.GOLD, + Rect2(box.position.x, box.position.y + 24, box.size.x, 40), "center", true) + for i in h.offer.size(): + var card: Cards.Card = h.offer[i] + var r := BattleLayout.pick_rect(h.offer.size(), i) + var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))]) + v.position = r.position + v.lift = true + apply_forecast(v) + var idx := i + v.clicked.connect(func(_view): do_pick(idx)) + pick_box.add_child(v) + pick_views.append(v) + pick_layer.visible = true + if Style.animations: + for i in pick_views.size(): + fx.deal(pick_views[i], i) + + +## Hand- und Wahlkarten tragen den Elementbonus als Vorschau. func apply_forecast(v: CardView) -> void: - var f := game.forecast(perspective, v.card) - v.bonus = f.resonance + f.field - v.tag = f.combo + v.bonus = game.forecast(perspective, v.card) -## Was den Angriff eines Wesens gerade hebt. +## Was den Angriff eines Wesens gerade hebt oder senkt. func creature_info(cr: Game.Creature) -> String: var parts: Array[String] = [] - if cr.resonance > 0: - parts.append(tr("Resonanz +%d") % cr.resonance) - if cr.field_bonus > 0: - parts.append(tr("Feld +%d") % cr.field_bonus) - var perk := cr.power() - cr.attack - cr.resonance - cr.field_bonus + if cr.aura > 0: + parts.append(tr("Elementbonus +%d/+%d") % [cr.aura, cr.aura]) + elif cr.aura < 0: + parts.append(tr("Elementbonus %d/%d") % [cr.aura, cr.aura]) + var perk := cr.power() - cr.attack - cr.aura if perk > 0: parts.append(tr("Merkmal +%d") % perk) + if cr.hidden: + parts.append(tr("verdeckt")) return " · ".join(parts) -## Elementvorteil gegen ein mögliches Ziel: des gewählten Angreifers oder eines Schadenszaubers auf der Hand. -func matchup_for(cr: Game.Creature, valid: Array) -> int: - if selected != null and game.can_attack(selected, cr)[0]: +## Elementvorteil des gewählten Angreifers gegen ein mögliches Ziel; gegen Verdeckte unbekannt. +func matchup_for(cr: Game.Creature) -> int: + if selected != null and not cr.hidden and game.can_attack(selected, cr)[0]: return Game.matchup_bonus(selected, cr) - if pending != null and cr in valid and pending.effects.any(func(fx): return fx.kind == "damage" and fx.target in Cards.CHOSEN_TARGETS): - return Cards.matchup(pending.elements, cr.card.elements) return 0 -## Resonanzgruppen je Reihe: Wesen mit gleichem Element werden unter der Reihe verbunden. -func resonance_groups() -> Array: - var out := [] - for p in game.players: - var k := 0 - for e in Cards.ELEMENTS: - var xs: Array[float] = [] - for j in p.board.size(): - if e in p.board[j].card.elements: - xs.append(slot_rect(p, j).get_center().x) - if xs.size() < 2: - continue - out.append({"color": Style.lighter(Cards.ELEMENT_COLORS[e], 0.25), "y": slot_rect(p, 0).end.y + 4 + k * 6, "xs": xs}) - k += 1 - return out +class Deck extends Control: + signal clicked + var enabled := true + var count := 0 + var cost := Game.DRAW_COST + func _init(rect: Rect2) -> void: + position = rect.position + size = rect.size + mouse_filter = Control.MOUSE_FILTER_STOP -class Links extends Control: - var groups: Array = [] - - func _init() -> void: - set_anchors_preset(Control.PRESET_FULL_RECT) - mouse_filter = Control.MOUSE_FILTER_IGNORE + 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 gr in groups: - var y: float = gr.y - draw_line(Vector2(gr.xs[0], y), Vector2(gr.xs[-1], y), gr.color, 3.0) - for x in gr.xs: - draw_circle(Vector2(x, y), 4.0, gr.color) + for k in range(2, -1, -1): + var r := Rect2(Vector2(k * 3, -k * 3), size - Vector2(6, 12)).grow(0) + r.position.y += 6 + draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD if enabled else Style.MUTED, 2), r) + if k == 0: + Style.border(self, r.grow(-12), Color("5a3c82"), 2, 6) + 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") + if not enabled: + draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35)) + + # --- Netzwerk ------------------------------------------------------------------------------- @@ -845,9 +963,7 @@ func refresh() -> void: var h := perspective var now := Time.get_ticks_msec() var mine := my_turn() - var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else [] hand_views = [] - shop_views = [] own_views = [] enemy_views = [] @@ -858,7 +974,7 @@ func refresh() -> void: if g.current() == o: bcol = Style.BLUE bw = 3 - if o in valid or (selected != null and o.alive() and g.can_attack(selected, o)[0]): + if selected != null and o.alive() and g.can_attack(selected, o)[0]: bcol = Style.ORANGE bw = 3 if int(flash_until.get(g.players.find(o), 0)) > now: @@ -870,7 +986,7 @@ func refresh() -> void: labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED) if o.alive(): labels.life.text = "♥ %d" % maxi(o.life, 0) - labels.stats.text = "Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy] + labels.stats.text = "Hand %d ◆ %d" % [o.hand.size(), o.energy] else: labels.life.text = "besiegt" labels.life.add_theme_color_override("font_color", Style.MUTED) @@ -898,7 +1014,11 @@ func refresh() -> void: tiles[cr.uid] = tile tile_layer.add_child(tile) var looks: Dictionary = p.looks - tile.hover.connect(func(card, entered): show_preview(card, entered, looks, creature_info(tile.creature) if entered else "", Rect2(tile.slot, tile.size))) + tile.hover.connect(func(card, entered): + if tile.secret: + show_preview(null, false) + else: + show_preview(card, entered, looks, creature_info(tile.creature) if entered else "", Rect2(tile.slot, tile.size))) var uid := cr.uid tile.clicked.connect(func(_v): var c := game.find_creature(uid) @@ -920,7 +1040,7 @@ func refresh() -> void: elif fusing and p == h and mine: b = Style.PURPLE w = 3 - elif cr in valid or (selected != null and g.can_attack(selected, cr)[0]): + elif selected != null and g.can_attack(selected, cr)[0]: b = Style.ORANGE w = 3 elif p == h and mine and cr.can_attack(): @@ -928,8 +1048,8 @@ func refresh() -> void: w = 3 tile.border = b tile.width = w - tile.mark = "" - tile.matchup = matchup_for(cr, valid) + tile.secret = cr.hidden and p != h + tile.matchup = matchup_for(cr) tile.queue_redraw() if p == h: own_views.append(tile) @@ -940,31 +1060,12 @@ func refresh() -> void: var t: CreatureView = tiles[uid] tiles.erase(uid) fx.fade_out(t) - link_view.groups = resonance_groups() - link_view.queue_redraw() - - Style.clear(shop_layer) - if not spectating(): - for i in h.shop.size(): - var offer = h.shop[i] - var sr := BattleLayout.shop_rect(i) - if offer == null: - Style.panel(shop_layer, sr, Style.darker(Style.PANEL, 0.8), 8, Style.PANEL_LIGHT, 2) - continue - var sv := CardView.new(offer, sr.size, false, Cards.RARITIES[int(h.looks.get(offer.id, 0))]) - sv.position = sr.position - sv.lift = mine - apply_forecast(sv) - sv.dim = not mine or h.energy < offer.cost or h.hand.size() >= Game.MAX_HAND - sv.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(sv.position, sv.size))) - var slot := i - sv.set_meta("slot", slot) # der Platz im Laden, auch für das Gamepad - sv.clicked.connect(func(_view): on_click("shop", slot)) - shop_layer.add_child(sv) - shop_views.append(sv) - if deal_shop: - fx.deal(sv, i) - deal_shop = false + build_pick() + deck.visible = not spectating() + deck.enabled = mine and g.can_draw(h)[0] + deck.count = g.deck.size() + deck.cost = g.draw_cost(h) + deck.queue_redraw() Style.clear(hand_layer) if spectating(): @@ -978,9 +1079,7 @@ func refresh() -> void: v.position = r.position v.lift = mine apply_forecast(v) - if card == pending: - v.border = Style.YELLOW - elif playable: + if playable: v.border = Style.GREEN v.dim = not playable v.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(v.position, v.size))) @@ -992,14 +1091,10 @@ func refresh() -> void: if int(flash_until.get(g.players.find(h), 0)) > now: bar_border = Style.RED status_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0)) - var board_col := Style.darker(Style.PANEL, 0.8) - if g.field_element != "": - board_col = board_col.lerp(Cards.ELEMENT_COLORS[g.field_element], 0.22) # das Feld färbt das Brett - board_panel.add_theme_stylebox_override("panel", Style.box(board_col, 10)) name_label.text = h.name life_label.text = "♥ %d" % maxi(h.life, 0) energy_label.text = "◆ %d" % h.energy - income_label.text = tr("+%d je Zug") % h.max_energy + income_label.text = tr("nächster Zug ◆ %d") % (h.max_energy + 1) status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name]) if spectating(): status_label.text = tr("Zuschauer") + " · " + status_label.text @@ -1009,21 +1104,18 @@ func refresh() -> void: b_end.visible = not ai_running and not spectating() b_undo.visible = undo_state != null and mine b_fuse.visible = mine and not spectating() and h.board.size() >= 2 - b_reroll.visible = mine and not spectating() - b_reroll.text = tr("Neu würfeln ◆ %d") % Game.REROLL_COST - b_reroll.disabled = h.energy < Game.REROLL_COST b_fuse.text = tr("Abbrechen") if fusing else tr("Verschmelzen") - field_view.update_from(g) + element_view.update_from(g) var lines: Array[String] = [] var start := maxi(0, g.log.size() - 60) for i in range(start, g.log.size()): var line := g.log[i] if line.begins_with("---"): lines.append("[color=#6eaaf0]%s[/color]" % line) - elif line.begins_with("Das Feld wird"): + elif " verschmelzen zu " in line or " wird aufgedeckt." in line: lines.append("[color=#8c78dc]%s[/color]" % line) - elif line.begins_with("Feldkombo"): - lines.append("[color=#f5c850]%s[/color]" % line) + elif " bekommt " in line: + lines.append("[color=#5ad282]%s[/color]" % line) else: lines.append(line) log_box.text = "\n".join(lines) diff --git a/scripts/ui/campaign_screen.gd b/scripts/ui/campaign_screen.gd index 9736097..cd87b7e 100644 --- a/scripts/ui/campaign_screen.gd +++ b/scripts/ui/campaign_screen.gd @@ -88,17 +88,9 @@ func build() -> void: var next := selected == p.campaign_stage Style.panel(self, Rect2(240, 270, 800, 300)) Style.label(self, "%s · %s" % [st.name, st.title], 28, Style.TEXT, Rect2(270, 290, 740, 40), "center", true) - var dots := Control.new() - dots.mouse_filter = Control.MOUSE_FILTER_IGNORE - var elems: Array = st.elements - dots.draw.connect(func(): - var w := elems.size() * 26 - 6 - for m in elems.size(): - dots.draw_circle(Vector2(640 - w / 2.0 + 10 + m * 26, 348), 10, Cards.ELEMENT_COLORS[elems[m]])) - add_child(dots) - Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 370, 740, 24), "center") + Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 350, 740, 24), "center") var perks: Array = st.perks - var py := 402 + var py := 390 for k in perks: Style.label(self, "★ " + tr(Game.PERKS[k]), 15, Style.GOLD, Rect2(270, py, 740, 22), "center") py += 24 diff --git a/scripts/ui/card_view.gd b/scripts/ui/card_view.gd index 783195c..2b6a07b 100644 --- a/scripts/ui/card_view.gd +++ b/scripts/ui/card_view.gd @@ -12,8 +12,7 @@ var card: Cards.Card var border = null # Color oder null (dann Rahmen der Seltenheitsstufe) var rarity := "gewöhnlich" # nur Optik var dim := false -var bonus := 0 # Vorschau im Kampf: Angriff, den die Karte auf dem Feld dazubekäme -var tag := "" # Vorschau im Kampf: Feldkombo, die das Ausspielen auslöst +var bonus := 0 # Vorschau im Kampf: Elementbonus, den die Karte offen gelegt bekäme (+1 oder -1) var lift := false # hebt sich beim Zeigen an (Hand und Laden) @@ -132,32 +131,27 @@ func _draw_full(rect: Rect2, frame: Color, seed: int) -> void: y += icon * 2 + 8 # Effekttext: so hoch wie nötig, endet über den Wertkreisen var box := Rect2() - var fx := card.effect_text() + var fx := card.text() var box_bottom: float = size.y - r_stat * 2 - 4 if fx != "" and box_bottom - y > Style.fs(f_text) + 6: var tsize := Style.fs(f_text) - var heading := big - var needed: float = Style.FONT.get_multiline_string_size(fx, HORIZONTAL_ALIGNMENT_LEFT, size.x - 20, tsize).y + 8 + (tsize + 2 if heading else 0) + var needed: float = Style.FONT.get_multiline_string_size(fx, HORIZONTAL_ALIGNMENT_LEFT, size.x - 20, tsize).y + 8 var box_top: float = maxf(y, y + (box_bottom - y - needed) / 2) box = Rect2(6, box_top, size.x - 12, box_bottom - box_top) draw_rect(box, Color(0.06, 0.06, 0.09, 0.72)) var ty := box.position.y + 4 - if heading: - Style.text(self, tr("Beim Ausspielen"), f_text - 2, Style.MUTED, Vector2(box.position.x + 4, ty), "topleft") - ty += tsize + 2 var max_lines := maxi(1, int((box.end.y - ty - 2) / Style.FONT.get_height(tsize))) draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, ty + Style.FONT.get_ascent(tsize)), fx, HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT) # Werte springen aus den unteren Ecken, dazwischen der Edelstein Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat) Style.stat_circle(self, Vector2(size.x - r_stat + 2, size.y - r_stat + 2), r_stat, str(card.health), Style.CRIMSON, f_stat) - if bonus > 0: - Style.text_outlined(self, "+%d" % bonus, f_text, Style.GREEN, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3) + if bonus != 0: + Style.text_outlined(self, ("+%d/+%d" % [bonus, bonus]) if bonus > 0 else ("%d/%d" % [bonus, bonus]), f_text, + Style.GREEN if bonus > 0 else Style.RED, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3) Style.rarity_gem(self, rarity, Vector2(size.x / 2, size.y - r_stat + 1), 8 if big else 6, seed) Style.border(self, rect, frame, 3 if big else 2, 8) Style.cost_crystal(self, Vector2(r_cost + 2, r_cost + 2), r_cost, card.cost, f_stat) - if tag != "": - _draw_tag(Vector2(size.x - 6, 6), f_text - 1) Style.rarity_sparkle(self, rect, rarity, seed, box) @@ -170,29 +164,16 @@ func _draw_compact(rect: Rect2, frame: Color, seed: int) -> void: var nf := Style.fit_size(card.title(), true, 12, name_w) Style.text(self, Style.fit(card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(28, 13), "midleft", true) Style.rarity_gem(self, rarity, Vector2(size.x - 9, 13), 5, seed) - if tag != "": - var w := Style.FONT_BOLD.get_string_size(tr(tag), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(9)).x + 12 - _draw_tag(Vector2(size.x / 2 + w / 2, size.y - 21), 9) - else: - var kws: Array[String] = [] - for k in Cards.KEYWORDS: - if k in card.keywords: - kws.append(k) - var x := size.x / 2 - (kws.size() * 18 - 4) / 2.0 - for k in kws: - Style.keyword_icon(self, k, Vector2(x + 6, size.y - 13), 6) - x += 18 + var kws: Array[String] = [] + for k in Cards.KEYWORDS: + if k in card.keywords: + kws.append(k) + var x := size.x / 2 - (kws.size() * 18 - 4) / 2.0 + for k in kws: + Style.keyword_icon(self, k, Vector2(x + 6, size.y - 13), 6) + x += 18 Style.stat_circle(self, Vector2(11, size.y - 11), 10, str(card.attack), Style.AMBER, 12) Style.stat_circle(self, Vector2(size.x - 11, size.y - 11), 10, str(card.health), Style.CRIMSON, 12) - if bonus > 0: - Style.text_outlined(self, "+%d" % bonus, 10, Style.GREEN, Vector2(4, size.y - 29), "midleft", 3) + if bonus != 0: + Style.text_outlined(self, ("+%d" % bonus) if bonus > 0 else str(bonus), 10, Style.GREEN if bonus > 0 else Style.RED, Vector2(4, size.y - 29), "midleft", 3) Style.rarity_sparkle(self, rect, rarity, seed) - - -## Goldenes Schild mit der Feldkombo, rechts oben. -func _draw_tag(top_right: Vector2, font: int) -> void: - var w := Style.FONT_BOLD.get_string_size(tr(tag), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(font)).x + 12 - var h := Style.fs(font) + 6 - var r := Rect2(top_right - Vector2(w, 0), Vector2(w, h)) - draw_style_box(Style.box(Color("14141c"), 6, Style.GOLD, 1), r) - Style.text(self, tr(tag), font, Style.GOLD, r.get_center(), "center", true) diff --git a/scripts/ui/creature_view.gd b/scripts/ui/creature_view.gd index 8e942be..83264a2 100644 --- a/scripts/ui/creature_view.gd +++ b/scripts/ui/creature_view.gd @@ -11,8 +11,8 @@ var width := 2 var flash := false # kurz rot: wurde gerade angegriffen var slot: Vector2 # Zielposition im Raster var busy := false # wird gerade animiert (kein Neupositionieren) -var mark := "" # Feldkombo, die dieses Wesen als Partner auslösen würde var matchup := 0 # Elementvorteil des gewählten Angreifers gegen dieses Wesen (+1/-1) +var secret := false # verdecktes fremdes Wesen: nur der Rücken ist zu sehen func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void: @@ -45,36 +45,31 @@ func rarity() -> String: func _draw() -> void: var rect := Rect2(Vector2.ZERO, size) + if secret: + _draw_back(rect) + return var small := size.x < 100 - var mini := size.x < 60 var rar := rarity() var seed := hash(creature.card.id) + creature.uid if rar != "gewöhnlich": - Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 4 if mini else 6) + Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 6) Style.fill_elements(self, rect, creature.card.elements, 6) - if not mini: - Style.glyph(self, creature.card, Vector2(size.x / 2, size.y * 0.5), size.x * 0.75, 0.3) + Style.glyph(self, creature.card, Vector2(size.x / 2, size.y * 0.5), size.x * 0.75, 0.3) Style.rarity_sheen(self, rect, rar, seed) var bcol: Color = border if border != null else Style.rarity_frame(rar, seed) if flash: bcol = Style.RED Style.border(self, rect, bcol, 4 if flash else width, 6) - if mark != "": - Style.border(self, rect.grow(3), Style.GOLD, 3, 8) - if mini: - _draw_mini() - Style.rarity_sparkle(self, rect, rar, seed) - return var fs := 11 if small else 13 var name_w: float = size.x - (30 if matchup != 0 else 6) # Platz für den Vorteilskreis oben rechts var nf := Style.fit_size(creature.card.title(), true, fs, name_w) draw_rect(Rect2(2, 2, size.x - 4, Style.fs(fs) + 8), Color(0.05, 0.05, 0.08, 0.7)) Style.text(self, Style.fit(creature.card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(3 + name_w / 2, 6), "midtop", true) - # Schlüsselwörter als Symbole + # Schlüsselwörter als Symbole (Schild nur, solange er steht) var icon := 7.0 if small else 9.0 var kws: Array[String] = [] for k in Cards.KEYWORDS: - if k in creature.card.keywords: + if k in creature.card.keywords and (k != "Schild" or creature.shield): kws.append(k) if creature.shield and not ("Schild" in kws): kws.append("Schild") @@ -85,23 +80,31 @@ func _draw() -> void: x += icon * 2 + 6 _draw_stats(15 if small else 17, 10 if small else 12, 12.0 if small else 14.0) Style.rarity_gem(self, rar, Vector2(size.x / 2, size.y - 9), 5, seed) - if mark != "": - var w := Style.FONT_BOLD.get_string_size(tr(mark), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(11)).x + 14 - draw_style_box(Style.box(Color("14141c"), 6, Style.GOLD, 1), Rect2(size.x / 2 - w / 2, -9, w, 18)) - Style.text(self, tr(mark), 11, Style.GOLD, Vector2(size.x / 2, 0), "center", true) - if creature.frozen: - draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35)) - Style.text_outlined(self, "❄", 22 if small else 28, Color("dff2ff"), size / 2, "center", 3) + if creature.hidden: + draw_rect(rect, Color(0.05, 0.05, 0.1, 0.55)) + Style.text_outlined(self, tr("verdeckt"), 12, Style.TEXT, Vector2(size.x / 2, size.y / 2), "center", 3) Style.rarity_sparkle(self, rect, rar, seed) -## Angriff (grün mit „+n“, wenn Resonanz, Feld oder Merkmale ihn heben), Leben, Elementvorteil. +## Verdecktes fremdes Wesen: ein Kartenrücken. +func _draw_back(rect: Rect2) -> void: + var bcol: Color = border if border != null else Style.GOLD + if flash: + bcol = Style.RED + draw_style_box(Style.box(Color("3c285a"), 6, bcol, 4 if flash else maxi(width, 2)), rect) + Style.border(self, rect.grow(-10), Color("5a3c82"), 2, 6) + Style.text(self, "?", 36, Style.GOLD, size / 2, "center", true) + if matchup != 0: + pass # unbekanntes Element: kein Vorteil anzeigen + + +## Angriff (grün oder rot, wenn die Elementregel ihn hebt oder senkt), Leben, Elementvorteil. func _draw_stats(f: int, f_small: int, r: float) -> void: var extra := creature.power() - creature.attack - var atk_col := Style.GREEN if extra > 0 else Style.AMBER + var atk_col := Style.GREEN if extra > 0 else (Style.RED if extra < 0 else Style.AMBER) Style.stat_circle(self, Vector2(r - 1, size.y - r + 1), r, str(creature.power()), atk_col, f) - if extra > 0: - Style.text_outlined(self, "+%d" % extra, f_small, Style.GREEN, Vector2(r * 2 + 3, size.y - r + 1), "midleft", 3) + if extra != 0: + Style.text_outlined(self, ("+%d" % extra) if extra > 0 else ("−%d" % -extra), f_small, Style.GREEN if extra > 0 else Style.RED, Vector2(r * 2 + 3, size.y - r + 1), "midleft", 3) var hp_col := Style.CRIMSON if creature.health < creature.max_health else Color("b84a4a") Style.stat_circle(self, Vector2(size.x - r + 1, size.y - r + 1), r, str(creature.health), hp_col, f) if matchup != 0: @@ -109,26 +112,3 @@ func _draw_stats(f: int, f_small: int, r: float) -> void: draw_circle(c, 11, Color("14141c")) Style.text(self, ("+%d" % matchup) if matchup > 0 else ("−%d" % -matchup), f_small + 1, Style.GREEN if matchup > 0 else Style.RED, c, "center", true) - - -## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe. -func _draw_mini() -> void: - var letters: Array[String] = [] - if creature.taunt(): - letters.append("W") - if creature.shield: - letters.append("S") - if creature.attacks_per_turn() == 2: - letters.append("2") - if creature.frozen: - letters.append("❄") - Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true) - var extra := creature.power() - creature.attack - Style.text_outlined(self, str(creature.power()), 13, Style.GREEN if extra > 0 else Style.YELLOW, Vector2(9, size.y - 9), "center", 3) - var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8") - Style.text_outlined(self, str(creature.health), 13, hp_col, Vector2(size.x - 9, size.y - 9), "center", 3) - if matchup != 0: - Style.text(self, ("+%d" % matchup) if matchup > 0 else ("−%d" % -matchup), 10, - Style.GREEN if matchup > 0 else Style.RED, Vector2(size.x - 3, 2), "topright", true) - if creature.frozen: - draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35)) diff --git a/scripts/ui/element_view.gd b/scripts/ui/element_view.gd new file mode 100644 index 0000000..389a8e9 --- /dev/null +++ b/scripts/ui/element_view.gd @@ -0,0 +1,73 @@ +class_name ElementView +extends Control +## Die Vier-Elemente-Regel auf einen Blick: offene Wesen je Element auf dem Tisch. Ab vier +## bekommt das Element +1/+1 und das Element, das es schlägt, -1/-1. + +var counts := {} + + +func _init(rect: Rect2) -> void: + position = rect.position + size = rect.size + mouse_filter = Control.MOUSE_FILTER_IGNORE + for e in Cards.ELEMENTS: + counts[e] = 0 + + +func update_from(g: Game) -> void: + counts = g.element_counts() + queue_redraw() + + +func strong(e: String) -> bool: + return int(counts.get(e, 0)) >= Game.ELEMENT_STACK + + +func weak(e: String) -> bool: + var predator := Cards.beaten_by(e) + return predator != "" and strong(predator) + + +## Der Inhalt als Text (Tests, Protokoll). +func text() -> String: + var parts: Array[String] = [] + for e in Cards.ELEMENTS: + var s := "%s %d" % [tr(e), int(counts.get(e, 0))] + if strong(e): + s += " (+1/+1)" + if weak(e): + s += " (-1/-1)" + parts.append(s) + return " · ".join(parts) + + +func _draw() -> void: + var w := size.x / Cards.ELEMENTS.size() + for i in Cards.ELEMENTS.size(): + var e: String = Cards.ELEMENTS[i] + var col: Color = Cards.ELEMENT_COLORS[e] + var x0 := i * w + var cy := size.y / 2 + var n := int(counts.get(e, 0)) + if strong(e): + draw_style_box(Style.box(Color(col, 0.35), 6, col, 2), Rect2(x0 + 2, 0, w - 4, size.y)) + elif weak(e): + draw_style_box(Style.box(Color(Style.RED, 0.12), 6, Style.RED, 1), Rect2(x0 + 2, 0, w - 4, size.y)) + Style.text(self, tr(e), 13, Style.TEXT if strong(e) else Style.MUTED, Vector2(x0 + 12, cy), "midleft", strong(e)) + var px := x0 + 12 + Style.FONT_BOLD.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 12 + for k in Game.ELEMENT_STACK: + var c := Vector2(px + k * 13, cy) + if k < n: + draw_circle(c, 4.5, col) + else: + draw_circle(c, 4.5, Color(0, 0, 0, 0.35)) + draw_arc(c, 4.5, 0, TAU, 16, Color(col, 0.6), 1.0) + if n > Game.ELEMENT_STACK: + Style.text(self, "+%d" % (n - Game.ELEMENT_STACK), 11, Style.MUTED, Vector2(px + Game.ELEMENT_STACK * 13 + 2, cy), "midleft") + var tag := "" + if strong(e): + tag = "+1/+1" + elif weak(e): + tag = "-1/-1" + if tag != "": + Style.text(self, tag, 13, Style.GREEN if strong(e) else Style.RED, Vector2(x0 + w - 12, cy), "midright", true) diff --git a/scripts/ui/element_view.gd.uid b/scripts/ui/element_view.gd.uid new file mode 100644 index 0000000..15c92e7 --- /dev/null +++ b/scripts/ui/element_view.gd.uid @@ -0,0 +1 @@ +uid://c2tr1bg65rhk6 diff --git a/scripts/ui/field_view.gd b/scripts/ui/field_view.gd deleted file mode 100644 index ba7e99c..0000000 --- a/scripts/ui/field_view.gd +++ /dev/null @@ -1,53 +0,0 @@ -class_name FieldView -extends Control -## Das gemeinsame Feld: je Element vier Ladepunkte, bei vollen Punkten kippt das Feld. -## Das aktive Feld steht farbig mit seinem Bonus da. - -var element := "" -var charge := {} - - -func _init(rect: Rect2) -> void: - position = rect.position - size = rect.size - mouse_filter = Control.MOUSE_FILTER_IGNORE - - -func update_from(g: Game) -> void: - element = g.field_element - charge = g.field_charge.duplicate() - queue_redraw() - - -## Der Inhalt als Text (Tests, Protokoll). -func text() -> String: - var parts: Array[String] = [] - for e in Cards.ELEMENTS: - if e == element: - parts.append("%s: +1 %s" % [tr(Cards.FIELD_NAMES[e]), tr("Angriff")]) - else: - parts.append("%s %d/%d" % [tr(e), int(charge.get(e, 0)), Cards.FIELD_CHARGE]) - return " · ".join(parts) - - -func _draw() -> void: - var w := size.x / Cards.ELEMENTS.size() - for i in Cards.ELEMENTS.size(): - var e: String = Cards.ELEMENTS[i] - var col: Color = Cards.ELEMENT_COLORS[e] - var x0 := i * w - var cy := size.y / 2 - if e == element: - draw_style_box(Style.box(Color(col, 0.35), 6, col, 2), Rect2(x0 + 2, 0, w - 4, size.y)) - Style.text(self, "%s +1 %s" % [tr(Cards.FIELD_NAMES[e]), tr("Angriff")], 13, Style.lighter(col, 0.4), Vector2(x0 + w / 2, cy), "center", true) - continue - Style.text(self, tr(e), 13, Style.MUTED, Vector2(x0 + 12, cy), "midleft") - var n := int(charge.get(e, 0)) - var px := x0 + 12 + Style.FONT.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 14 - for k in Cards.FIELD_CHARGE: - var c := Vector2(px + k * 13, cy) - if k < n: - draw_circle(c, 4.5, col) - else: - draw_circle(c, 4.5, Color(0, 0, 0, 0.35)) - draw_arc(c, 4.5, 0, TAU, 16, Color(col, 0.6), 1.0) diff --git a/scripts/ui/field_view.gd.uid b/scripts/ui/field_view.gd.uid deleted file mode 100644 index f8b2e63..0000000 --- a/scripts/ui/field_view.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://i4rdq5rmcyuk diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd index 5368292..77bdb71 100644 --- a/scripts/ui/opponent_select_screen.gd +++ b/scripts/ui/opponent_select_screen.gd @@ -1,7 +1,7 @@ class_name OpponentSelectScreen extends Control ## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf -## aus dem Laden (siehe DeckGen.shop_cards). +## per Ziehen (siehe DeckGen.offer_cards). var app var diff_buttons := {} diff --git a/scripts/ui/style.gd b/scripts/ui/style.gd index 26e6180..b5f2321 100644 --- a/scripts/ui/style.gd +++ b/scripts/ui/style.gd @@ -344,7 +344,17 @@ static func rarity_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> var lx := x0 + (w - lw) / 2 var band := PackedVector2Array([Vector2(lx, rect.position.y), Vector2(lx + lw, rect.position.y), Vector2(lx + lw - k, rect.end.y), Vector2(lx - k, rect.end.y)]) for poly in Geometry2D.intersect_polygons(band, frame): - ci.draw_colored_polygon(poly, Color(1, 1, 1, layer[1])) + if poly.size() >= 3 and _area(poly) > 2.0: # Splitter am Rand lassen sich nicht zeichnen + ci.draw_colored_polygon(poly, Color(1, 1, 1, layer[1])) + + +static func _area(poly: PackedVector2Array) -> float: + var a := 0.0 + for i in poly.size(): + var p := poly[i] + var q := poly[(i + 1) % poly.size()] + a += p.x * q.y - q.x * p.y + return absf(a) / 2.0 ## Funkeln ab legendär: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen. @@ -434,8 +444,7 @@ static func stat_circle(ci: CanvasItem, c: Vector2, r: float, value: String, col text_outlined(ci, value, font, Color.WHITE, c, "center", 3) -## Schlüsselwort als Symbol auf dunklem Kreis: Wächter Mauer, Schild Schild, Flink Doppelpfeil, -## Doppelschlag gekreuzte Klingen. +## Schlüsselwort als Symbol auf dunklem Kreis: Schild Schild, Doppelschlag gekreuzte Klingen. static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: Color = Color("f2f2f6")) -> void: ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.7)) var w := maxf(1.5, r * 0.22) diff --git a/tests/frame_probe.gd b/tests/frame_probe.gd index f47458f..498479e 100644 --- a/tests/frame_probe.gd +++ b/tests/frame_probe.gd @@ -19,11 +19,15 @@ func _ready() -> void: await get_tree().process_frame await get_tree().process_frame var h: Game.Player = scr.game.human - scr.game.buy(h, 0) + for i in 3: + scr.game.pick(h, 0) + h.energy = 10 + scr.game.draw(h) + scr.game.pick(h, 0) var t0 := Time.get_ticks_usec() for i in 30: scr.refresh() - print("refresh() mit 8 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30)) + print("refresh() mit 4 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30)) scr.ai_delay = 0.2 measuring = true var start := Time.get_ticks_msec() diff --git a/tests/net_bot.gd b/tests/net_bot.gd index 72e78d3..462d0aa 100644 --- a/tests/net_bot.gd +++ b/tests/net_bot.gd @@ -73,22 +73,20 @@ func _act_host() -> void: _decide(game.players[seat]) -## Einfache Züge mit kurzer Bedenkzeit: erst das billigste bezahlbare Angebot kaufen, dann das -## erste spielbare Wesen ohne Ziel, dann angreifen, sonst Zug beenden. +## Einfache Züge mit kurzer Bedenkzeit: erst eine offene Wahl treffen, dann das erste +## spielbare Wesen ohne Ziel 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.shop.size(): - var offer = me.shop[i] - if offer != null and me.energy >= offer.cost and me.hand.size() < Game.MAX_HAND: - net.send_action({"type": "buy", "slot": i}) - return + if not me.offer.is_empty(): + net.send_action({"type": "pick", "index": 0}) + return for i in me.hand.size(): var card: Cards.Card = me.hand[i] - if game.can_play(me, card)[0] and card.target_type() == "": - net.send_action({"type": "play", "hand": i, "target": null}) + if game.can_play(me, card)[0]: + net.send_action({"type": "play", "hand": i}) return for cr in me.board: if cr.can_attack(): @@ -100,4 +98,7 @@ func _decide(me: Game.Player) -> void: 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/perf_probe.gd b/tests/perf_probe.gd index 7cdf7ad..9e6bb5d 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("Ladenangebot: %.2f ms" % ms(func(): DeckGen.shop_cards(r, 5), 50)) + print("Stapel mischen: %.2f ms" % ms(func(): DeckGen.deck(r), 20)) print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20)) for n in [1]: var g := Game.new_battle(r) diff --git a/tests/test_runner.gd b/tests/test_runner.gd index c6d954a..622c2c6 100644 --- a/tests/test_runner.gd +++ b/tests/test_runner.gd @@ -15,10 +15,10 @@ func check(cond: bool, msg: String) -> void: func C(id: String) -> Cards.Card: - return Cards.by_id(id) + return Cards.by_id(id).copy() -## Duell mit vollem Mana; der Gegner hat 15 Leben. Liefert [Spiel, Spieler, Gegner]. +## Duell mit 10 Mana; der Gegner hat 15 Leben, kein Stapel nötig. 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) @@ -40,11 +40,11 @@ func setup(human_hand: Array = [], opp_hand: Array = []) -> Array: var next_uid := 9000 -func put(p: Game.Player, cid: String, ready: bool = true) -> Game.Creature: +func put(p: Game.Player, cid: String, ready: bool = true, hidden: bool = false) -> Game.Creature: next_uid += 1 var cr := Game.Creature.new(C(cid), p, next_uid) - cr.ready = ready cr.attacks_left = cr.attacks_per_turn() if ready else 0 + cr.hidden = hidden p.board.append(cr) return cr @@ -52,39 +52,24 @@ func put(p: Game.Player, cid: String, ready: bool = true) -> Game.Creature: func _initialize() -> void: TranslationServer.set_locale("de") # das Spiel setzt die Sprache aus den Einstellungen; hier fehlt der Einstieg test_kartenpool() - test_kampf_speichern() - test_englische_karten() test_kombos() - test_wesen_spielen() - test_flink() - test_waechter() - test_kampf_beidseitig() - test_schild() - test_doppelschlag() - test_einfrieren() - test_effekt_ziel() - test_flaechenschaden() - test_zurueck_auf_hand() - test_buff_heilung() + test_mana_und_kosten() + test_ziehen() + test_wesen_und_angriff() + test_elementvorteil() + test_verdeckt() + test_vier_elemente() + test_verbessern() + test_verschmelzen() test_limits() - test_grenzfaelle() - test_grenzfaelle_2() - test_laden() - test_mana_effekt() test_gleiche_regeln() test_zugreihenfolge() test_sieg_niederlage() test_booster() test_spielstand() - test_resonanz() - test_elementvorteil() - test_feld() - test_vorschau() - test_verschmelzen() - test_feldkombos() - test_schild_effekt_und_sonne() test_kopie() test_ki_lethal() + test_ki_verdeckt_unbekannt() test_ki_besser_als_gierig() test_ki_spiele_enden() test_spielstand_migration() @@ -96,6 +81,8 @@ func _initialize() -> void: 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) @@ -107,10 +94,13 @@ func test_kartenpool() -> void: for e in Cards.ELEMENTS: check(Cards.all().any(func(c): return c.elements == [e]), "Element vorhanden: " + e) for c in Cards.all(): - check(c.health > 0 and c.cost > 0, "Werte " + c.id) - check(C("el_koenig").text() == "Wächter, Flink, Schild. Beim Ausspielen: Alle befreundeten Wesen erhalten +1/+1.", "Kartentext") - check(C("f_ritter").text() == "Flink. Beim Ausspielen: 1 Schaden an ein Ziel.", "Kartentext mit Effekt") - check(C("www_ozean").text() == "Wächter. Beim Ausspielen: Friere alle gegnerischen Wesen ein, Erhalte 3 Mana.", "Ozean-Text") + check(c.health > 0 and c.cost == ceili((c.attack + c.health) / 2.0), "Kosten = (Angriff + Leben) / 2: " + c.id) + check(c.keywords.is_empty() and c.text() == "", "Karten kommen ohne Eigenschaften: " + c.id) + check(C("f_kobold").cost == 2 and C("ff_phoenix").cost == 7 and C("e_wall").cost == 2, "Kostenbeispiele") + var a := C("f_wolf") + var b := C("f_wolf") + a.keywords.append("Schild") + check(b.keywords.is_empty() and Cards.by_id("f_wolf").keywords.is_empty(), "Exemplare teilen sich keine Eigenschaften") func test_kombos() -> void: @@ -118,288 +108,312 @@ func test_kombos() -> void: 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(Cards.combo_name(["Feuer", "Wasser", "Erde"]) == "Chimäre", "drei Elemente sind eine Chimäre") + check(Cards.beaten_by("Feuer") == "Wasser" and Cards.beaten_by("Erde") == "Luft", "wer wen schlägt") -func test_wesen_spielen() -> void: +## Mana: 5 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 g: Game = s[0] + var h: Game.Player = s[1] + h.energy = 0 + h.max_energy = 0 + h.turns = 0 + g.begin_turn(h) + check(h.energy == Game.START_MANA and h.max_energy == Game.START_MANA, "erster Zug: 5 Mana") + h.energy = 3 # Rest + g.begin_turn(h) + check(h.energy == Game.START_MANA + 1, "zweiter Zug: 6 Mana, der Rest ist verfallen") + for i in 20: + 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.can_play(h, h.hand[0])[1] == "Nicht genug Mana", "Drache (6) zu teuer bei %d Mana" % h.energy) + + +## Ziehen: 1 Mana, drei vom gemeinsamen Stapel, eine behalten; der Rest unter den Stapel. +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_OFFER, "Stapel: jede Karte dreimal, zehn schon ausgegeben") + for p in g.players: + check(p.offer.size() == Game.START_OFFER and p.picks_left == Game.START_PICKS and p.hand.is_empty(), "Start: fünf zur Wahl, drei zu nehmen") + check(p.offer.all(func(c): return c.cost <= Game.START_MANA + p.start_bonus), "nur Karten, die im ersten Zug spielbar sind") + var first: Game.Player = g.current() + var deck_before := g.deck.size() + var wanted: Cards.Card = first.offer[4] + check(g.pick(first, 4) == "" and first.hand.size() == 1 and first.hand[0].id == wanted.id and first.hand[0] != wanted, "erste Startkarte genommen, als eigenes Exemplar") + check(first.offer.size() == 4 and first.picks_left == 2, "vier bleiben, zwei noch zu nehmen") + check(g.draw(first) == "Erst wählen" and g.can_play(first, first.hand[0])[1] == "Erst wählen", "bei offener Wahl geht nichts anderes") + g.pick(first, 0) + g.pick(first, 0) + check(first.hand.size() == 3 and first.offer.is_empty() and g.deck.size() == deck_before + 2, "drei behalten, die anderen zwei liegen unter dem Stapel") + var mana := first.energy + check(g.draw(first) == "" and first.energy == mana - 1 and first.offer.size() == 3 and first.picks_left == 1, "gezogen: 1 Mana, drei zur Wahl") + check(first.offer.all(func(c): return c.cost <= first.max_energy), "nur Karten bis zum Mana dieses Zugs") + check(first.offer[0] != first.offer[1] and first.offer[1] != first.offer[2], "drei verschiedene Karten") + check(g.pick(first, 1) == "" and first.hand.size() == 4 and first.offer.is_empty(), "eine genommen, Wahl zu") + check(g.pick(first, 0) == "Keine Wahl offen", "ohne Wahl nichts zu nehmen") + first.energy = 0 + check(g.draw(first) == "Nicht genug Mana", "ohne Mana kein Ziehen") + first.energy = 5 + g.draw(first) + g.pick(first, 0) + check(first.hand.size() == Game.MAX_HAND and g.draw(first) == "Deine Hand ist voll", "fünf Handkarten sind das Limit") + var second: Game.Player = g.enemy_of(first) + check(g.pick(second, 0) == "" and second.hand.size() == 1, "wählen geht auch außerhalb des Zugs") + g.end_turn() # jetzt ist der Zweite dran, seine Wahl ist noch offen + g.end_turn() # sein Zugende nimmt den Rest von vorn + check(second.hand.size() == 3 and second.offer.is_empty(), "Zugende nimmt offene Karten von vorn") + # Leerer Stapel wird neu gemischt + g.deck = [] + var cur: Game.Player = g.current() + cur.energy = 3 + cur.hand.pop_back() # Platz auf der Hand + check(g.draw(cur) == "" and cur.offer.size() == 3 and g.deck.size() > 0 and g.log.any(func(l): return l.begins_with("Der Stapel wird neu gemischt")), "leerer Stapel: neu gemischt") + # Mit viel Mana tauchen auch teure Karten auf; zu teure wandern unter den Stapel + g.pick(cur, 0) + cur.max_energy = 20 + cur.energy = 20 + var expensive_before: int = g.deck.filter(func(c): return c.cost > 8).size() + g.draw(cur) + g.pick(cur, 0) + var bottom: Array = g.deck.slice(0, 5) + check(expensive_before > 0 and (g.deck.size() + 1 + cur.offer.size()) > 0 and bottom.size() == 5, "Stapel bleibt vollständig") + + +func test_wesen_und_angriff() -> void: var s := setup(["f_wolf"]) var g: Game = s[0] var h: Game.Player = s[1] - check(g.play_card(h, C("f_wolf")) == "", "Wolf spielen") - check(h.energy == 10 and h.board.size() == 1, "Ausspielen kostet nichts") - check(not h.board[0].can_attack(), "Erschöpfung") - g.end_turn() - g.end_turn() - check(h.board[0].can_attack(), "bereit im nächsten Zug") - - -func test_flink() -> void: - var s := setup(["l_falke"]) - var g: Game = s[0] - var h: Game.Player = s[1] var o: Game.Player = s[2] - g.play_card(h, C("l_falke")) - check(g.attack(h.board[0], o) == "", "Flink greift sofort an") - check(o.life == 12, "Falke 3 Schaden") + check(g.play_card(h, h.hand[0]) == "" and h.board.size() == 1 and h.board[0].can_attack(), "gespielt und sofort angriffsbereit") + var wolf: Game.Creature = h.board[0] + var e := put(o, "e_wall") # 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") + g.begin_turn(o) + check(e.health == 4, "zu Zugbeginn erholt sich das Wesen seines Besitzers ganz") + g.begin_turn(h) + g.attack(wolf, e) + check(e.health == 1 and o.board.size() == 1, "Angriff: wieder nur 3 Schaden") + e.health = 1 + g.begin_turn(h) + g.attack(wolf, e) + check(o.board.is_empty() and g.log.any(func(l): return l == "Steinwall stirbt."), "Wesen stirbt, Überschuss schlägt durch") + g.begin_turn(h) + var life0 := o.life + check(g.attack(wolf, o) == "" and o.life == life0 - 3, "ohne Wesen trifft es den Helden") + # Gegenschlag + var troll := put(o, "e_troll") # 3/6 + 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") + # Durchschlag: was über das Leben des Wesens hinausgeht, trifft den Besitzer + var drache := put(h, "f_drache") # 6/5 + var kobold := put(o, "f_kobold") # 2/1 + var life := o.life + g.attack(drache, kobold) + check(not (kobold in o.board) and o.life == life - 5 and g.log.any(func(l): return l == "5 Schaden schlagen durch auf Gegner."), "Durchschlag: 6 gegen 1 Leben, 5 gehen durch") + var sch := put(o, "f_kobold") + sch.shield = true + life = o.life + g.begin_turn(h) + g.attack(drache, sch) + check(sch.health == 1 and o.life == life, "Schild blockt alles, nichts schlägt durch") -func test_waechter() -> void: +func test_elementvorteil() -> void: + check(Cards.matchup(["Wasser"], ["Feuer"]) == 1 and Cards.matchup(["Feuer"], ["Wasser"]) == -1 and Cards.matchup(["Feuer"], ["Erde"]) == 0, "Kreis") + var dampf := ["Feuer", "Wasser"] + check(Cards.matchup(dampf, ["Feuer"]) == 1 and Cards.matchup(dampf, ["Luft"]) == 1, "zwei Elemente: Vorteil, wenn eines ihn hat") + check(Cards.matchup(dampf, ["Wasser"]) == 0 and Cards.matchup(dampf, ["Erde"]) == 0, "zwei Elemente: nie im Nachteil gegen ein einzelnes") + check(Cards.matchup(["Erde"], dampf) == 0 and Cards.matchup(["Wasser"], dampf) == 0, "auch als Verteidiger zählt das bessere Element") + check(Cards.matchup(dampf, ["Wasser", "Erde"]) == -1 and Cards.matchup(["Wasser", "Erde"], dampf) == 1, "beide Elemente geschlagen: Nachteil") + check(Cards.matchup(dampf, []) == 0 and Cards.matchup([], dampf) == 0, "unbekannte Elemente: kein Vorteil") var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - var a := put(h, "f_wolf") - var w := put(o, "e_wall") - check(g.attack(a, o) != "", "Wächter blockt Angriff auf Held") - check(g.attack(a, w) == "", "Wächter angreifen") - check(w.health == 1 and a.health == 2, "Schaden verrechnet") + var tiefen := put(h, "w_tiefen") # Wasser 4/6 + var wolf := put(o, "f_wolf") # Feuer 3/2 + var life: int = o.life + g.attack(tiefen, wolf) + check(wolf.health <= 0 and o.life == life - 3 and tiefen.health == 6 - 3, "Wasser gegen Feuer: +1 beim Zuschlagen, der Konter bleibt gleich") + var s3 := setup() + var w3 := put(s3[1], "f_wolf") + var t3 := put(s3[2], "w_tiefen") + s3[0].attack(w3, t3) + check(t3.health == 6 - 2 and w3.health <= 0, "Feuer gegen Wasser: 1 weniger Schaden, der Konter bleibt gleich") + var s2 := setup() + var g2: Game = s2[0] + var geist := put(s2[1], "w_geist") # Wasser 2/2 + g2.attack(geist, s2[2]) + check(s2[2].life == 13, "gegen Helden gibt es keinen Vorteil") -func test_kampf_beidseitig() -> void: +## Verdeckt: greift nicht an, zählt nicht für die Elementregel, wird beim Angriff aufgedeckt. +func test_verdeckt() -> void: + var s := setup(["f_wolf", "f_kobold"]) + 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") + 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") + g.begin_turn(h) + check(not wolf.can_attack() and g.can_attack(wolf, o)[1] == "Flammenwolf 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", true, 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") + 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") + + +## Vier offene Wesen eines Elements: +1/+1 für das Element, -1/-1 für das, das es schlägt. +func test_vier_elemente() -> void: var s := setup() var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - var a := put(h, "f_asche") - var d := put(o, "w_reiter") - g.attack(a, d) - check(not (d in o.board) and not (a in h.board), "beide sterben") - check("Aschekrieger stirbt." in g.log, "Log") + var fire := [] + for i in 3: + fire.append(put(h, "f_wolf")) + var bird := put(o, "l_wind") # Luft 1/1 + g.apply_auras() + check(fire[0].aura == 0 and bird.aura == 0, "drei reichen nicht") + var hidden_fire := put(o, "f_kobold", true, true) + g.apply_auras() + check(fire[0].aura == 0, "verdeckte zählen nicht") + check(g.element_counts()["Feuer"] == 3, "Zähler ohne Verdeckte") + var fourth := put(o, "f_asche") # Feuer beim Gegner zählt auch + g.apply_auras() + check(fire[0].aura == 1 and fire[0].power() == 4 and fire[0].health == 3 and fourth.aura == 1, "vier Feuer: +1/+1 für alle Feuerwesen") + check(bird.aura == -1 and bird.power() == 0 and bird.health == 0, "Luft wird geschlagen: -1/-1") + g.cleanup() + check(not (bird in o.board), "das 1/1 ist daran gestorben") + g.reveal(o, hidden_fire) if g.current() == o else g._flip(hidden_fire) + check(hidden_fire.aura == 1, "aufgedeckt zählt es und bekommt den Bonus sofort") + o.board.erase(fourth) + o.board.erase(hidden_fire) + g.apply_auras() + check(fire[0].aura == 0 and fire[0].health == 2, "Bonus geht wieder, Leben folgt") + # Vorschau + check(g.forecast(h, C("f_kobold")) == 1 and g.forecast(h, C("l_wind")) == 0 and g.forecast(h, C("w_geist")) == 0, "Vorschau: Feuer würde den Stapel voll machen") + # Merkmal: doppelt + h.perks = ["element_doppelt"] + put(h, "f_kobold") + g.apply_auras() + check(fire[0].aura == 2 and fire[0].power() == 5, "Elementbonus zählt doppelt") + check(g.forecast(h, C("l_wind")) == -2, "Vorschau: Luft bekäme jetzt -2/-2") -func test_schild() -> void: - var s := setup() +## Verbessern: Eigenschaften für Mana, je Exemplar. +func test_verbessern() -> void: + var s := setup(["f_wolf"]) var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - var sh := put(o, "ee_bastion") - var a := put(h, "f_asche") - g.attack(a, sh) - check(sh.health == 7 and not sh.shield, "Schild blockt einmal") - var b := put(h, "f_wolf") - g.attack(b, sh) - check(sh.health == 4, "zweiter Schaden zählt") - - -func test_doppelschlag() -> void: - var s := setup() - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - var a := put(h, "l_schlange") - g.attack(a, o) - g.attack(a, o) - check(o.life == 11, "zwei Angriffe") - check(g.attack(a, o) != "", "kein dritter Angriff") - - -func test_einfrieren() -> void: - var s := setup([], ["w_frost"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - var a := put(h, "f_wolf") - g.end_turn() - check(g.play_card(o, C("w_frost"), a) == "", "Frostmagier spielen") - check(a.frozen, "eingefroren") - g.end_turn() - check(not a.frozen and not a.can_attack(), "taut auf, setzt aus") - g.end_turn() - g.end_turn() - check(a.can_attack(), "wieder bereit") - - -func test_effekt_ziel() -> void: - var s := setup(["w_frost", "f_ritter"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - check(g.play_card(h, C("w_frost")) == "" and h.board.size() == 1, "ohne Ziel kommt das Wesen trotzdem, der Effekt verpufft") - g.play_card(h, C("f_ritter"), o) - check(o.life == 14 and h.board.size() == 2, "Ritter macht 1 Schaden an den Helden") - - -func test_flaechenschaden() -> void: - var s := setup(["ff_phoenix"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - put(o, "f_kobold") - put(o, "e_golem") - g.play_card(h, C("ff_phoenix")) - check(o.life == 11, "Held 4 Schaden") - check(o.board.size() == 1 and o.board[0].health == 1, "Wesen getroffen, Kobold tot") - - -func test_zurueck_auf_hand() -> void: - var s := setup(["wl_nebel"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - var d := put(o, "e_troll") - g.play_card(h, C("wl_nebel"), d) - check(o.board.is_empty() and C("e_troll") in o.hand, "zurück auf Hand") - - -func test_buff_heilung() -> void: - var s := setup(["el_koenig", "e_baum"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var a := put(h, "f_wolf") - h.life = 20 h.energy = 20 - g.play_card(h, C("el_koenig")) - check(a.attack == 4 and a.health == 3, "der König verstärkt alle eigenen Wesen um +1/+1") - g.play_card(h, C("e_baum")) - check(h.life == 25, "Heilung") + var card: Cards.Card = h.hand[0] + check(g.upgrade(h, card, "Schild") == "" and h.energy == 20 - 3 and "Schild" in h.hand[0].keywords, "Schild für die Handkarte gekauft") + check(h.hand[0] != card and Cards.by_id("f_wolf").keywords.is_empty(), "eigenes Exemplar, der Pool bleibt sauber") + check(g.upgrade(h, h.hand[0], "Schild") != "", "nicht doppelt") + g.play_card(h, h.hand[0]) + var wolf: Game.Creature = h.board[0] + var after_play := h.energy + check(wolf.shield and wolf.can_attack(), "mit Schild gelegt, greift sofort an") + check(g.upgrade(h, wolf, "Doppelschlag") == "" and wolf.attacks_per_turn() == 2 and wolf.attacks_left == 2 and h.energy == after_play - 4, "Doppelschlag: zwei Angriffe") + check(wolf.card.text() == "Schild, Doppelschlag.", "Kartentext zeigt die Eigenschaften") + check(g.upgrade(h, wolf, "Doppelschlag") != "" and g.upgrade(h, put(o, "e_wall"), "Schild") != "" and g.upgrade(h, wolf, "Flink") != "", "nicht doppelt, nicht fremd, nicht unbekannt") + h.energy = 2 + var k2 := put(h, "f_kobold") + check(g.upgrade(h, k2, "Schild") == "Nicht genug Mana", "zu teuer") + h.perks = ["verbessern_billig"] + check(g.upgrade(h, k2, "Schild") == "" and h.energy == 0, "Merkmal: Eigenschaften kosten 1 weniger") + + +## Verschmelzen: der bessere Wert von beidem plus 1, Kosten 1, 2, 4, 8 … +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 + b.health = 4 + a.card.keywords.append("Schild") + 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") + 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 mit dem besseren Angriff und Leben plus 1") + check("Schild" in f.card.keywords and f.card.fusions == 1 and f.card.cost == Cards.cost_of(5, 7), "Eigenschaften geerbt, einmal verschmolzen") + check(f.card.elements == ["Feuer", "Wasser"], "beide Elemente") + var c := put(h, "e_wall") + check(Game.fusion_cost(f, c) == 2, "zweite Verschmelzung kostet 2") + 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, "Chimäre aus drei Elementen") + var d := put(h, "l_wind") + check(Game.fusion_cost(f2, d) == 4, "dritte kostet 4") + h.energy = 3 + check(g.fuse(h, f2, d) == "Nicht genug Mana", "zu teuer") + check(g.can_fuse(h, f2, f2)[1] == "Zwei verschiedene Wesen wählen", "nicht mit sich selbst") + # Verdeckte verschmelzen offen + var s2 := setup() + var x := put(s2[1], "f_wolf", true, true) + var y := put(s2[1], "f_kobold", true, true) + s2[0].fuse(s2[1], x, y) + check(not s2[1].board[0].hidden, "das Ergebnis liegt offen") + # Elementbonus der Eltern zählt nicht doppelt; Dampf nimmt den besseren Elementwert + 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") + for i in 3: + put(h3, "f_kobold") + g3.apply_auras() + check(fa.aura == 1 and fa.max_health == 3, "vier Feuer vor dem Verschmelzen") + g3.fuse(h3, fa, fb) + var ff: Game.Creature = h3.board[0] + check(ff.aura == 1 and ff.max_health == 8 and ff.health == 8 and ff.power() == 6, "nach dem Verschmelzen zählt der Bonus nur einmal") + for i in 3: + put(h3, "w_geist") + g3.apply_auras() + check(g3.element_counts()["Wasser"] == 4 and ff.aura == 1, "Wasser +1 und Feuer -1: Dampf nimmt den besseren Wert") func test_limits() -> void: - var s := setup(["f_kobold"]) + var s := setup() var g: Game = s[0] var h: Game.Player = s[1] for i in Game.MAX_BOARD: put(h, "f_kobold") - check(g.can_play(h, C("f_kobold"))[1] == "Dein Feld ist voll", "Feld voll") + h.hand = [C("f_kobold")] + check(g.can_play(h, h.hand[0])[1] == "Dein Feld ist voll", "Feld voll") h.hand = [] for i in Game.MAX_HAND: h.hand.append(C("f_kobold")) - h.energy = 10 - h.shop = [C("f_kobold")] - check(g.buy(h, 0) == "Hand voll" and h.hand.size() == Game.MAX_HAND, "volle Hand kauft nicht") - - -## Grenzfälle, die schon einmal falsch waren. -func test_grenzfaelle() -> void: - var s := setup(["f_wolf"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o1: Game.Player = s[2] - # Nur Karten von der eigenen Hand - check(not g.can_play(h, C("e_troll"))[0] and g.play_card(h, C("e_troll")) == "Nicht auf der Hand", "fremde Karte lässt sich nicht spielen") - check(g.play_card(h, C("f_wolf")) == "" and h.board.size() == 1, "eigene Handkarte geht") - # Besiegte Spieler nehmen ihre Wesen mit - var dead := put(o1, "e_troll") - o1.life = 0 - g.cleanup() - check(o1.board.is_empty() and not o1.alive() and g.log.any(func(l): return l.find("verschwindet mit") >= 0), "besiegter Spieler räumt sein Feld") - check(g.enemies_of(h).is_empty(), "Besiegte sind keine Ziele mehr") - check(dead != null, "Wesen war vorher da") - # Verschmolzene Karte auf der Hand übersteht den Spielstand - var a := put(h, "f_kobold") - var b := put(h, "w_geist") - h.energy = 10 - check(g.fuse(h, a, b) == "", "verschmolzen") - var fused: Game.Creature = h.board[h.board.size() - 1] - h.hand.append(fused.card) - var back := NetState.from_snapshot(JSON.parse_string(JSON.stringify(g.snapshot(-1)))) - check(back.players[0].hand.any(func(c): return c.name == fused.card.name), "verschmolzene Karte übersteht die Hand im Spielstand") - - -## Zweite Runde Grenzfälle: Konter ohne Angriff, Chimäre, Ereignisse mit verschmolzener Karte, -## ausgeschiedener Netzwerkspieler mitten im eigenen Zug. -func test_grenzfaelle_2() -> void: - var s := setup() - var g: Game = s[0] - var h: Game.Player = s[1] - var o1: Game.Player = s[2] - # Ein Wesen ohne Angriff schlägt nicht zurück, auch nicht mit Elementvorteil - var wall := put(o1, "e_wall") # Erde 0/4, Erde schlägt Wasser - var reiter := put(h, "w_reiter") # Wasser 3/3 - g.attack(reiter, wall) - check(reiter.health == 3 and wall.health == 4 - 3 + 1, "Steinwall kontert nicht, nimmt aber 1 weniger") - # Drei Elemente ergeben eine Chimäre: erst Feuer und Erde zu Lava, dann Lava und Wasser - var fire := put(h, "f_wolf") - var earth := put(h, "e_kiesel") - h.energy = 10 - check(g.fuse(h, fire, earth) == "", "Wolf und Kiesel verschmelzen") - var lava: Game.Creature = h.board[h.board.size() - 1] - check(lava.name == "Lava" and lava.card.elements == ["Feuer", "Erde"], "zwei Elemente heißen nach ihrer Kombo") - var water := put(h, "w_geist") - check(g.fuse(h, lava, water) == "", "Lava und Flussgeist verschmelzen") - var chimera: Game.Creature = h.board[h.board.size() - 1] - check(chimera.name == "Chimäre" and chimera.card.elements.size() == 3 and chimera.card.combo() == "Chimäre", "drei Elemente heißen Chimäre") - # Ein "play"-Ereignis mit verschmolzener Karte übersteht den Spielstand - h.hand.append(chimera.card) - h.board.erase(chimera) - g.events.clear() - check(g.play_card(h, chimera.card) == "", "Chimäre von der Hand gespielt") - var back := NetState.from_snapshot(JSON.parse_string(JSON.stringify(g.snapshot(-1)))) - check(back.events.any(func(e): return e.type == "play" and e.card.name == "Chimäre"), "Ereignis mit verschmolzener Karte übertragen") - # Netzwerk: wer während des eigenen Zugs ausscheidet, blockiert das Spiel nicht - var net := Net.new() - net.is_host = true - net.game = g - net.rejoin_seconds = 0.0 - net.waiting = {"Spieler": {"seat": g.players.find(h), "since": 0.0}} - o1.is_human = true # ein Duell zweier Menschen - check(g.current() == h, "Spieler ist dran") - net.tick() - check(not h.alive() and h.board.is_empty() and g.winner != "" and g.winner_player == o1, "Ausgeschiedener räumt das Feld, der andere gewinnt") - net.free() - - -## Laden: kaufen kostet Mana, ausspielen nichts, nicht Ausgegebenes bleibt liegen. -func test_laden() -> void: - var s := setup() - var g: Game = s[0] - var h: Game.Player = s[1] - h.max_energy = 3 - g.roll_shop(h) - check(h.shop.size() == Game.SHOP_SIZE, "fünf Angebote") - check(h.shop.all(func(c): return c.cost <= h.max_energy + 1), "nur Karten bis Einkommen plus eins") - for i in 20: # der erste Zug darf nie tot sein - var poor := Game.Player.new("Arm", false, 30) - poor.max_energy = 1 - g.roll_shop(poor) - check(poor.shop.any(func(c): return c.cost <= 1), "immer ein bezahlbares Angebot") - h.energy = 20 - var card: Cards.Card = h.shop[0] - var before: int = h.energy - check(g.buy(h, 0) == "" and h.hand == [card], "gekauft, liegt auf der Hand") - check(h.energy == before - card.cost and h.shop[0] == null, "Mana bezahlt, Platz leer") - check(g.buy(h, 0) != "" and g.buy(h, 99) != "", "leerer und unbekannter Platz") - before = h.energy - var gain: int = 0 # manche Wesen geben beim Ausspielen Mana - for fx in card.effects: - if fx.kind == "mana": - gain += fx.amount - check(g.play_card(h, card) == "" and h.energy == before + gain, "Ausspielen kostet nichts") - before = h.energy - check(g.reroll(h) == "" and h.energy == before - Game.REROLL_COST and h.shop.size() == Game.SHOP_SIZE, "neu gewürfelt") - h.energy = 0 - check(g.reroll(h) != "" and g.buy(h, 0) != "", "ohne Mana geht nichts") - h.energy = 100 - for i in Game.MAX_HAND: - h.hand.append(C("f_kobold")) - check(g.buy(h, 1) != "", "volle Hand kauft nicht") - # Teure Karten tauchen erst mit dem Einkommen auf - h.max_energy = Game.MAX_ENERGY - g.roll_shop(h) - check(DeckGen.pool(1).all(func(c): return c.cost <= 2) and DeckGen.pool(Game.MAX_ENERGY).size() == Cards.all().size(), - "Angebot wächst mit dem Einkommen") - # Nicht ausgegebenes Mana bleibt liegen - var s2 := setup() - var g2: Game = s2[0] - var h2: Game.Player = s2[1] - h2.energy = 0 - h2.max_energy = 0 - g2.begin_turn(h2) - check(h2.energy == 1 and h2.max_energy == 1, "erste Runde: 1 Mana") - g2.begin_turn(h2) - check(h2.energy == 3 and h2.max_energy == 2, "gespartes Mana bleibt liegen") - - -## "Erhalte Mana" statt Nachziehen: Kartenvorteil ist im Laden Manavorteil. -func test_mana_effekt() -> void: - var s := setup(["w_geist"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var before: int = h.energy - g.play_card(h, C("w_geist")) - check(h.energy == before + 1, "Flussgeist gibt 1 Mana") - check(C("w_geist").text() == "Beim Ausspielen: Erhalte 1 Mana.", "Kartentext: " + C("w_geist").text()) - check(Cards.FIELD_COMBO_TEXT["Sturm"] == "Erhalte 1 Mana", "Sturm gibt Mana") - + 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") func test_gleiche_regeln() -> void: @@ -407,18 +421,18 @@ func test_gleiche_regeln() -> void: r.seed = 0 var g := Game.new_battle(r) var first: Game.Player = g.players[0] - check(g.current() == first and first.max_energy == 1 and first.energy == 1, "Startspieler: 1 Mana, kein Bonus") - check(first.shop.size() == Game.SHOP_SIZE and first.hand.is_empty(), "jeder beginnt mit einem Laden und leerer Hand") + check(g.current() == first and first.max_energy == Game.START_MANA and first.energy == Game.START_MANA, "Startspieler: 5 Mana, kein Bonus") + check(first.offer.size() == Game.START_OFFER and first.hand.is_empty(), "jeder beginnt mit einer Wahl aus fünf und leerer Hand") for p in g.players: check(p.life == Game.START_LIFE, "gleiches Leben") if p != first: check(p.start_bonus == 1, "Nachzieher: Bonus offen") g.end_turn() var second: Game.Player = g.players[1] - check(g.current() == second and second.max_energy == 1 and second.energy == 2, "Nachzieher: 1 Mana plus Ausgleich") + check(g.current() == second and second.energy == Game.START_MANA + 1, "Nachzieher: 5 Mana plus Ausgleich") for i in 2: g.end_turn() - check(g.current() == second and second.max_energy == 2 and second.energy == 4, "Ausgleich nur einmal: 2 übrig plus 2 Einkommen") + check(g.current() == second and second.max_energy == Game.START_MANA + 1 and second.energy == Game.START_MANA + 1, "Ausgleich nur einmal, Rest verfällt") func test_zugreihenfolge() -> void: @@ -469,7 +483,6 @@ func test_booster() -> void: check(upgraded.all(func(x): return p.rarity(x.card.id) == x.rarity), "aufgewertete Karten tragen ihre Stufe") check(p.dust == got.reduce(func(a, x): return a + int(x.dust), 0), "der Rest wird zu Staub") check(p.open_booster(r).is_empty(), "ohne Münzen kein Booster") - # Aufwerten und zerlegen p.dust = 100000 check(p.upgrade("f_wolf") == "" and p.level("f_wolf") >= 1, "aufgewertet") var lvl := p.level("f_wolf") @@ -499,6 +512,24 @@ func test_spielstand() -> void: DirAccess.remove_absolute(abs + suffix) +func test_kopie() -> void: + var s := setup(["f_wolf"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2] + var a := put(h, "f_asche") + 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") + g2.attack(a2, g2.players[1]) + 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.upgrade(g2.players[0], a2, "Schild") + check(not a.shield and a.card.keywords.is_empty(), "Verbessern in der Kopie lässt das Original unberührt") + check(g2.log.is_empty() and g2.events.is_empty(), "Kopie schreibt keinen Verlauf") + + func test_ki_lethal() -> void: var s := setup() var g: Game = s[0] @@ -506,11 +537,45 @@ func test_ki_lethal() -> void: var o: Game.Player = s[2] o.life = 3 var a := put(h, "f_asche") - put(o, "w_reiter") var act := AI.choose_action(g, h) check(act.type == "attack" and act.creature == a and act.target == o, "KI nutzt Lethal") +## 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"]: + 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, 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]) + + +func test_ki_besser_als_gierig() -> void: + var r := RandomNumberGenerator.new() + r.seed = 11 + var smart := 0 + var games := 40 + for i in games: + var g := Game.new_battle(r) + var smart_is_human := i % 2 == 0 + var guard := 0 + while g.winner == "" and guard < 300: + var p := g.current() + AI.take_turn(g, p, p.is_human != smart_is_human) + guard += 1 + if (g.winner == "human") == smart_is_human: + smart += 1 + check(smart >= games / 2, "Vorausschau-KI ist mindestens so gut wie die gierige (%d von %d)" % [smart, games]) + + func test_ki_spiele_enden() -> void: var r := RandomNumberGenerator.new() r.seed = 7 @@ -523,286 +588,13 @@ func test_ki_spiele_enden() -> void: check(g.winner != "", "Spiel endet (Partie %d)" % i) -func test_resonanz() -> void: - var s := setup(["f_wolf", "f_kobold", "e_wall"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - g.play_card(h, C("f_wolf")) - check(h.board[0].power() == 3, "allein keine Resonanz") - g.play_card(h, C("f_kobold")) - check(h.board[0].power() == 4 and h.board[1].power() == 3, "Feuer+Feuer: beide +1") - g.play_card(h, C("e_wall")) - check(h.board[2].power() == 0, "Erde allein: kein Bonus") - check(C("f_wolf").text() == "", "Resonanz ist Regel, kein Kartentext") - var third := put(h, "f_asche") - g.update_resonance() - check(h.board[0].power() == 5 and third.power() == 6, "drei Feuer-Wesen: +2") - put(h, "f_kobold") - put(h, "f_kobold") - put(h, "f_kobold") - g.update_resonance() - check(h.board[0].power() == 6 and Game.resonance_for(1) == 1 and Game.resonance_for(3) == 3 and Game.resonance_for(4) == 3, "Resonanz höchstens +3") - for i in [1, 3, 4, 5, 6]: - h.board[i].health = 0 - g.cleanup() - check(h.board[0].power() == 3, "Resonanz sinkt mit den Partnern") - - -## Elementkreis: Wasser > Feuer > Luft > Erde > Wasser, im Vorteil +1 Schaden, im Nachteil -1. -func test_elementvorteil() -> void: - check(Cards.matchup(["Wasser"], ["Feuer"]) == 1 and Cards.matchup(["Feuer"], ["Wasser"]) == -1, "Wasser schlägt Feuer") - check(Cards.matchup(["Feuer"], ["Luft"]) == 1 and Cards.matchup(["Luft"], ["Erde"]) == 1 and Cards.matchup(["Erde"], ["Wasser"]) == 1, "der Kreis schließt sich") - check(Cards.matchup(["Feuer"], ["Feuer"]) == 0 and Cards.matchup(["Feuer"], ["Erde"]) == 0, "gleiche und unbeteiligte Elemente: kein Vorteil") - check(Cards.matchup(["Feuer", "Wasser"], ["Feuer"]) == 1, "Mehrfachelemente nehmen den besseren Wert") - check(Cards.matchup([], ["Feuer"]) == 0 and Cards.matchup(["Feuer"], []) == 0, "ohne Element kein Vorteil") - var s := setup(["f_ritter"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - var mine := put(h, "w_reiter") # Wasser 3/3 - var his := put(o, "f_wolf") # Feuer 3/2 - g.attack(mine, his) - check(his.health == -2 and mine.health == 1, "im Vorteil: 1 mehr ausgeteilt, 1 weniger eingesteckt") - check(Game.matchup_bonus(mine, o) == 0, "gegen Helden gibt es keinen Vorteil") - var life := o.life - g.play_card(h, C("f_ritter"), o) # Feuerwesen, 1 Schaden an den Helden - check(o.life == life - 1, "Effekte auf Helden bleiben unverändert") - s = setup(["f_ritter"]) - g = s[0] - h = s[1] - o = s[2] - var air := put(o, "l_laeufer") # Luft 3/4, Feuer schlägt Luft - g.play_card(h, C("f_ritter"), air) - check(air.health == 2, "Feuereffekt gegen Luft: 1 Schaden mehr") - - -## Das Feld gehört allen am Tisch und kippt, sobald ein Element genug Karten gesehen hat. -func test_feld() -> void: - var s := setup(["f_ritter"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - check(g.field_element == "" and int(g.field_charge["Feuer"]) == 0, "das Feld beginnt neutral") - var wolf := put(h, "f_wolf") - for i in Cards.FIELD_CHARGE - 1: - g.charge_field(C("f_kobold")) - check(g.field_element == "", "drei Karten reichen noch nicht") - g.charge_field(C("f_kobold")) - g.update_resonance() - check(g.field_element == "Feuer" and int(g.field_charge["Feuer"]) == 0, "die vierte Feuerkarte kippt das Feld, die Zähler beginnen von vorn") - check(g.log.any(func(l): return l.begins_with("Das Feld wird zu Vulkan")), "Feldwechsel im Verlauf") - check(wolf.field_bonus == 1 and wolf.power() == 4, "Feuerwesen bekommen +1 Angriff") - var stone := put(h, "e_wall") - var his := put(o, "f_kobold") - g.update_resonance() - check(stone.field_bonus == 0, "andere Elemente bleiben unberührt") - check(his.field_bonus == 1, "das Feld gilt auch für die Gegner") - var before := int(g.field_charge["Feuer"]) - g.play_card(h, C("f_ritter"), o) - check(int(g.field_charge["Feuer"]) == before + 1, "jede gespielte Karte lädt ihr Element") - for i in Cards.FIELD_CHARGE: - g.charge_field(C("w_reiter")) - check(g.field_element == "Wasser" and wolf.field_bonus == 0, "das Feld kippt weiter und nimmt den alten Bonus mit") - - -## Verschmelzen: zwei eigene Wesen werden eins, Werte Durchschnitt plus 1. -## Vorschau fürs Ausspielen: Resonanz, Feldbonus und Feldkombo mit Partner. -func test_vorschau() -> void: - var s := setup(["f_kobold", "e_wall"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var f := g.forecast(h, C("f_kobold")) - check(f.resonance == 0 and f.field == 0 and f.combo == "" and f.partner == null, "leeres Feld: keine Vorschau") - var wolf := put(h, "f_wolf") - put(h, "f_asche") - var reiter := put(h, "w_reiter") - f = g.forecast(h, C("f_kobold")) - check(f.resonance == 2 and f.combo == "Dampf" and f.partner == reiter, "zwei Feuerpartner geben +2, das Wasserwesen die Kombo Dampf") - f = g.forecast(h, C("e_wall")) - check(f.resonance == 0 and f.combo == "Lava" and f.partner == wolf, "Erde: kein Partner, Kombo Lava mit dem ersten Feuerwesen") - g.field_element = "Feuer" - check(g.forecast(h, C("f_kobold")).field == 1 and g.forecast(h, C("e_wall")).field == 0, "der Feldbonus zählt nur fürs eigene Element") - h.perks.append("resonanz_doppelt") - check(g.forecast(h, C("f_kobold")).resonance == 4, "doppelte Resonanz verdoppelt die Vorschau") - var real := g.play_card(h, C("f_kobold")) - var kobold: Game.Creature = h.board[-1] - check(real == "" and kobold.power() == 2 + 4 + 1, "das Ausspielen bringt genau den vorhergesagten Angriff") - - -func test_verschmelzen() -> void: - var s := setup() - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - var a := put(h, "f_wolf") # Feuer 3/2, Kosten 2 - var b := put(h, "w_reiter") # Wasser 3/3, Kosten 2 - check(Cards.fusion_cost(a.card, b.card) == 2 and Cards.fusion_cost(C("f_kobold"), C("f_asche")) == 2, "Kosten: Durchschnitt, aufgerundet") - check(g.can_fuse(h, a, a)[0] == false and g.can_fuse(h, a, put(o, "f_kobold"))[0] == false, "nur zwei verschiedene eigene Wesen") - h.energy = 1 - check(g.can_fuse(h, a, b)[0] == false, "zu wenig Mana") - h.energy = 5 - check(g.fuse(h, a, b) == "" and h.board.size() == 1, "aus zwei Wesen wird eines") - var cr: Game.Creature = h.board[0] - check(h.energy == 3, "die Verschmelzung kostet Mana") - check(cr.card.elements == ["Feuer", "Wasser"], "es erbt beide Elemente") - check(cr.name == "Dampf", "es heißt nach seiner Kombo") - check(cr.attack == 4 and cr.health == 3 and cr.max_health == 3, "Werte: Durchschnitt plus 1") - check(g.log.any(func(l): return l.find("verschmelzen zu Dampf") >= 0), "Verschmelzung im Verlauf") - # Schlüsselwörter beider Karten wandern mit - s = setup() - g = s[0] - h = s[1] - h.energy = 10 - var wall := put(h, "e_wall") # Erde 0/4, Wächter - var wind := put(h, "l_wind") # Luft 1/1, Flink - check(g.fuse(h, wall, wind) == "", "Erde und Luft verschmelzen") - var sand: Game.Creature = h.board[0] - check(sand.name == "Sand" and sand.taunt() and "Flink" in sand.card.keywords, "Wächter und Flink bleiben erhalten") - check(sand.attack == 1 and sand.health == 3, "0/4 und 1/1 werden 1/3") - # Das verschmolzene Wesen übersteht Speichern und Netzwerk - var back := NetState.from_snapshot(NetState.snapshot(g, -1)) - var copy: Game.Creature = back.players[0].board[0] - check(copy.name == "Sand" and copy.attack == 1 and copy.health == 3 and copy.taunt(), "verschmolzene Karte übersteht den Snapshot") - # Platz auf dem Feld: fünf Wesen, verschmelzen macht einen Platz frei - s = setup(["f_kobold"]) - g = s[0] - h = s[1] - h.energy = 10 - for i in Game.MAX_BOARD: - put(h, "f_kobold") - check(g.can_play(h, C("f_kobold"))[0] == false, "das Feld ist voll") - check(g.fuse(h, h.board[0], h.board[1]) == "" and g.can_play(h, C("f_kobold"))[0], "verschmelzen macht Platz") - - -func test_feldkombos() -> void: - # Dampf: Feuer neben Wasser friert das stärkste gegnerische Wesen ein - var s := setup(["w_reiter"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - put(h, "f_wolf") - var weak := put(o, "f_kobold") - var strong := put(o, "e_troll") - g.play_card(h, C("w_reiter")) - check(strong.frozen and not weak.frozen, "Dampf friert das stärkste Wesen") - check(g.log.any(func(l): return l.begins_with("Feldkombo Dampf")), "Feldkombo im Verlauf") - # Lava: 1 Schaden an das stärkste gegnerische Wesen - s = setup(["e_golem"]) - g = s[0] - h = s[1] - o = s[2] - put(h, "f_wolf") - var k := put(o, "f_kobold") - var big := put(o, "e_troll") - g.play_card(h, C("e_golem")) - check(k in o.board and big.health == 5, "Lava trifft nur das stärkste Wesen") - # Blitz: 2 Schaden an den Gegner - s = setup(["l_wind"]) - g = s[0] - h = s[1] - put(h, "f_wolf") - g.play_card(h, C("l_wind")) - check(s[2].life == 13, "Blitz trifft den Gegner") - # Schlamm heilt, Sturm gibt Mana, Sand gibt +1/+1 - s = setup(["e_wall", "l_wind", "w_reiter"]) - g = s[0] - h = s[1] - h.life = 20 - g.play_card(h, C("w_reiter")) - g.play_card(h, C("e_wall")) - check(h.life == 23, "Schlamm heilt um 3") - var mana_before := h.energy - g.play_card(h, C("l_wind")) - check(h.energy == mana_before + 1, "Sturm gibt 1 Mana") - check(h.board[2].attack == 1, "nur eine Feldkombo je Ausspielen") - s = setup(["l_wind"]) - g = s[0] - h = s[1] - put(h, "e_wall") - g.play_card(h, C("l_wind")) - check(h.board[1].attack == 2 and h.board[1].health == 2, "Sand: neues Wesen erhält +1/+1") - # Gleiche Elemente lösen keine Feldkombo aus - s = setup(["f_kobold"]) - g = s[0] - h = s[1] - put(h, "f_wolf") - g.play_card(h, C("f_kobold")) - check(not g.log.any(func(l): return l.begins_with("Feldkombo")), "Feuer+Feuer ist Resonanz, keine Feldkombo") - # Ein Feuerwesen neben Wasser löst Dampf aus - s = setup(["fe_golem"]) - g = s[0] - h = s[1] - o = s[2] - put(h, "w_reiter") - var t := put(o, "e_troll") - g.play_card(h, C("fe_golem")) - check(t.frozen, "Lava-Wesen neben Wasser löst Dampf aus") - - -func test_schild_effekt_und_sonne() -> void: - var s := setup(["el_duenen", "fff_sonne"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - var a := put(h, "f_wolf") - g.play_card(h, C("el_duenen"), a) - check(a.shield, "Dünenläufer gibt Schild") - # Frischer Tisch: die Sonne soll allein wirken, ohne Feldkombo mit eigenen Wesen - s = setup(["fff_sonne"]) - g = s[0] - h = s[1] - o = s[2] - check(C("fff_sonne").elements == ["Feuer"] and C("fff_sonne").cost == 9, "Kern der Sonne ist ein teures Feuerwesen") - var troll := put(o, "e_troll") - g.play_card(h, C("fff_sonne")) - check(o.life == 10 and troll.health == 1, "Kern der Sonne: 5 Schaden an alle") - - -func test_kopie() -> void: - var s := setup(["f_wolf"]) - var g: Game = s[0] - var h: Game.Player = s[1] - var o: Game.Player = s[2] - var a := put(h, "f_asche") - 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") - g2.attack(a2, g2.players[1]) - check(g2.players[1].life == 11 and o.life == 15, "Kopie verändert das Original nicht") - g2.play_card(g2.players[0], C("f_wolf")) - check(h.hand.size() == 1 and g2.players[0].hand.is_empty(), "Hand der Kopie unabhängig") - check(g2.log.is_empty() and g2.events.is_empty(), "Kopie schreibt keinen Verlauf") - - -func test_ki_besser_als_gierig() -> void: - var r := RandomNumberGenerator.new() - r.seed = 11 - var smart := 0 - var games := 40 - for i in games: - var pool: Array = Cards.ELEMENTS.duplicate() - Game.shuffle(pool, r) - var g := Game.new_battle(r) - # abwechselnd beginnt die schlaue oder die gierige KI - var smart_is_human := i % 2 == 0 - var guard := 0 - while g.winner == "" and guard < 300: - var p := g.current() - AI.take_turn(g, p, p.is_human != smart_is_human) - guard += 1 - if (g.winner == "human") == smart_is_human: - smart += 1 - check(smart >= games / 2, "Vorausschau-KI ist mindestens so gut wie die gierige (%d von %d)" % [smart, games]) - - ## Alte Stände behalten Münzen, Staub, Kampagne und Einstellungen; die alte Sammlung entfällt. func test_spielstand_migration() -> void: var alt := {"version": 5, "coins": 300, "dust": 70, "campaign_stage": 2, - "collection": {"f_kobold": 2}, "decks": [], "settings": {"volume": 0.3}} + "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(), "Sammlung auf Standard") + check(p.collection.is_empty() and p.perk == "", "Sammlung auf Standard, unbekanntes Merkmal verworfen") check(is_equal_approx(p.settings.volume, 0.3) and p.settings.animations, "Einstellungen übernommen") p.collection["f_wolf"] = 3 var d := p.to_dict() @@ -816,8 +608,7 @@ func test_kampagne() -> void: r.seed = 4 check(Campaign.STAGES.size() == 10, "zehn Stufen") var boss := Campaign.opponent(4) - check(boss.name == "Vulkar" and boss.title == "Herr der Lava" and boss.life == 32, "Stufe 5: Vulkar mit 32 Leben") - check(boss.elements == ["Feuer", "Erde"] and boss.shop.all(func(c): return c.elements.all(func(e): return e in ["Feuer", "Erde"])), "Boss handelt nur mit seinen Elementen") + check(boss.name == "Vulkar" and boss.title == "Herr der Lava" and boss.life == 24, "Stufe 5: Vulkar mit 24 Leben") 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() @@ -873,42 +664,39 @@ func test_spielstand_sicherung() -> void: func test_merkmale() -> void: - var s := setup(["f_wolf", "l_falke", "e_wall"]) + var s := setup(["f_wolf", "l_falke"]) var g: Game = s[0] var h: Game.Player = s[1] - var o: Game.Player = s[2] - h.perks = ["feuer_angriff", "flink_angriff", "waechter_leben", "schild_wesen", "alle_flink", "resonanz_doppelt"] - g.play_card(h, C("f_wolf")) + h.perks = ["feuer_angriff", "wesen_angriff", "wesen_leben", "schild_wesen", "ziehen_gratis"] + g.play_card(h, h.hand[0]) var wolf: Game.Creature = h.board[0] - check(wolf.power() == 4 and wolf.shield and wolf.can_attack(), "Feuer +1, Schild, sofort bereit") - g.play_card(h, C("l_falke")) - check(h.board[1].power() == 4, "Flink +1") - g.play_card(h, C("e_wall")) - check(h.board[2].health == 6 and h.board[2].max_health == 6, "Wächter +2 Leben") - put(h, "f_kobold") - g.update_resonance() - check(wolf.resonance == 2 and wolf.power() == 6, "Resonanz doppelt") + check(wolf.power() == 3 + 1 + 1 and wolf.health == 3 and wolf.shield and wolf.can_attack(), "Feuer +1, Wesen +1/+1, Schild") + g.play_card(h, h.hand[0]) + check(h.board[1].power() == 3 + 1, "Luft: nur Wesen +1") + g.deck = DeckGen.deck(g.rng) + var mana := h.energy + check(g.draw(h) == "" and h.energy == mana, "Merkmal: Ziehen kostet nichts") + g.pick(h, 0) 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 - check(mara.life == 35 and mara.max_life == 35 and mara.perks == ["leben_35"], "Mara hat mit ihrem Merkmal 35 Leben") + 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 archi: Game.Player = g3.opponent check(g3.players[0] == g3.human, "feste Reihenfolge: Mensch beginnt") g3.end_turn() - check(g3.current() == archi and archi.energy == 3 + 1, "Archimagier beginnt mit 3 Mana (plus Ausgleich)") + check(g3.current() == archi and archi.energy == Game.START_MANA + 2 + 1, "Archimagier hat im ersten Zug 2 Mana mehr (plus Ausgleich)") var nyx: Game.Player = Campaign.opponent(5) var g4 := Game.new_battle_with(nyx, r, true) g4.end_turn() - # Einkommen 1, dazu 1 Mana für ihr Merkmal und 1 als Ausgleich für den späteren Start - check(nyx.max_energy == 1 and nyx.energy == 3, "Nyx bekommt 1 Mana mehr als ihr Einkommen") + check(nyx.max_energy == Game.START_MANA and nyx.energy == Game.START_MANA + 1 + 1, "Nyx bekommt 1 Mana mehr je Zug") func test_ki_zwei_schritte() -> void: - # Gegner hat 5 Leben, das eigene Wesen kann noch nicht angreifen, der Wüstenkönig (flink, 5 Angriff) - # liegt auf der Hand: erst ausspielen, dann mit ihm töten. + # Gegner hat 5 Leben und kein Wesen; der Wüstenkönig (5 Angriff) liegt auf der Hand: + # erst ausspielen, dann mit ihm töten. var s := setup(["el_koenig"]) var g: Game = s[0] var h: Game.Player = s[1] @@ -917,7 +705,7 @@ func test_ki_zwei_schritte() -> void: o.life = 5 put(h, "f_wolf", false) var act := AI.choose_action(g, h) - check(act.type == "play" and act.card == C("el_koenig"), "KI spielt zuerst den König") + check(act.type == "play" and act.card == h.hand[0] and not act.face_down, "KI spielt zuerst den König, offen") AI.apply_action(g, h, act) var act2 := AI.choose_action(g, h) check(act2.type == "attack" and act2.target == o, "dann tödlicher Angriff") @@ -930,23 +718,28 @@ func test_snapshot() -> void: var g: Game = s[0] var h: Game.Player = s[1] var o: Game.Player = s[2] - g.play_card(h, C("f_wolf")) + g.upgrade(h, h.hand[0], "Schild") + g.play_card(h, h.hand[0]) put(o, "e_troll") var hi := g.players.find(h) var oi := g.players.find(o) - g.roll_shop(h) - g.roll_shop(o) + h.energy = 5 + o.energy = 5 + g.deck = DeckGen.deck(g.rng) + g.draw(h) + o.offer = g.draw_cards(3, 5) + o.picks_left = 1 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, "Grunddaten") + 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.is_empty(), "eigene Hand sichtbar") - check(g2.players[oi].hand.size() == 1 and g2.players[oi].hand[0] != C("w_frost"), "fremde Hand nur als Anzahl") - check(g2.players[oi].shop.is_empty() and g2.players[hi].shop.size() == Game.SHOP_SIZE, "eigener Laden sichtbar, fremder nicht") + check(g2.players[oi].hand.size() == 1 and g2.players[oi].hand[0].id != "w_frost", "fremde Hand nur als Anzahl") + check(g2.players[oi].offer.is_empty() and g2.players[hi].offer.size() == Game.OFFER_SIZE and g2.players[hi].picks_left == 1, "eigene Wahl sichtbar, fremde nicht") var cr2 := g2.find_creature(h.board[0].uid) - check(cr2 != null and cr2.card.id == "f_wolf" and cr2.owner() == g2.players[hi] and not cr2.ready, "Wesen mit Besitzer und Zustand") + check(cr2 != null and cr2.card.id == "f_wolf" and cr2.owner() == g2.players[hi] and cr2.shield and "Schild" in cr2.card.keywords, "Wesen mit Besitzer, Zustand und gekaufter Eigenschaft") check(g2.events.size() == g.events.size() and g2.events.back().type == g.events.back().type, "Ereignisse übertragen") - check(g2.valid_targets(g2.players[hi], "enemy_creature").size() == 1 and g2.buy(g2.players[oi], 0) == "Nicht dein Zug", "Kopie kennt die Regeln") + 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") @@ -954,16 +747,13 @@ func test_snapshot() -> void: 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") - for st in Campaign.STAGES3: - for pk in st.perks: - check(Game.PERKS.has(pk), "Merkmal %s bekannt" % pk) for i in Campaign.total(): var st := Campaign.stage(i) 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) - check(archi2.name == "Archimagier" and archi2.perks.size() == 3 and archi2.life == 34, "letzte Stufe von Kapitel 2: Archimagier mit drei Merkmalen und 34 Leben") + 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") var p := Profile.starter() check(not p.perk_unlocked(), "Merkmalwahl erst nach Kapitel 1") p.campaign_stage = 10 @@ -983,10 +773,12 @@ func test_ki_zeitbudget() -> void: r.seed = 12 var g := Game.new_battle(r) var h: Game.Player = g.players[0] - h.energy = 10 - h.max_energy = 10 - for i in 8: - h.hand.append(Cards.by_id("f_ritter")) + for p in g.players: + p.offer = [] + p.picks_left = 0 + h.energy = 30 + for i in Game.MAX_HAND: + h.hand.append(C("f_ritter")) for p in g.players: for i in 3: put(p, "e_troll") @@ -995,31 +787,27 @@ func test_ki_zeitbudget() -> void: var act := AI.choose_action(g, h) var ms := Time.get_ticks_msec() - t0 AI.time_budget_ms = 150 - check(act.type in ["play", "attack", "end"] and ms < 80, "Zeitbudget greift (%d ms)" % ms) - check(Cards.by_id("wl_gischt").keywords == ["Flink"] and Cards.by_id("wl_gischt").elements == ["Wasser"], "Gischtläufer ist Wasser") + check(act.type in ["play", "attack", "upgrade", "fuse", "draw", "end"] and ms < 80, "Zeitbudget greift (%d ms)" % ms) var v := Profile.from_dict(JSON.parse_string(JSON.stringify(Profile.starter().to_dict()))) check(v.settings.has("big_font") and v.coins == Profile.START_COINS, "Starterstand gelesen") -## Englisch: jede Karte hat einen englischen Namen, Kartentexte und Schlüsselwörter werden übersetzt. +## Englisch: jede Karte hat einen englischen Namen, gekaufte Eigenschaften werden übersetzt. func test_englische_karten() -> void: check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen") - var de: String = Cards.by_id("f_ritter").text() + var ritter := C("f_ritter") + ritter.keywords = ["Schild", "Doppelschlag"] TranslationServer.set_locale("en") - var ritter := Cards.by_id("f_ritter") - var wall := Cards.by_id("e_wall") - check(ritter.title() == "Blazing Knight" and ritter.text() == "Swift. On play: 1 damage to a target.", "Ritter auf Englisch: %s / %s" % [ritter.title(), ritter.text()]) - check(wall.text() == "Guardian.", "Schlüsselwort auf Englisch") + check(ritter.title() == "Blazing Knight" and ritter.text() == "Shield, Double Strike.", "Ritter auf Englisch: %s / %s" % [ritter.title(), ritter.text()]) TranslationServer.set_locale("de") - check(ritter.title() == "Lodernder Ritter" and ritter.text() == de, "zurück auf Deutsch") + check(ritter.title() == "Lodernder Ritter" and ritter.text() == "Schild, Doppelschlag.", "zurück auf Deutsch") -## Voller Spielstand (viewer -1) stellt Hände, Decks und Bretter wieder her; Zuschauer (-2) sehen nichts Verborgenes. +## 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) - g.start() AI.take_turn(g, g.current()) var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1))) var g2 := Game.from_snapshot(d) @@ -1030,14 +818,15 @@ func test_kampf_speichern() -> void: 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, Decks und Bretter erhalten") + 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) and p.shop.is_empty()), "Zuschauer-Sicht ohne Hände und Läden") + check(s2.players.all(func(p): return not (p.hand is Array) and p.offer.is_empty()), "Zuschauer-Sicht ohne Hände und Wahlen") ## Jeder tr("...")-Text in der Oberfläche hat einen englischen Eintrag. @@ -1054,7 +843,7 @@ func test_uebersetzung_vollstaendig() -> void: key = line.substr(0, line.find(",")) keys[key] = true var missing := [] - for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + Cards.FIELD_NAMES.values() + Cards.FIELD_TEXT.values(): + for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + Cards.KEYWORD_TEXT.values() + Game.PERKS.values(): if not keys.has(k): missing.append(k) var d := DirAccess.open("res://scripts/ui") diff --git a/tests/ui_test.gd b/tests/ui_test.gd index 2a0ab8e..052d976 100644 --- a/tests/ui_test.gd +++ b/tests/ui_test.gd @@ -51,9 +51,13 @@ func click(node: Control) -> void: func right_click() -> void: + await right_click_at(Vector2(5, 5)) + + +func right_click_at(pos: Vector2) -> void: var down := InputEventMouseButton.new() - down.position = Vector2(5, 5) - down.global_position = down.position + down.position = pos + down.global_position = pos down.button_index = MOUSE_BUTTON_RIGHT down.pressed = true get_viewport().push_input(down) @@ -149,7 +153,7 @@ func _ready() -> void: await frames(3) var fast := OS.get_environment("DINGER_FAST") != "" var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen, - test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_feld_und_verschmelzen, test_kampf_grenzen] + test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen] if not fast: suite += [test_kampf.bind(3), test_kampf_abbrechen] # Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten. @@ -215,18 +219,25 @@ func target_view(scr, valid: Array) -> Control: return null -func human_turn(scr) -> void: +## Nimmt alle offenen Wahlen des Spielers (immer die erste Karte), auch über das Netz. +func pick_all(scr) -> void: + var guard := 0 + while scr.game != null and not scr.perspective.offer.is_empty() and guard < 60: + guard += 1 + if scr.pick_views.is_empty(): + await frames(1) + continue + var n: int = scr.perspective.offer.size() + await click(scr.pick_views[0]) + var w := 0 + while scr.game != null and scr.perspective.offer.size() == n and w < 120: + await frames(1) + w += 1 + + +## Legt, was bezahlbar ist. +func play_hand(scr) -> void: var g: Game = scr.game - # erst kaufen, solange Mana und Handplatz reichen - for i in 12: - var offers: Array = scr.shop_views.filter(func(v): return g.human.energy >= v.card.cost and g.human.hand.size() < Game.MAX_HAND) - if offers.is_empty(): - break - var offer: Cards.Card = offers[0].card - var mana: int = g.human.energy - await click(offers[0]) - check(g.human.energy == mana - offer.cost, "gekauft: " + offer.name) - # dann ausspielen, das kostet nichts for i in 12: var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0]) if playable.is_empty(): @@ -234,9 +245,22 @@ func human_turn(scr) -> void: var card: Cards.Card = playable[0].card var board_before: int = g.human.board.size() await click(playable[0]) - if scr.pending != null: - await click(target_view(scr, g.valid_targets(g.human, card.target_type()))) check(g.human.board.size() > board_before, "Karte gespielt: " + card.name) + + +func human_turn(scr) -> void: + var g: Game = scr.game + # erst legen, was bezahlbar ist, dann mit dem Rest ziehen (und wieder legen) + for round in 2: + await play_hand(scr) + for i in 6: + if g.winner != "" or not g.can_draw(g.human)[0]: + break + var mana: int = g.human.energy + await click(scr.deck) + check(g.human.energy == mana - Game.DRAW_COST and not scr.pick_views.is_empty(), "gezogen: drei zur Wahl") + await pick_all(scr) + check(scr.pick_views.is_empty(), "gewählt") for i in 12: var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack()) if ready.is_empty() or g.winner != "": @@ -249,7 +273,7 @@ func human_turn(scr) -> void: if scr.selected != cr: print("DEBUG Auswahl fehlgeschlagen: rect=", ready[0].get_global_rect(), " scale=", ready[0].scale, " busy=", ready[0].busy, " unter Maus=", under, " Meldung='", scr.msg_label.text, - "' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " pending=", scr.pending, " selected=", scr.selected) + "' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " selected=", scr.selected) check(scr.selected == cr, "Wesen gewählt") var targets := [] for o in [g.opponent]: @@ -269,6 +293,7 @@ func start_battle() -> void: await click(find_button("Einzelspieler")) check(screen() is OpponentSelectScreen, "Gegnerauswahl") await click(find_button("Starten")) + await pick_all(screen()) func test_gegnerauswahl() -> void: @@ -285,8 +310,12 @@ func test_gegnerauswahl() -> void: check(hard is BattleScreen and hard.game.players.size() == 2, "Duell gestartet") var opp: Game.Player = hard.game.opponent check(opp.max_life == Profile.DIFFICULTIES.schwer.life and hard.smart_ai, "Schwer: Gegner mit %d Leben, Vorausschau-KI" % Profile.DIFFICULTIES.schwer.life) - check(hard.game.human.shop.size() == Game.SHOP_SIZE and hard.game.human.hand.is_empty(), - "Start mit vollem Laden und leerer Hand") + check(hard.game.human.offer.size() == Game.START_OFFER and hard.game.human.hand.is_empty() and hard.pick_layer.visible, + "Start: fünf Karten zur Wahl, Hand leer") + check(hard.game.human.offer.all(func(c): return c.cost <= Game.START_MANA + 1), "alle fünf sind im ersten Zug spielbar") + await shot("05_startwahl") + await pick_all(hard) + check(hard.game.human.hand.size() == Game.START_PICKS and not hard.pick_layer.visible, "drei behalten, Wahl geschlossen") check(hard.hero_panels[opp].position == BattleLayout.OPP_BOX.position, "der Gegner steht oben in der Mitte") await shot("11_kampf") await give_up() @@ -294,9 +323,10 @@ func test_gegnerauswahl() -> void: await click(find_button("Leicht")) await click(find_button("Starten")) var easy = screen() + await pick_all(easy) opp = easy.game.opponent check(opp.max_life == Profile.DIFFICULTIES.leicht.life and not easy.smart_ai, "Leicht: Gegner mit %d Leben, einfache KI" % Profile.DIFFICULTIES.leicht.life) - check(easy.game.players[0].max_energy == 1 and easy.game.human.life == Game.START_LIFE, "gleiche Regeln für beide") + check(easy.game.players[0].max_energy == Game.START_MANA and easy.game.human.life == Game.START_LIFE, "gleiche Regeln für beide") await give_up() await click(find_button("Einzelspieler")) await click(find_button("Normal")) @@ -457,39 +487,47 @@ func test_kampf_steuerung() -> void: scr.ai_delay = 0.0 await wait_for_human(scr) var g: Game = scr.game - g.human.energy = 20 # das Angebot ist zufällig, hier soll nur die Steuerung geprüft werden + g.human.energy = 20 # die Wahl ist zufällig, hier soll nur die Steuerung geprüft werden + g.human.hand = [Cards.by_id("f_wolf")] scr.refresh() - check(scr.shop_views.size() == Game.SHOP_SIZE and g.human.hand.is_empty(), "voller Laden, leere Hand") - # Kaufen: Mana weg, Karte auf der Hand - var offer: Cards.Card = scr.shop_views[0].card + check(scr.hand_views.size() == 1 and scr.deck.enabled and scr.pick_views.is_empty(), "eine Handkarte, Stapel bereit, keine Wahl offen") + # Ziehen: 1 Mana weg, drei zur Wahl, eine landet auf der Hand var mana: int = g.human.energy - await click(scr.shop_views[0]) - check(g.human.hand.size() == 1 and g.human.hand[0] == offer and g.human.energy == mana - offer.cost, "Angebot gekauft") - # Ausspielen kostet nichts + await click(scr.deck) + check(g.human.energy == mana - Game.DRAW_COST and scr.pick_views.size() == Game.OFFER_SIZE and scr.pick_layer.visible, "gezogen: drei Karten zur Wahl") + var wanted: Cards.Card = scr.pick_views[1].card + await click(scr.pick_views[1]) + check(g.human.hand.size() == 2 and g.human.hand[1].id == wanted.id and not scr.pick_layer.visible, "gewählt, liegt auf der Hand") + # Ausspielen kostet die Kartenkosten var board_before: int = g.human.board.size() mana = g.human.energy await key(KEY_1) - check(g.human.board.size() == board_before + 1 and g.human.energy == mana, "Wesen per Taste gespielt, ohne Mana") - # Rückgängig: Wesen zurück auf die Hand + check(g.human.board.size() == board_before + 1 and g.human.energy == mana - Cards.by_id("f_wolf").cost, "Wesen per Taste gespielt, Kosten bezahlt") + # 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 == offer.id), "Rückgängig stellt die Hand her") + 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(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht") await key(KEY_1) check(g.human.board.size() == board_before + 1, "erneut gespielt") - # Neu würfeln kostet Mana - var shop_before: Array = g.human.shop.duplicate() + # Ziehen per Taste mana = g.human.energy - await click(scr.b_reroll) - check(g.human.energy == mana - Game.REROLL_COST and g.human.shop != shop_before, "Laden neu gewürfelt") + await key(KEY_Z) + check(g.human.energy == mana - Game.DRAW_COST and scr.pick_layer.visible, "Z zieht") + await key(KEY_1) + check(not scr.pick_layer.visible, "1 wählt die erste Karte") await shot("22_kampf_steuerung") - # Gamepad: Zeiger im Laden bewegen, A kauft - await pad(JOY_BUTTON_DPAD_RIGHT) - check(scr.gp_zone == "shop" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweitem Angebot") + # Gamepad: A auf dem Stapel zieht, Steuerkreuz wählt die zweite Karte, A nimmt sie + await pad(JOY_BUTTON_DPAD_LEFT) + check(scr.gp_zone == "deck" and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf dem Stapel") var hand_before: int = g.human.hand.size() await pad(JOY_BUTTON_A) - check(g.human.hand.size() == hand_before + 1, "Gamepad A kauft das Angebot") + check(scr.pick_layer.visible, "Gamepad A zieht") + await pad(JOY_BUTTON_DPAD_RIGHT) + check(scr.gp_zone == "pick" and scr.gp_index == 1, "Zeiger auf der zweiten Karte der Wahl") + await pad(JOY_BUTTON_A) + check(g.human.hand.size() == hand_before + 1 and not scr.pick_layer.visible, "Gamepad A nimmt die Karte") scr.ai_delay = 0.75 await key(KEY_SPACE) check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") @@ -519,31 +557,28 @@ func test_kampf_grenzen() -> void: g.human.energy = 100 scr.refresh() await frames(1) - # Hand voll kaufen + # Hand voll ziehen var guard := 0 while g.human.hand.size() < Game.MAX_HAND and guard < 30: guard += 1 - if scr.shop_views.is_empty(): - await click(scr.b_reroll) - continue - await click(scr.shop_views[0]) + await click(scr.deck) + await pick_all(scr) check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND) - check(scr.shop_views.all(func(v): return v.dim), "bei voller Hand ist nichts mehr kaufbar") - if not scr.shop_views.is_empty(): - await click(scr.shop_views[0]) - check(scr.msg_label.text == tr("Hand voll"), "Meldung bei voller Hand: " + scr.msg_label.text) + 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.deck) + await pick_all(scr) await key(KEY_1) - if scr.pending != null: # Karte will ein Ziel - var valid: Array = g.valid_targets(g.human, scr.pending.target_type()) - if valid.is_empty(): - await right_click() - else: - await click(target_view(scr, valid)) check(g.human.board.size() == Game.MAX_BOARD, "Feld mit %d Wesen voll" % Game.MAX_BOARD) + if g.human.hand.is_empty(): + await click(scr.deck) + await pick_all(scr) 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") @@ -553,16 +588,16 @@ func test_kampf_grenzen() -> void: await click(scr.own_views[0]) await click(scr.own_views[1]) check(g.human.board.size() == before - 1, "Verschmelzen macht einen Platz frei") - # Ohne Mana kein Neuwürfeln + # Ohne Mana kein Ziehen g.human.energy = 0 scr.refresh() await frames(1) - check(scr.b_reroll.disabled, "ohne Mana kein Neuwürfeln") + check(not scr.deck.enabled, "ohne Mana kein Ziehen") await give_up() -## Feldanzeige und Verschmelzen im Kampf. -func test_kampf_feld_und_verschmelzen() -> void: +## Elementregel, verdeckt legen, Kartenmenü und Verschmelzen im Kampf. +func test_kampf_elemente_und_verschmelzen() -> void: await start_battle() var scr = screen() check(scr is BattleScreen, "Kampf gestartet") @@ -570,47 +605,71 @@ func test_kampf_feld_und_verschmelzen() -> void: await wait_for_human(scr) var g: Game = scr.game g.human.board.clear() - var put := func(p: Game.Player, id: String) -> Game.Creature: - var cr := Game.Creature.new(Cards.by_id(id), p, g.next_uid) + scr.opponent().board.clear() # was die KI im ersten Zug gelegt hat, stört die Zähler + var put := func(p: Game.Player, id: String, hidden: bool = false) -> Game.Creature: + var cr := Game.Creature.new(Cards.by_id(id).copy(), p, g.next_uid) g.next_uid += 1 - cr.ready = true cr.attacks_left = 1 + cr.hidden = hidden p.board.append(cr) return cr put.call(g.human, "f_wolf") + put.call(g.human, "f_asche") put.call(g.human, "w_reiter") - var falke: Game.Creature = put.call(scr.others()[0], "l_falke") - g.human.hand = [Cards.by_id("f_kobold")] - g.human.energy = 10 - g.field_charge["Wasser"] = 2 + 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()] + g.human.energy = 20 + g.apply_auras() scr.refresh() await frames(2) - check(scr.field_view.text() == "Feuer 0/4 · Wasser 2/4 · Erde 0/4 · Luft 0/4", "neutrales Feld zeigt die Ladung: " + scr.field_view.text()) - for i in Cards.FIELD_CHARGE: - g.charge_field(Cards.by_id("f_kobold")) - scr.refresh() - await frames(2) - check(scr.field_view.text().begins_with("Vulkan: +1 Angriff"), "Feldanzeige nennt das Feld: " + scr.field_view.text()) - check(g.human.board[0].field_bonus == 1 and g.human.board[0].power() == 4, "das Feuerwesen bekommt +1 Angriff") - check(scr.hand_views[0].bonus == 2 and scr.hand_views[0].tag == "Dampf", "die Handkarte zeigt +2 (Resonanz und Feld) und die Kombo Dampf") - check(scr.b_fuse.visible and scr.b_fuse.text == "Verschmelzen", "Verschmelzen wird angeboten") - await shot("23_feld") + check(scr.element_view.text() == "Feuer 2 · Wasser 1 · Erde 0 · Luft 1", "Zähler ohne das verdeckte Wesen: " + scr.element_view.text()) + check(scr.tiles[secret.uid].secret and scr.hand_views[0].bonus == 0, "fremdes verdecktes Wesen zeigt nur den Rücken, noch kein Bonus in Sicht") + # verdeckt legen über das Kartenmenü (Rechtsklick auf die Handkarte) + var menu_text := "Verdeckt legen ◆ %d" % Cards.by_id("f_kobold").cost await hover(scr.hand_views[0]) - check(scr.tiles[g.human.board[1].uid].mark == "Dampf" and "Dampf" in scr.info_label.text, "beim Zeigen auf die Handkarte ist der Kombo-Partner markiert") + await right_click_at(scr.hand_views[0].get_global_rect().get_center()) + check(scr.menu_layer.visible and find_button(menu_text, scr.menu_layer) != null, "Rechtsklick öffnet das Kartenmenü mit „Verdeckt legen“") + await shot("23_kartenmenue") + await click(find_button(menu_text, scr.menu_layer)) + var mine_hidden: Game.Creature = g.human.board[3] + check(mine_hidden.hidden and not scr.menu_layer.visible and scr.element_view.text().begins_with("Feuer 2"), "verdeckt gelegt, zählt nicht") + # aufdecken per Klick auf das eigene verdeckte Wesen (erst, wenn es aus der Hand angekommen ist) + while scr.own_views[3].busy: + await frames(1) + await click(scr.own_views[3]) + check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3"), "aufgedeckt, zählt jetzt") + put.call(g.human, "f_kobold") + g.apply_auras() + scr.refresh() + await frames(2) + check(scr.element_view.text().begins_with("Feuer 4 (+1/+1)") and "Luft 1 (-1/-1)" in scr.element_view.text(), "vier Feuer: Bonus und Malus angezeigt: " + scr.element_view.text()) + check(g.human.board[0].power() == 4 and falke.power() == 2, "Feuerwesen +1, Luftwesen -1") + check(scr.hand_views[0].bonus == -1, "die Luftkarte auf der Hand zeigt -1") + await shot("23b_vier_feuer") + # Verbessern über das Menü + var wolf: Game.Creature = g.human.board[0] + scr.open_menu(wolf, Rect2(scr.own_views[0].slot, scr.own_views[0].size)) + var mana: int = g.human.energy + await click(find_button("Schild ◆ %d" % Cards.KEYWORD_COST["Schild"], scr.menu_layer)) + check(wolf.shield and "Schild" in wolf.card.keywords and g.human.energy == mana - Cards.KEYWORD_COST["Schild"], "Schild gekauft") + # Angriffsziel: Elementvorteil, verdeckte ohne Anzeige, Held gesperrt await click(scr.own_views[0]) - check(scr.selected == g.human.board[0] and scr.tiles[falke.uid].matchup == 1, "Angreifer gewählt: das Luftwesen zeigt +1 für Feuer") - await shot("23b_angriffsziel") - await right_click() # Auswahl wieder aufheben - check(scr.selected == null and scr.tiles[falke.uid].matchup == 0, "Auswahl aufgehoben, Vorteil verschwindet") + check(scr.selected == wolf and scr.tiles[falke.uid].matchup == 1 and scr.tiles[secret.uid].matchup == 0, "Angreifer gewählt: +1 gegen Luft, nichts gegen Verdeckte") + check(not g.can_attack(wolf, scr.opponent())[0], "Held nicht angreifbar, solange Wesen stehen") + await click(scr.tiles[secret.uid]) + check(not secret.hidden and not (secret in scr.opponent().board), "Angriff deckt auf und tötet den Kobold") + await right_click() + # Verschmelzen await click(scr.b_fuse) check(scr.fusing and scr.b_fuse.text == "Abbrechen", "Verschmelzen ausgewählt") - await click(scr.own_views[0]) + await click(scr.own_views[1]) check(scr.fuse_first != null, "erstes Wesen gewählt") await shot("24_verschmelzen") - await click(scr.own_views[1]) - check(g.human.board.size() == 1 and g.human.board[0].name == "Dampf", "die beiden Wesen sind zu Dampf verschmolzen") - check(not scr.fusing and g.human.energy == 8, "Modus beendet und Mana bezahlt") - check(scr.own_views.size() == 1, "nur noch eine eigene Kachel") + mana = g.human.energy + await click(scr.own_views[2]) + check(g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 1, "zu Dampf verschmolzen, 1 Mana bezahlt") + check(not scr.fusing, "Modus beendet") await give_up() @@ -642,6 +701,7 @@ func test_netzwerk() -> void: var b = screen() check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host") b.ai_delay = 0.0 + await pick_all(b) var rounds := 0 while b.game != null and b.game.winner == "" and rounds < 2: if b.game.current() == b.perspective: @@ -747,6 +807,7 @@ func test_host_wechsel() -> void: 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") + await pick_all(b) main.net.rejoin_seconds = 3.0 _bot_quit(true) w = 0 @@ -820,6 +881,7 @@ func test_kampagne() -> void: await click(find_button("Kämpfen")) var battle = screen() check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet") + await pick_all(battle) check(battle.game.opponent.name == "Fenn", "Fenn ist der Gegner") check(battle.game.opponent.perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv") await shot("16_kampagne_kampf") @@ -835,7 +897,8 @@ func test_kampagne() -> void: await shot("27_kampagne_kapitel2") await click(find_button("Kämpfen")) var b2 = screen() - check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponent.perks.size() == 2 and b2.game.opponent.life == 30, "Kapitel-2-Boss mit zwei Merkmalen und 30 Leben") + await pick_all(b2) + check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponent.perks.size() == 2 and b2.game.opponent.life == 22, "Kapitel-2-Boss mit zwei Merkmalen und 22 Leben") check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf") await give_up() main.profile.campaign_stage = 0 @@ -884,7 +947,7 @@ func test_kampf_abbrechen() -> void: scr.ai_delay = 0.0 var g: Game = scr.game await wait_for_human(scr) - check(g.opponent != null and g.human.max_energy == 1, "Duell läuft") + 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) @@ -894,18 +957,15 @@ func test_kampf_abbrechen() -> void: scr.refresh() var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0] check(reiter_view.rarity == "episch", "Handkarte trägt die eigene Stufe") - await hover(scr.field_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird + await hover(scr.element_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird await hover(reiter_view) var previews = scr.preview_holder.get_children().filter(func(c): return c is CardView) check(previews.size() == 1 and previews[0].rarity == "episch", "Vorschau ebenfalls") await shot("21_kampf_vorschau") - var targeted: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0]) - if not targeted.is_empty(): - var card: Cards.Card = targeted[0].card - await click(targeted[0]) - check(scr.pending == card, "Ziel wird erwartet") - await right_click() - check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab") + if not scr.own_views.is_empty(): + await click(scr.own_views[0]) + await right_click() + check(scr.selected == null, "Rechtsklick bricht ab") await hover(scr.hand_views[0]) if scr.preview_holder.get_child_count() == 0: print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(), diff --git a/translations/ui.csv b/translations/ui.csv index b958198..7fd94c5 100644 --- a/translations/ui.csv +++ b/translations/ui.csv @@ -43,7 +43,6 @@ Kapitel 2,Chapter 2 "Münzen: %d · Staub: %d","Coins: %d · Dust: %d" Zug %d · Du bist dran,Turn %d · Your turn Zug %d · %s ist dran …,Turn %d · %s is playing … -Wähle ein Ziel,Choose a target Wähle ein Angriffsziel,Choose an attack target Abgebrochen,Cancelled %s kann nicht angreifen,%s cannot attack @@ -65,26 +64,6 @@ Große Schrift,Large text %d Münzen,%d coins Abbrechen,Cancel "Ja, zurücksetzen","Yes, reset" -%s Schaden an ein gegnerisches Wesen,%s damage to an enemy creature -%s Schaden an ein Ziel,%s damage to a target -%s Schaden an alle gegnerischen Wesen,%s damage to all enemy creatures -%s Schaden an den Gegner,%s damage to the opponent -%s Schaden an den Gegner und seine Wesen,%s damage to the opponent and their creatures -Heile dich um %s,Heal yourself for %s -Erhalte 1 Mana,Gain 1 mana -Erhalte %s Mana,Gain %s mana -Friere ein gegnerisches Wesen ein,Freeze an enemy creature -Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature -Friere alle gegnerischen Wesen ein,Freeze all enemy creatures -Ein gegnerisches Wesen zurück auf die Hand,Return an enemy creature to its owner's hand -Alle gegnerischen Wesen zurück auf die Hand,Return all enemy creatures to their owners' hands -Ein befreundetes Wesen erhält %s,A friendly creature gets %s -Alle befreundeten Wesen erhalten %s,All friendly creatures get %s -Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield -Erhält Schild,Gets Shield -Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield -Beim Ausspielen: %s,On play: %s -Wächter,Guardian Flink,Swift Schild,Shield Doppelschlag,Double Strike @@ -97,16 +76,6 @@ Feuer,Fire Wasser,Water Erde,Earth Luft,Air -Inferno,Inferno -Tiefsee,Deep Sea -Berg,Mountain -Orkan,Hurricane -Dampf,Steam -Lava,Lava -Blitz,Lightning -Schlamm,Mud -Sturm,Storm -Sand,Sand %s zerlegt (+%d Staub),%s dismantled (+%d dust) Kapitel %d,Chapter %d Zuschauen,Watch @@ -119,14 +88,6 @@ Zwei eigene Wesen wählen,Pick two of your creatures Zweites Wesen wählen,Pick the second creature Zwei verschiedene Wesen wählen,Pick two different creatures Nur eigene Wesen,Only your own creatures -Vulkan,Volcano -Flut,Flood -Fels,Rock -Wirbel,Vortex -Feuerwesen haben +1 Angriff,Fire creatures get +1 attack -Wasserwesen haben +1 Angriff,Water creatures get +1 attack -Erdwesen haben +1 Angriff,Earth creatures get +1 attack -Luftwesen haben +1 Angriff,Air creatures get +1 attack Aufgewertet: %d von %d Karten · Kampagne: %d/%d,Upgraded: %d of %d cards · Campaign: %d/%d %s ist jetzt %s,%s is now %s Alle Kapitel abgeschlossen,All chapters completed @@ -141,27 +102,53 @@ Schon auf der höchsten Stufe,Already at the top tier Gewöhnliche Karten lassen sich nicht zerlegen,Common cards cannot be dismantled Aufgewertet,Upgraded +%d Staub,+%d dust -+%d je Zug,+%d per turn -Neu würfeln ◆ %d,Reroll ◆ %d Dein Zug,Your turn -Beim Ausspielen,On play Nicht genug Mana,Not enough mana -Kein Angebot,No offer -Hand voll,Hand full Profil wirklich zurücksetzen?,Really reset your profile? "Sammlung, Münzen und Fortschritt gehen verloren.","Collection, coins and progress are lost." -Chimäre,Chimera -Angriff,attack -Eis,Ice -Resonanz +%d,Resonance +%d -Feld +%d,Field +%d -Merkmal +%d,Perk +%d -Feldkombo %s mit %s: %s,Field combo %s with %s: %s -1 Schaden an das stärkste gegnerische Wesen,1 damage to the strongest enemy creature -2 Schaden an den Gegner,2 damage to the opponent -Heile dich um 3,Heal yourself for 3 -Das neue Wesen erhält +1/+1,The new creature gets +1/+1 %d Leben,%d life Gegner mit %d Leben · %d Münzen bei Sieg,Opponent with %d life · %d coins for a win Start gegen die KI,Start against the AI +Wähle %d von %d,Pick %d of %d +Ziehen,Draw +Erst wählen,Pick first +Deine Hand ist voll,Your hand is full +Keine Wahl offen,Nothing to pick +Keine solche Karte,No such card +Vorbei,Over +Verdeckt legen ◆ %d,Set face-down ◆ %d +Aufdecken,Reveal +verdeckt,face-down +Aufgedeckt!,Revealed! +Elementbonus +%d/+%d,Element bonus +%d/+%d +Elementbonus %d/%d,Element bonus %d/%d +Merkmal +%d,Perk +%d +nächster Zug ◆ %d,next turn ◆ %d +Erst die Wesen,Creatures first +%s liegt verdeckt,%s is face-down +%s liegt schon offen,%s is already face-up +Unbekannte Eigenschaft,Unknown trait +%s hat schon %s,%s already has %s +Wesen kommen mit +1 Angriff,Creatures enter with +1 attack +Hat im ersten Zug 2 Mana mehr,Has 2 extra mana on the first turn +Elementbonus zählt doppelt,Element bonus counts twice Beginnt mit 5 Leben mehr,Starts with 5 extra life +Blockt den ersten Schaden,Blocks the first damage +Greift zweimal je Zug,Attacks twice per turn +Verdeckt,Face-down +Inferno,Inferno +Tiefsee,Deep Sea +Berg,Mountain +Orkan,Hurricane +Dampf,Steam +Lava,Lava +Blitz,Lightning +Schlamm,Mud +Sturm,Storm +Sand,Sand +Feuerwesen haben +1 Angriff,Fire creatures have +1 attack +Wesen kommen mit +1 Leben,Creatures enter with +1 health +Bekommt jeden Zug 1 Mana mehr,Gets 1 extra mana each turn +Wesen kommen mit Schild,Creatures enter with a shield +Ziehen kostet kein Mana,Drawing costs no mana +Eigenschaften kosten 1 weniger,Traits cost 1 less diff --git a/translations/ui.en.translation b/translations/ui.en.translation index 2775cd5..c0e3f8c 100644 Binary files a/translations/ui.en.translation and b/translations/ui.en.translation differ