update
Some checks failed
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled

This commit is contained in:
Team3
2026-10-02 19:57:02 +02:00
parent f0a8f18de8
commit 24a03ed1ee
31 changed files with 1866 additions and 2620 deletions

View File

@@ -20,20 +20,19 @@ Der Spielstand liegt unter `user://spielstand.json`
## Spielsysteme ## Spielsysteme
- 77 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos entstehen erst im Spiel (Verschmelzen, Feldkombos) - 77 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos sind die Namen verschmolzener Wesen
- 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 - Karten haben nur Element, Angriff und Leben; die Kosten sind Angriff plus Leben, halbiert und aufgerundet
- Mana wächst um 1 je Zug (bis 10) und bleibt liegen, wenn du es nicht ausgibst — sparen ist eine echte Entscheidung - Mana: 5 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen, Legen, Verbessern und Verschmelzen
- Der Laden zeigt nur Karten, die höchstens dein Einkommen plus eins kosten: teure Wesen tauchen erst später auf - 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 - 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 - 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 - 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)
- Feldkombos: ein neues Wesen neben einem Wesen mit anderem Element löst einen Effekt aus - 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
- Karteneffekte lösen beim Ausspielen aus (Schaden, Einfrieren, Heilen, Zurückwerfen, Verstärken, Schild, Mana); Kartenvorteil ist hier Manavorteil - Angriffe treffen erst Wesen; das Leben ist nur ohne Wesen angreifbar. Schaden über das Leben eines Wesens hinaus schlägt auf den Besitzer durch
- Elementvorteil im Kreis (Wasser > Feuer > Luft > Erde > Wasser): 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, auch für Karteneffekte gegen Wesen - 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
- 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 - Duell mit ausgeloster Zugreihenfolge, wer nicht beginnt, bekommt einmalig 1 Mana; die Hand fasst 5 Karten, das Feld 5 Wesen
- 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
- Schwierigkeit Leicht/Normal/Schwer: Gegner mit 25/30/35 Leben, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach - 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 - 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 - 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_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_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) 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 godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf
``` ```
@@ -71,14 +70,14 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
## Aufbau ## Aufbau
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json` - `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Feldkombos, Resonanz, Feld, Verschmelzen - `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementregel, Kampf, Verbessern, Verschmelzen
- `scripts/deckgen.gd` – Ladenangebot und Gegner - `scripts/deckgen.gd` – gemeinsamer Stapel und Gegner
- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk - `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Booster, Einstellungen - `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Booster, Einstellungen
- `scripts/campaign.gd` – Kampagnenstufen - `scripts/campaign.gd` – Kampagnenstufen
- `scenes/main.tscn` – Startszene - `scenes/main.tscn` – Startszene
- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden - `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 - `translations/ui.csv` – englische Oberflächentexte
- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs - `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs

View File

@@ -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 - 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 - Booster öffnen: höhere Stufen werten Karten auf, der Rest wird zu Staub
- Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück - 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 - 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
- Nicht ausgegebenes Mana bleibt liegen, neu würfeln kostet 1 Mana (wie in TFT) - 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 - Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen
## Elemente ## Elemente
- Jede Karte gehört zu genau einem Element - 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 - Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan)
- 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 - 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 (1 Schaden mehr im Vorteil, 1 weniger im Nachteil; Mehrfachelemente nehmen den für sie besseren Wert) - 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)
- 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 Mana verschmelzen: das neue erbt Elemente und Eigenschaften beider, seine Werte sind jeweils der bessere Wert plus 1
- Zwei eigene Wesen lassen sich für Energie verschmelzen: das neue erbt Elemente und Fähigkeiten beider, seine Werte sind der Durchschnitt plus 1
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus - Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus

File diff suppressed because it is too large Load Diff

View File

@@ -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 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 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. 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 - **Hearthstones Entdecken** – drei Karten sehen, eine nehmen: Zufall mit Entscheidung. Jede
mit seinem Preis direkt daneben, die Bank (bei uns die Hand) liegt als schmale Reihe darüber. Wahl zeigt an, was zum eigenen Brett passt (Elementbonus als „+1/+1“ oder „−1/−1“).
Der Laden zeigt an, was zum eigenen Brett passt (bei uns: „+n“ und die Feldkombo). - **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 - **Pokémon TCG** – Seltenheit ist Optik: Folienglanz, Funkeln, Schillern. Werte und Symbole
statt Sätze. statt Sätze.
- **Allgemeine Regeln für Kartenspiel-Oberflächen** (GDKeys, Medium): klar getrennte Zonen, - **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 ## Karte
- Kostenkristall oben links, Bild (Elementsymbol) oben, Namensband auf 36 % Höhe, darunter - 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 = gekaufte Eigenschaften als Symbole (Schild, gekreuzte Klingen = Doppelschlag), unten Angriff (gelb) und Leben (rot) als Kreise, dazwischen der Edelstein der
Doppelschlag), dann der Effekttext ohne Einleitung, unten Angriff (gelb) und Leben (rot) als Stufe. Karten haben keinen Text: nur Element, Angriff, Leben.
Kreise, dazwischen der Edelstein der Stufe. - Große Karten (Vorschau, Sammlung, Booster) schreiben das Wort neben das Symbol.
- Große Karten (Vorschau, Sammlung, Booster) schreiben das Wort neben das Symbol und die - Die Hand sind bis zu fünf große Karten; Details kommen aus der schwebenden Vorschau.
Überschrift „Beim Ausspielen“ über den Effekt. Kleine Karten zeigen nur Symbole.
- Handkacheln (96×62) sind flach: Kosten, Name, Werte, Symbole oder das Kombo-Schild. Details
kommen aus der schwebenden Vorschau.
- Seltenheit: gewöhnlich schlicht; selten Rahmen, Schein, Edelstein, stehender Lichtstreifen; - Seltenheit: gewöhnlich schlicht; selten Rahmen, Schein, Edelstein, stehender Lichtstreifen;
episch wandernder Lichtstreifen; legendär Funkeln; mythisch schillernde Farben. episch wandernder Lichtstreifen; legendär Funkeln; mythisch schillernde Farben.
- Der Kartentext in Daten und Sammlungssuche bleibt vollständig (`Card.text()`), die Karte - Verdeckte fremde Wesen sind ein Kartenrücken mit „?“; eigene verdeckte zeigen die Karte mit
zeichnet nur `effect_text()`. dem Vermerk „verdeckt“.
## Kampfbildschirm (1280×800) ## Kampfbildschirm (1280×800)
Nur, was gerade zählt, ist zu sehen. Von unten nach oben: 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 bis zu fünf große Karten, links der Zieh-Stapel mit „◆ 1“ (Klick zieht), rechts
- **Hand** als flache Kacheln (die Bank), für andere verdeckt. „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 - **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 die eigene Leiste (Leben, Mana, Einkommen, Zug), rechts „Verschmelzen“ und „Rückgängig“, nur
wenn sie gerade möglich sind. 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 - **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 Hearthstone: mehr als zwei Bretter passen nicht lesbar auf einen Bildschirm, und ein Gegner
macht Ziele, Texte und Balance eindeutig. macht Ziele, Texte und Balance eindeutig.
- **Vorschau** schwebt neben der Karte, auf die man zeigt (über Ladenkarten), mit Resonanz, Feld - **Vorschau** schwebt neben der Karte, auf die man zeigt (über Ladenkarten), mit Resonanz, Feld
und Feldkombo darunter. und Feldkombo darunter.
- **Verlauf** und **Aufgeben** als kleine Knöpfe oben links; der Verlauf öffnet als Überlagerung. - **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 - Hand- und Wahlkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen
der Partner auf dem Brett markiert; beim Angreifen steht der Elementkonter (+1/−1) am Ziel. steht der Elementkonter (+1/−1) am Ziel, bei Verdeckten nicht.
- Wesen mit gleichem Element sind unter der Reihe verbunden (Resonanz).
## Bewegung und Ton ## Bewegung und Ton
- Zugbeginn: Band „Dein Zug“ quer über die Mitte, Laden wird ausgeteilt, Ton. - Zugbeginn: Band „Dein Zug“ quer über die Mitte, Ton.
- Kaufen: die Karte fliegt aus dem Laden auf die Hand, Münzton. - 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. - Ausspielen: die Kachel fliegt von der Hand auf ihren Platz und federt ein.
- Angriff: Ausfallschritt, roter Rahmen am Ziel, kurzes Zittern des Bretts, Schadenszahl. - 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. - 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. - Booster: Karten drehen sich auf, seltene Stufen blitzen in ihrer Farbe auf.
- Alles hängt an der Einstellung „Animationen“. - Alles hängt an der Einstellung „Animationen“.

View File

@@ -1,25 +1,19 @@
class_name AI class_name AI
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels ## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels ausgeführt und die
## ausgeführt und die Stellung danach bewertet; die besten Erstschritte werden um ## Stellung danach bewertet; die besten Erstschritte werden um einen Folgeschritt vertieft.
## einen Folgeschritt vertieft. Die gierige Variante (choose_action_greedy) dient ## Verdeckte gegnerische Wesen kennt die KI nicht (Game.masked_clone). Die gierige Variante
## als Vergleich und für die Schwierigkeit Leicht. Alles hier ist zustandslos und ## (choose_action_greedy) dient als Vergleich und für die Schwierigkeit Leicht. Alles hier ist
## darf in einem Hintergrund-Thread auf einer Spielkopie laufen. ## zustandslos und darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
# --- Bewertung ------------------------------------------------------------------- # --- Bewertung -------------------------------------------------------------------
static func creature_value(c: Game.Creature) -> float: static func creature_value(c: Game.Creature) -> float:
var v := float(c.power() + c.health) var v := float(c.power() + c.health)
if c.taunt():
v += 2.0
if c.shield: if c.shield:
v += 1.5 v += 1.0
if c.attacks_per_turn() == 2: if c.attacks_per_turn() == 2:
v += c.power() v += c.power() * 0.5
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
return v return v
@@ -38,16 +32,14 @@ static func hand_value(p: Game.Player) -> float:
return v return v
## Stellung aus Sicht von p: eigenes Leben, Feld, Hand und übriges Mana zählen positiv, ## Stellung aus Sicht von p: eigenes Leben, Brett und Hand zählen positiv, die des Gegners
## die des Gegners negativ. Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, ## negativ. Ohne eigene Wesen zielt gegnerische Angriffskraft aufs Leben, tödliche besonders.
## tödliche besonders.
static func evaluate(g: Game, p: Game.Player) -> float: static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive(): if not p.alive():
return -10000.0 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 alive_enemies := 0
var threat := 0.0 var threat := 0.0
var leader := 0.0
for e in g.players: for e in g.players:
if e == p: if e == p:
continue continue
@@ -55,59 +47,40 @@ static func evaluate(g: Game, p: Game.Player) -> float:
score += 40.0 score += 40.0
continue continue
alive_enemies += 1 alive_enemies += 1
var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2 score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
score -= ev
leader = maxf(leader, ev)
if e.life <= 6: if e.life <= 6:
score += 3.0 # fast besiegt: dranbleiben score += 3.0 # fast besiegt: dranbleiben
for c in e.board: for c in e.board:
if not c.frozen: if not c.hidden:
threat += c.power() * c.attacks_per_turn() threat += c.power() * c.attacks_per_turn()
if alive_enemies == 0: if alive_enemies == 0:
return 10000.0 return 10000.0
score -= leader * 0.25 if p.board.is_empty():
var guarded := false if threat >= p.life:
for c in p.board:
if c.taunt():
guarded = true
break
if guarded:
score -= threat * 0.1
elif threat >= p.life:
score -= 25.0 score -= 25.0
else: else:
score -= threat * 0.3 score -= threat * 0.3
else:
score -= threat * 0.12 # Durchschlag: auch hinter Wesen tut Angriffskraft weh
return score return score
# --- Vorausschau --------------------------------------------------------------- # --- 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 DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug 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). ## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann der Rest; Durchschlag zählt mit.
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.
static func _attack_rank(cr: Game.Creature, t) -> float: static func _attack_rank(cr: Game.Creature, t) -> float:
if t is Game.Player: if t is Game.Player:
return 50.0 - t.life return 50.0 - t.life
if _kills(cr, t): 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) 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]) 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: static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
var out := [] 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: for card in p.hand:
if not game.can_play(p, card)[0]: if not game.can_play(p, card)[0]:
continue continue
var tt: String = card.target_type() out.append({"type": "play", "card": card, "face_down": false})
if tt == "": if allow_hidden and game.forecast(p, card) < 0:
out.append({"type": "play", "card": card, "target": null}) out.append({"type": "play", "card": card, "face_down": true})
continue for cr in p.board:
if not valid_cache.has(tt): if cr.hidden and game.can_reveal(p, cr)[0]:
valid_cache[tt] = game.valid_targets(p, tt) out.append({"type": "reveal", "creature": cr})
var valid: Array = valid_cache[tt] elif not cr.hidden and allow_upgrade:
if valid.is_empty(): # Nur Verbesserungen, die sich noch in diesem Zug auszahlen können: Doppelschlag für
out.append({"type": "play", "card": card, "target": null}) # ein Wesen, das gleich angreift; Schild nur, wenn kein Wesen mehr spielbar ist.
continue var has_target := not game.enemies_of(p).is_empty()
if prune: if cr.attacks_left > 0 and has_target and game.can_upgrade(p, cr, "Doppelschlag")[0]:
var fx: Cards.Effect = null out.append({"type": "upgrade", "creature": cr, "keyword": "Doppelschlag"})
for f in card.effects: if not p.hand.any(func(c): return game.can_play(p, c)[0]) and game.can_upgrade(p, cr, "Schild")[0]:
if f.target == tt: out.append({"type": "upgrade", "creature": cr, "keyword": "Schild"})
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})
var pairs := [] var pairs := []
for i in p.board.size(): for i in p.board.size():
for j in range(i + 1, p.board.size()): for j in range(i + 1, p.board.size()):
if game.can_fuse(p, p.board[i], p.board[j])[0]: if game.can_fuse(p, p.board[i], p.board[j])[0]:
pairs.append({"type": "fuse", "creature": p.board[i], "other": p.board[j]}) pairs.append([p.board[i], p.board[j]])
if prune: # Verschmelzen lohnt vor allem bei schwachen Wesen if prune and pairs.size() > MAX_TARGETS:
pairs = _top(pairs, func(a): return -float(_value(a.creature) + _value(a.other)), MAX_TARGETS) pairs = _top(pairs, func(pr): return _value(pr[0]) + _value(pr[1]), MAX_TARGETS)
out.append_array(pairs) for pr in pairs:
out.append({"type": "fuse", "creature": pr[0], "other": pr[1]})
for cr in p.board: for cr in p.board:
if not cr.can_attack(): if not cr.can_attack():
continue continue
var targets := [] var targets := []
for e in game.enemies_of(p): for e in game.enemies_of(p):
for t in e.board + [e]: targets.append_array(e.board)
if game.can_attack(cr, t)[0]: if e.board.is_empty():
targets.append(t) targets.append(e)
targets = targets.filter(func(t): return game.can_attack(cr, t)[0])
if prune: if prune:
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS) targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
for t in 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. ## Führt eine Aktion des Originals auf der Kopie aus.
static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void: static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
var p2: Game.Player = g2.players[game.current_idx] 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: match action.type:
"play": "play":
g2.play_card(p2, action.card, target) g2.play_card(p2, action.card, bool(action.get("face_down", false)))
"buy": "reveal":
g2.buy(p2, int(action.slot)) g2.reveal(p2, g2.find_creature(action.creature.uid))
"reroll": "upgrade":
g2.reroll(p2) g2.upgrade(p2, g2.find_creature(action.creature.uid), action.keyword)
"fuse": "fuse":
g2.fuse(p2, g2.find_creature(action.creature.uid), g2.find_creature(action.other.uid)) 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): 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 return out
## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer ## Beste Aktion nach einer Vorausschau von zwei Schritten auf einer Kopie, die verdeckte
## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte ## gegnerische Wesen nicht kennt. Bei Zeitmangel gilt der beste bisherige Zug.
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug.
static func choose_action(game: Game, p: Game.Player) -> Dictionary: static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty(): if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"} 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 t0 := Time.get_ticks_msec()
var base := evaluate(game, p) + 0.01 var base := evaluate(view, me) + 0.01
var firsts := [] # {action, game, score} 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: if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
break break
var g2 := game.clone() var g2 := view.clone()
apply_on_clone(g2, game, action) apply_on_clone(g2, view, action)
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])}) 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) firsts.sort_custom(func(a, b): return a.score > b.score)
var best := {"type": "end"} var best := {"type": "end"}
var best_score := base var best_score := base
@@ -247,7 +199,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
var g2: Game = f.game var g2: Game = f.game
var score: float = f.score var score: float = f.score
if g2.winner == "": if g2.winner == "":
var p2: Game.Player = g2.players[game.current_idx] var p2: Game.Player = g2.players[view.current_idx]
var count := 0 var count := 0
for nxt in candidate_actions(g2, p2, true): for nxt in candidate_actions(g2, p2, true):
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms: 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 count += 1
var g3 := g2.clone() var g3 := g2.clone()
apply_on_clone(g3, g2, nxt) 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: if score > best_score:
best_score = score best_score = score
best = f.action best = f.action
@@ -264,57 +216,81 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if firsts[i].score > best_score: if firsts[i].score > best_score:
best_score = firsts[i].score best_score = firsts[i].score
best = firsts[i].action best = firsts[i].action
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 best
return map_action(game, view, best)
# --- Gierige Vergleichs-KI ------------------------------------------------------- # --- Einfache Heuristiken ----------------------------------------------------------
static func _kills(attacker, defender) -> bool: 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: 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 = null
var best_v := 0 var best_v := -INF
for x in arr: for t in list:
var v: int = key.call(x) var v := float(key.call(t))
if best == null or v > best_v: if v > best_v:
best = x
best_v = v best_v = v
best = t
return best return best
static func _threat_score(h) -> int: ## Wert einer angebotenen Karte für p: Werte je Mana, jetzt spielbar zählt extra, der
var board_attack := 0 ## Elementbonus und ein Vorteil gegen das gegnerische Brett auch; viel zu teuer zählt ab.
for c in h.board: static func offer_score(game: Game, p: Game.Player, c: Cards.Card) -> float:
board_attack += c.power() var score := float(c.attack + c.health) / maxf(1.0, c.cost) * 2.0 + c.attack + c.health
return h.life - board_attack 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): ## Welche der angebotenen Karten p nimmt.
var best = null static func pick_index(game: Game, p: Game.Player) -> int:
for h in heroes: var best := 0
if best == null or _threat_score(h) < _threat_score(best): var best_score := -INF
best = h 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 return best
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty(): if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"} return {"type": "end"}
# Gierig: erst das teuerste Bezahlbare kaufen, dann ausspielen, dann angreifen. if not p.offer.is_empty():
var best := -1 return {"type": "pick", "index": pick_index(game, p)}
for k in p.shop.size(): # Gierig: erst das teuerste Spielbare legen, dann aufdecken und angreifen, dann ziehen.
var offer = p.shop[k]
if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND:
if best < 0 or offer.cost > p.shop[best].cost:
best = k
if best >= 0:
return {"type": "buy", "slot": best}
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0]) var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
if not playable.is_empty(): if not playable.is_empty():
Game.shuffle(playable, game.rng) Game.shuffle(playable, game.rng)
@@ -322,71 +298,35 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
for c in playable: for c in playable:
if c.cost > card.cost: if c.cost > card.cost:
card = c 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: for cr in p.board:
if cr.can_attack(): if cr.can_attack():
var t = pick_attack_target(game, p, cr) var t = pick_attack_target(game, p, cr)
if t != null: if t != null:
return {"type": "attack", "creature": cr, "target": t} return {"type": "attack", "creature": cr, "target": t}
if game.can_draw(p)[0]:
return {"type": "draw"}
return {"type": "end"} return {"type": "end"}
static func pick_target(game: Game, p: Game.Player, card: Cards.Card): ## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
var tt := card.target_type() ## der Held nur, wenn kein Wesen mehr steht.
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]
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature): static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
var enemies := game.enemies_of(p) for e in game.enemies_of(p):
var total := 0 if e.board.is_empty():
for c in p.board: return e if game.can_attack(cr, e)[0] else null
if c.can_attack(): var creatures: Array = e.board.filter(func(c): return game.can_attack(cr, c)[0])
total += c.power() * c.attacks_left if creatures.is_empty():
var options := [] continue
for e in enemies: var good: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and not _kills(c, cr))
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(): if not good.is_empty():
return _best(good, Callable(AI, "_value")) return _best(good, Callable(AI, "_value"))
var trade := creatures.filter(func(c): return _kills(cr, c) and _value(c) > _value(cr)) 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(): if not trade.is_empty():
return _best(trade, Callable(AI, "_value")) return _best(trade, Callable(AI, "_value"))
if not heroes.is_empty():
return _weakest(heroes)
if not creatures.is_empty():
return _best(creatures, func(c): return 1000 - c.health) return _best(creatures, func(c): return 1000 - c.health)
return null 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: static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
match action.type: match action.type:
"play": "play":
game.play_card(p, action.card, action.target) game.play_card(p, action.card, bool(action.get("face_down", false)))
"buy": "pick":
game.buy(p, int(action.slot)) game.pick(p, int(action.index))
"reroll": "draw":
game.reroll(p) game.draw(p)
"reveal":
game.reveal(p, action.creature)
"upgrade":
game.upgrade(p, action.creature, action.keyword)
"attack": "attack":
game.attack(action.creature, action.target) game.attack(action.creature, action.target)
"fuse": "fuse":
@@ -416,3 +360,4 @@ static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: i
apply_action(game, p, action) apply_action(game, p, action)
if action.type == "end" or game.winner != "": if action.type == "end" or game.winner != "":
return return
game.end_turn()

View File

@@ -1,47 +1,48 @@
class_name Campaign 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 := [ const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "life": 30, "reward": 100, "perk": "feuer_angriff"}, {"name": "Fenn", "title": "Kobold-Zähmer", "life": 22, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "life": 30, "reward": 100, "perk": "leben_35"}, {"name": "Mara", "title": "Gezeitenhexe", "life": 22, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "life": 31, "reward": 150, "perk": "waechter_leben"}, {"name": "Borin", "title": "Steinfaust", "life": 24, "reward": 150, "perk": "wesen_angriff"},
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "life": 31, "reward": 150, "perk": "flink_angriff"}, {"name": "Liska", "title": "Windläuferin", "life": 24, "reward": 150, "perk": "ziehen_gratis"},
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "life": 32, "reward": 200, "perk": "wesen_leben"}, {"name": "Vulkar", "title": "Herr der Lava", "life": 24, "reward": 200, "perk": "wesen_leben"},
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 200, "perk": "extra_mana"}, {"name": "Nyx", "title": "Sturmruferin", "life": 26, "reward": 200, "perk": "extra_mana"},
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "life": 33, "reward": 250, "perk": "resonanz_doppelt"}, {"name": "Ignis", "title": "Inferno-Priester", "life": 26, "reward": 250, "perk": "element_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "life": 33, "reward": 250, "perk": "schild_wesen"}, {"name": "Thalassa", "title": "Tiefseekönigin", "life": 26, "reward": 250, "perk": "schild_wesen"},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 300, "perk": "alle_flink"}, {"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 300, "perk": "verbessern_billig"},
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 500, "perk": "start_energie"}, {"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. ## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Leben zurück.
const STAGES2 := [ const STAGES2 := [
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "life": 30, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, {"name": "Fenn", "title": "Flammenzähmer", "life": 22, "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": "Mara", "title": "Herrin der Gezeiten", "life": 22, "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": "Borin", "title": "Fels der Ewigkeit", "life": 24, "reward": 350, "perks": ["wesen_angriff", "schild_wesen"]},
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "life": 31, "reward": 350, "perks": ["flink_angriff", "alle_flink"]}, {"name": "Liska", "title": "Sturmläuferin", "life": 24, "reward": 350, "perks": ["ziehen_gratis", "verbessern_billig"]},
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "life": 32, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]}, {"name": "Vulkar", "title": "Aschekönig", "life": 24, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 400, "perks": ["extra_mana", "flink_angriff"]}, {"name": "Nyx", "title": "Gewitterruferin", "life": 26, "reward": 400, "perks": ["extra_mana", "ziehen_gratis"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "life": 33, "reward": 450, "perks": ["resonanz_doppelt", "wesen_leben"]}, {"name": "Ignis", "title": "Inferno-Erzpriester", "life": 26, "reward": 450, "perks": ["element_doppelt", "wesen_leben"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "life": 33, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, {"name": "Thalassa", "title": "Kaiserin der Tiefe", "life": 26, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 500, "perks": ["alle_flink", "start_energie"]}, {"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 500, "perks": ["verbessern_billig", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_mana"]}, {"name": "Archimagier", "title": "Herr aller Elemente", "life": 28, "reward": 800, "perks": ["start_energie", "element_doppelt"]},
] ]
## Kapitel 3: drei Merkmale und volle Tische. ## Kapitel 3: drei Merkmale und volle Tische.
const STAGES3 := [ const STAGES3 := [
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "life": 32, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]}, {"name": "Fenn", "title": "Aschefürst", "life": 20, "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": "Mara", "title": "Sturmflut", "life": 20, "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": "Borin", "title": "Berg selbst", "life": 22, "reward": 700, "perks": ["wesen_angriff", "schild_wesen", "leben_35"]},
{"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "life": 33, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_mana"]}, {"name": "Liska", "title": "Windbraut", "life": 22, "reward": 700, "perks": ["ziehen_gratis", "verbessern_billig", "extra_mana"]},
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "life": 34, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "waechter_leben"]}, {"name": "Vulkar", "title": "Magmakaiser", "life": 22, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "wesen_angriff"]},
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "life": 34, "reward": 800, "perks": ["extra_mana", "flink_angriff", "resonanz_doppelt"]}, {"name": "Nyx", "title": "Orkanmutter", "life": 24, "reward": 800, "perks": ["extra_mana", "ziehen_gratis", "element_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "life": 35, "reward": 900, "perks": ["resonanz_doppelt", "wesen_leben", "start_energie"]}, {"name": "Ignis", "title": "Sonnenpriester", "life": 24, "reward": 900, "perks": ["element_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": "Thalassa", "title": "Herz der Tiefe", "life": 24, "reward": 900, "perks": ["schild_wesen", "leben_35", "element_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "life": 36, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]}, {"name": "Zephyra", "title": "Gewitterkaiserin", "life": 26, "reward": 1000, "perks": ["verbessern_billig", "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": "Archimagier", "title": "Ewiger Meister", "life": 26, "reward": 1200, "perks": ["start_energie", "element_doppelt", "leben_35"]},
] ]
const CHAPTERS := [STAGES, STAGES2, STAGES3] const CHAPTERS := [STAGES, STAGES2, STAGES3]
@@ -63,9 +64,9 @@ static func stage(i: int) -> Dictionary:
return CHAPTERS[chapter(i) - 1][i % STAGES.size()] 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: static func opponent(stage_index: int) -> Game.Player:
var st := stage(stage_index) 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) boss.perks = Array(st.perks)
return boss return boss

View File

@@ -1,5 +1,5 @@
class_name Cards 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 ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"]
const ELEMENT_COLORS := { const ELEMENT_COLORS := {
@@ -8,8 +8,7 @@ const ELEMENT_COLORS := {
"Erde": Color("73552d"), "Erde": Color("73552d"),
"Luft": Color("78aac8"), "Luft": Color("78aac8"),
} }
# Jede Karte hat genau ein Element. Kombos entstehen erst im Spiel: beim Verschmelzen # Jede Karte hat genau ein Element. Kombos sind die Namen verschmolzener Wesen.
# (der Name des neuen Wesens) und als Feldkombo zwischen benachbarten Elementen.
const COMBOS := { const COMBOS := {
"Feuer+Feuer": "Inferno", "Feuer+Feuer": "Inferno",
"Wasser+Wasser": "Tiefsee", "Wasser+Wasser": "Tiefsee",
@@ -22,26 +21,8 @@ const COMBOS := {
"Wasser+Luft": "Sturm", "Wasser+Luft": "Sturm",
"Erde+Luft": "Sand", "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. # 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"} 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. # 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 RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
const RARITY_COLORS := { const RARITY_COLORS := {
@@ -51,8 +32,10 @@ const RARITY_COLORS := {
"legendär": Color("f0aa28"), "legendär": Color("f0aa28"),
"mythisch": Color("ff5a8c"), "mythisch": Color("ff5a8c"),
} }
const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"] # Schlüsselwörter haben Karten nicht von Haus aus; sie werden für Mana dazugekauft (Game.upgrade).
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"] 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" 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. ## 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: static func matchup(own: Array, other: Array) -> int:
for e in own: var mine := best_element(own, other)
if BEATS.get(e, "") in 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 return 1
for e in other: if BEATS.get(b, "") == a:
if BEATS.get(e, "") in own:
return -1 return -1
return 0 return 0
## Mana für eine Verschmelzung: Durchschnitt der Kartenkosten, aufgerundet. ## Das Element aus own, das gegen die Elemente in other am besten steht.
static func fusion_cost(a: Card, b: Card) -> int: static func best_element(own: Array, other: Array) -> String:
return ceili((a.cost + b.cost) / 2.0) 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). ## 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]]) return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]])
## Karte des verschmolzenen Wesens: Elemente und Schlüsselwörter beider Karten, ## Karte des verschmolzenen Wesens: Elemente und Schlüsselwörter beider Karten, die Werte
## Werte kommen vom Spiel (Durchschnitt plus 1, siehe Game.fuse). ## bestimmt Game.fuse, eine Verschmelzung mehr als der erfahrenere Elternteil.
static func fused(a: Card, b: Card, attack: int, health: int) -> Card: static func fused(a: Card, b: Card, attack: int, health: int) -> Card:
var els := [] var els := []
for e in a.elements + b.elements: 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.id = "fus_%s_%s" % [a.id, b.id]
c.name = fusion_name(els) c.name = fusion_name(els)
c.elements = els c.elements = els
c.cost = fusion_cost(a, b)
c.attack = attack c.attack = attack
c.health = health c.health = health
c.cost = cost_of(attack, health)
for k in KEYWORDS: for k in KEYWORDS:
if k in a.keywords or k in b.keywords: if k in a.keywords or k in b.keywords:
c.keywords.append(k) c.keywords.append(k)
c.fusions = maxi(a.fusions, b.fusions) + 1
return c return c
static func field_combo_effect(name: String) -> Effect: ## Platzhalter für ein verdecktes gegnerisches Wesen: unbekanntes Element, geschätzte Werte.
match name: static func unknown() -> Card:
"Dampf": var c := Card.new()
return Effect.new("freeze", "strongest_enemy_creature") c.id = "?"
"Lava": c.name = "Verdeckt"
return Effect.new("damage", "strongest_enemy_creature", 1) c.name_en = "Face-down"
"Blitz": c.elements = []
return Effect.new("damage", "weakest_enemy_hero", 2) c.attack = 2
"Schlamm": c.health = 3
return Effect.new("heal", "self_hero", 3) c.cost = cost_of(2, 3)
"Sturm": return c
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 ""
class Card extends RefCounted: 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: String # deutscher Name (auch im Verlauf und in Tests)
var name_en := "" # englischer Name var name_en := "" # englischer Name
var elements: Array var elements: Array
var cost: int
var attack: int = 0 var attack: int = 0
var health: int = 0 var health: int = 0
var keywords: Array = [] var cost: int = 0 # abgeleitet: (Angriff + Leben) / 2, aufgerundet
var effects: Array = [] var keywords: Array = [] # dazugekauft, je Exemplar
var fusions := 0 # wie oft verschmolzen (Verschmelzen kostet 1, 2, 4, 8 …)
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health}
"attack": attack, "health": health}
if not keywords.is_empty(): if not keywords.is_empty():
d["keywords"] = keywords d["keywords"] = keywords
if not effects.is_empty(): if fusions > 0:
d["effects"] = effects.map(func(e): return e.to_dict()) d["fusions"] = fusions
return d return d
static func from_dict(d: Dictionary) -> Card: static func from_dict(d: Dictionary) -> Card:
@@ -224,14 +163,17 @@ class Card extends RefCounted:
c.name = d.name c.name = d.name
c.name_en = str(d.get("name_en", "")) c.name_en = str(d.get("name_en", ""))
c.elements = Array(d.elements) c.elements = Array(d.elements)
c.cost = int(d.cost)
c.attack = int(d.get("attack", 0)) c.attack = int(d.get("attack", 0))
c.health = int(d.get("health", 0)) c.health = int(d.get("health", 0))
c.cost = Cards.cost_of(c.attack, c.health)
c.keywords = Array(d.get("keywords", [])) c.keywords = Array(d.get("keywords", []))
for e in d.get("effects", []): c.fusions = int(d.get("fusions", 0))
c.effects.append(Effect.from_dict(e))
return c 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. ## Anzeigename in der aktuellen Sprache.
func title() -> String: func title() -> String:
if name_en != "" and TranslationServer.get_locale().begins_with("en"): if name_en != "" and TranslationServer.get_locale().begins_with("en"):
@@ -244,35 +186,19 @@ class Card extends RefCounted:
func combo() -> String: func combo() -> String:
return Cards.combo_name(elements) return Cards.combo_name(elements)
func target_type() -> String: ## Mana für die nächste Verschmelzung mit dieser Karte.
for fx in effects: func fusion_cost() -> int:
if fx.target in Cards.CHOSEN_TARGETS: return 1 << fusions
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) + "."
## Kartentext: die gekauften Schlüsselwörter.
func text() -> String: func text() -> String:
var parts: Array[String] = [] if keywords.is_empty():
if not keywords.is_empty(): return ""
var kws: Array[String] = [] var kws: Array[String] = []
for k in Cards.KEYWORDS: for k in Cards.KEYWORDS:
if k in keywords: if k in keywords:
kws.append(tr(k)) kws.append(tr(k))
parts.append(", ".join(kws) + ".") return ", ".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)
static var _all: Array = [] static var _all: Array = []

View File

@@ -1,35 +1,18 @@
class_name DeckGen class_name DeckGen
## Ladenangebot und Gegner (ausgelagert aus den Spielregeln). ## Der gemeinsame Stapel (jede Karte dreimal) und Gegner.
## Es gibt keine Decks: jeder kauft jede Runde aus seinem eigenen Laden.
# Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner). const COPIES := 3
const RESONANCE_STEPS := [0, 1, 2, 3, 3]
## Karten, die im Laden auftauchen dürfen: höchstens Einkommen plus eins teuer, ## Frisch gemischter Stapel; das Ende des Arrays ist oben.
## bei gesetzten Elementen nur aus diesen. static func deck(r: RandomNumberGenerator) -> Array:
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))
var out := [] var out := []
for i in n: for c in Cards.all():
out.append(p[r.randi_range(0, p.size() - 1)]) for i in COPIES:
var cheap := p.filter(func(c): return c.cost <= income) out.append(c)
if not cheap.is_empty() and not out.any(func(c): return c.cost <= income): Game.shuffle(out, r)
out[r.randi_range(0, out.size() - 1)] = cheap[r.randi_range(0, cheap.size() - 1)]
return out return out
## Gegner mit eigenem Laden über alle Elemente.
static func random_opponent(life: int = Game.START_LIFE) -> Game.Player: static func random_opponent(life: int = Game.START_LIFE) -> Game.Player:
return Game.Player.new("Gegner", false, life) return Game.Player.new("Gegner", false, life)

View File

@@ -1,27 +1,30 @@
class_name Game class_name Game
extends RefCounted 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_BOARD := 5
const MAX_HAND := 8 const MAX_HAND := 5
const MAX_ENERGY := 10 # Einkommen je Zug, höher steigt es nicht const START_MANA := 5 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt
const SHOP_SIZE := 5 # so viele Karten liegen im Laden const DRAW_COST := 1 # Ziehen: drei Karten sehen, eine behalten
const REROLL_COST := 1 const OFFER_SIZE := 3
# Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben. const START_OFFER := 5 # zu Beginn fünf sehen, drei behalten
const START_PICKS := 3
const START_LIFE := 30 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). # Merkmale der Kampagnen-Bosse (Player.perks).
const PERKS := { const PERKS := {
"feuer_angriff": "Feuerwesen haben +1 Angriff", "feuer_angriff": "Feuerwesen haben +1 Angriff",
"flink_angriff": "Flinke Wesen haben +1 Angriff", "ziehen_gratis": "Ziehen kostet kein Mana",
"waechter_leben": "Wächter kommen mit +2 Leben", "wesen_angriff": "Wesen kommen mit +1 Angriff",
"wesen_leben": "Wesen kommen mit +1 Leben", "wesen_leben": "Wesen kommen mit +1 Leben",
"extra_mana": "Bekommt jeden Zug 1 Mana mehr", "extra_mana": "Bekommt jeden Zug 1 Mana mehr",
"leben_35": "Beginnt mit 5 Leben mehr", "leben_35": "Beginnt mit 5 Leben mehr",
"schild_wesen": "Wesen kommen mit Schild", "schild_wesen": "Wesen kommen mit Schild",
"alle_flink": "Alle Wesen sind flink", "verbessern_billig": "Eigenschaften kosten 1 weniger",
"start_energie": "Beginnt mit 3 Mana", "start_energie": "Hat im ersten Zug 2 Mana mehr",
"resonanz_doppelt": "Resonanz zählt doppelt", "element_doppelt": "Elementbonus zählt doppelt",
} }
## Für wiederholbare Tests: 0 = echter Zufall, sonst Startwert (zählt je Aufruf hoch). ## 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 return r
class Creature extends RefCounted: class Creature extends RefCounted:
var card: Cards.Card var card: Cards.Card
var owner_ref: WeakRef # schwache Referenz: kein Zyklus Spieler <-> Wesen var owner_ref: WeakRef # schwache Referenz: kein Zyklus Spieler <-> Wesen
var name: String var name: String
var uid: int var uid: int
var attack: 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 health: int
var max_health: int var max_health: int
var shield: bool var shield: bool
var frozen := false
var ready: bool
var attacks_left: int 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: func _init(c: Cards.Card, o, id: int) -> void:
card = c card = c
@@ -63,43 +63,35 @@ class Creature extends RefCounted:
health = c.health health = c.health
max_health = c.health max_health = c.health
shield = "Schild" in c.keywords shield = "Schild" in c.keywords
ready = "Flink" in c.keywords attacks_left = attacks_per_turn() # frisch gelegte Wesen greifen sofort an
attacks_left = attacks_per_turn() if ready else 0
func owner(): func owner():
return owner_ref.get_ref() return owner_ref.get_ref()
## Angriffswert inklusive Resonanz, Feld und Boss-Merkmalen. ## Angriffswert inklusive Elementbonus und Boss-Merkmalen.
func power() -> int: func power() -> int:
var v := attack + resonance + field_bonus var v := attack + aura
var o = owner() var o = owner()
if o != null: if o != null:
if "feuer_angriff" in o.perks and "Feuer" in card.elements: if "feuer_angriff" in o.perks and "Feuer" in card.elements:
v += 1 v += 1
if "flink_angriff" in o.perks and "Flink" in card.keywords:
v += 1
return v return v
func attacks_per_turn() -> int: func attacks_per_turn() -> int:
return 2 if "Doppelschlag" in card.keywords else 1 return 2 if "Doppelschlag" in card.keywords else 1
func taunt() -> bool:
return "Wächter" in card.keywords
func can_attack() -> bool: 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: func clone(new_owner) -> Creature:
var c := Creature.new(card, new_owner, uid) var c := Creature.new(card, new_owner, uid)
c.attack = attack c.attack = attack
c.resonance = resonance
c.field_bonus = field_bonus
c.health = health c.health = health
c.max_health = max_health c.max_health = max_health
c.shield = shield c.shield = shield
c.frozen = frozen
c.ready = ready
c.attacks_left = attacks_left c.attacks_left = attacks_left
c.hidden = hidden
c.aura = aura
return c return c
@@ -109,33 +101,33 @@ class Player extends RefCounted:
var is_human: bool var is_human: bool
var life: int var life: int
var max_life: int var max_life: int
var shop: Array = [] # SHOP_SIZE Plätze, gekaufte sind null var offer: Array = [] # offene Wahl: angebotene Karten (Start: 5, Ziehen: 3)
var hand: Array = [] var picks_left := 0 # wie viele davon noch zu nehmen sind
var hand: Array = [] # eigene Exemplare (Cards.Card.copy)
var board: Array = [] var board: Array = []
var energy := 0 # Mana; nicht ausgegebenes bleibt liegen var energy := 0 # Mana in diesem Zug; der Rest verfällt
var max_energy := 0 # Einkommen je Zug 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 start_bonus := 0 # einmaliges Extra-Mana für den späteren Start
var order := 0 # Platz in der Zugreihenfolge var order := 0 # Platz in der Zugreihenfolge
var perks: Array = [] # Boss-Merkmale (siehe PERKS) 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 looks := {} # Karten-ID -> Seltenheitsstufe, reine Optik (siehe Profile)
var turns := 0 # begonnene eigene Züge 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 name = n
title = t title = t
is_human = h is_human = h
life = l life = l
max_life = l max_life = l
elements = els.duplicate()
func alive() -> bool: func alive() -> bool:
return life > 0 return life > 0
func clone() -> Player: 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.max_life = max_life
p.shop = shop.duplicate() p.offer = offer.duplicate()
p.picks_left = picks_left
p.hand = hand.duplicate() p.hand = hand.duplicate()
p.energy = energy p.energy = energy
p.max_energy = max_energy p.max_energy = max_energy
@@ -155,6 +147,7 @@ var opponent: Player # der andere am Tisch
var players: Array = [] # in Zugreihenfolge var players: Array = [] # in Zugreihenfolge
var winner_player: Player = null var winner_player: Player = null
var rng: RandomNumberGenerator var rng: RandomNumberGenerator
var deck: Array = [] # gemeinsamer Stapel, das Ende ist oben (nur Host und Einzelspieler)
var turn := 0 var turn := 0
var current_idx := 0 var current_idx := 0
var log: Array[String] = [] var log: Array[String] = []
@@ -162,8 +155,6 @@ var events: Array = [] # strukturierte Ereignisse für Animationen
var winner := "" # "", "human" oder "opponents" var winner := "" # "", "human" oder "opponents"
var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse
var next_uid := 1 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: 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: if r == null:
r = make_rng() r = make_rng()
rng = r rng = r
for e in Cards.ELEMENTS:
field_charge[e] = 0
func current() -> Player: func current() -> Player:
@@ -198,7 +187,7 @@ func ref_of(target) -> Dictionary:
return {"kind": "player", "index": players.find(target)} 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: func enemies_of(p: Player) -> Array:
return players.filter(func(o): return o != p and o.alive()) return players.filter(func(o): return o != p and o.alive())
@@ -215,7 +204,7 @@ func find_creature(uid: int) -> Creature:
return null 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: func clone() -> Game:
var copies := [] var copies := []
for p in players: for p in players:
@@ -223,27 +212,46 @@ func clone() -> Game:
var idx := players.find(human) var idx := players.find(human)
var g := Game.new(copies[idx], copies[1 - idx], RandomNumberGenerator.new()) var g := Game.new(copies[idx], copies[1 - idx], RandomNumberGenerator.new())
g.players = copies # gleiche Zugreihenfolge wie das Original g.players = copies # gleiche Zugreihenfolge wie das Original
g.deck = deck.duplicate()
g.turn = turn g.turn = turn
g.current_idx = current_idx g.current_idx = current_idx
g.winner = winner g.winner = winner
g.next_uid = next_uid g.next_uid = next_uid
g.field_element = field_element
g.field_charge = field_charge.duplicate()
g.quiet = true g.quiet = true
return g 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 --------------------------------------------------------------- # --- Zugablauf ---------------------------------------------------------------
func start(fixed_order: bool = false) -> void: func start(fixed_order: bool = false) -> void:
if not fixed_order: if not fixed_order:
shuffle(players, rng) # zufällige Zugreihenfolge shuffle(players, rng) # zufällige Zugreihenfolge
deck = DeckGen.deck(rng)
for i in players.size(): for i in players.size():
var p: Player = players[i] var p: Player = players[i]
p.order = i p.order = i
# Wer nicht beginnt, bekommt im ersten Zug einmalig 1 Mana extra. # Wer nicht beginnt, bekommt im ersten Zug einmalig 1 Mana extra.
p.start_bonus = 0 if i == 0 else 1 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 turn = 1
current_idx = 0 current_idx = 0
say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name))) say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name)))
@@ -253,61 +261,101 @@ func start(fixed_order: bool = false) -> void:
begin_turn(current()) begin_turn(current())
## Füllt den Laden neu: SHOP_SIZE Karten, höchstens so teuer wie das Einkommen plus eins. ## Nimmt n Karten vom Stapel, die höchstens max_cost kosten (was man sich in diesem Zug leisten
func roll_shop(p: Player) -> void: ## kann); zu teure wandern unter den Stapel. Ist der Stapel leer, kommt ein frischer.
p.shop = DeckGen.shop_cards(rng, p.max_energy, p.elements) 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. ## Solange eine Wahl offen ist, geht nichts anderes.
func buy(p: Player, slot: int) -> String: 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(): if winner != "" or p != current():
return "Nicht dein Zug" return [false, "Nicht dein Zug"]
if slot < 0 or slot >= p.shop.size() or p.shop[slot] == null: if blocked(p) != "":
return "Kein Angebot" return [false, blocked(p)]
var card: Cards.Card = p.shop[slot]
if p.energy < card.cost:
return "Nicht genug Mana"
if p.hand.size() >= MAX_HAND: if p.hand.size() >= MAX_HAND:
return "Hand voll" return [false, "Deine Hand ist voll"]
p.energy -= card.cost if p.energy < draw_cost(p):
p.shop[slot] = null return [false, "Nicht genug Mana"]
p.hand.append(card) return [true, ""]
say("%s kauft %s." % [p.name, card.name])
emit({"type": "buy", "player": players.find(p), "card": card})
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 "" return ""
## Würfelt den Laden neu. Gibt "" zurück oder eine Fehlermeldung. ## Nimmt eine der angebotenen Karten auf die Hand; jederzeit erlaubt, auch außerhalb des Zugs.
func reroll(p: Player) -> String: ## Was liegen bleibt, wandert unter den Stapel.
if winner != "" or p != current(): func pick(p: Player, index: int) -> String:
return "Nicht dein Zug" if winner != "":
if p.energy < REROLL_COST: return "Vorbei"
return "Nicht genug Mana" if p.offer.is_empty():
p.energy -= REROLL_COST return "Keine Wahl offen"
roll_shop(p) if index < 0 or index >= p.offer.size():
say("%s würfelt den Laden neu." % p.name) return "Keine solche Karte"
emit({"type": "reroll", "player": players.find(p)}) 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 "" return ""
func begin_turn(p: Player) -> void: func begin_turn(p: Player) -> void:
p.max_energy = mini(MAX_ENERGY, p.max_energy + 1) p.max_energy = START_MANA + p.turns
# Nicht ausgegebenes Mana bleibt liegen. p.energy = p.max_energy + (1 if "extra_mana" in p.perks else 0) # der Rest vom letzten Zug ist verfallen
p.energy += p.max_energy + (1 if "extra_mana" in p.perks else 0)
if p.turns == 0 and "start_energie" in p.perks: 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: if p.start_bonus > 0:
p.energy += p.start_bonus p.energy += p.start_bonus
say("%s erhält %d Mana extra für den späteren Start." % [p.name, p.start_bonus]) say("%s erhält %d Mana extra für den späteren Start." % [p.name, p.start_bonus])
p.start_bonus = 0 p.start_bonus = 0
roll_shop(p)
for c in p.board: for c in p.board:
c.ready = true c.health = c.max_health # Wesen erholen sich zu Zugbeginn ganz
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.attacks_left = c.attacks_per_turn()
p.turns += 1 p.turns += 1
say("--- Zug %d: %s ---" % [turn, p.name]) say("--- Zug %d: %s ---" % [turn, p.name])
@@ -317,6 +365,8 @@ func begin_turn(p: Player) -> void:
func end_turn() -> void: func end_turn() -> void:
if winner != "": if winner != "":
return return
while not current().offer.is_empty(): # offene Wahl: die erste Karte, damit nichts verfällt
pick(current(), 0)
var n := players.size() var n := players.size()
for i in range(1, n + 1): for i in range(1, n + 1):
var idx := (current_idx + i) % n var idx := (current_idx + i) % n
@@ -330,302 +380,296 @@ func end_turn() -> void:
# --- Karten spielen ---------------------------------------------------------- # --- 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: func can_play(p: Player, card: Cards.Card) -> Array:
if winner != "" or p != current(): if winner != "" or p != current():
return [false, "Nicht dein Zug"] return [false, "Nicht dein Zug"]
if blocked(p) != "":
return [false, blocked(p)]
if not (card in p.hand): if not (card in p.hand):
return [false, "Nicht auf der Hand"] return [false, "Nicht auf der Hand"]
if p.board.size() >= MAX_BOARD: if p.board.size() >= MAX_BOARD:
return [false, "Dein Feld ist voll"] return [false, "Dein Feld ist voll"]
if p.energy < card.cost:
return [false, "Nicht genug Mana"]
return [true, ""] return [true, ""]
func valid_targets(p: Player, target_type: String) -> Array: ## Spielt eine Karte, offen oder verdeckt. Gibt "" zurück oder eine Fehlermeldung.
var enemies := enemies_of(p) func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String:
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:
var ok := can_play(p, card) var ok := can_play(p, card)
if not ok[0]: if not ok[0]:
return ok[1] 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) p.hand.erase(card)
say("%s spielt %s." % [p.name, card.name]) p.energy -= card.cost
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:
var cr := Creature.new(card, p, next_uid) var cr := Creature.new(card, p, next_uid)
next_uid += 1 next_uid += 1
if "waechter_leben" in p.perks and cr.taunt(): cr.hidden = face_down
cr.health += 2 if "wesen_angriff" in p.perks:
cr.max_health += 2 cr.attack += 1
if "wesen_leben" in p.perks: if "wesen_leben" in p.perks:
cr.health += 1 cr.health += 1
cr.max_health += 1 cr.max_health += 1
if "schild_wesen" in p.perks: if "schild_wesen" in p.perks:
cr.shield = true cr.shield = true
if "alle_flink" in p.perks:
cr.ready = true
cr.attacks_left = cr.attacks_per_turn()
p.board.append(cr) p.board.append(cr)
emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card}) if face_down:
return cr 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. ## Ein eigenes verdecktes Wesen aufdecken: kostenlos, im eigenen Zug.
func can_fuse(p: Player, a: Creature, b: Creature) -> Array: func can_reveal(p: Player, cr: Creature) -> Array:
if winner != "" or p != current(): if winner != "" or p != current():
return [false, "Nicht dein Zug"] return [false, "Nicht dein Zug"]
if a == null or b == null or a == b: if blocked(p) != "":
return [false, "Zwei verschiedene Wesen wählen"] return [false, blocked(p)]
if not (a in p.board) or not (b in p.board): if cr == null or not (cr in p.board):
return [false, "Nur eigene Wesen"] 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 [false, "Nicht genug Mana"]
return [true, ""] return [true, ""]
## Verschmilzt zwei eigene Wesen zu einem. Es erbt Elemente und Schlüsselwörter beider func upgrade(p: Player, target, keyword: String) -> String:
## Karten, seine Werte sind der abgerundete Durchschnitt plus 1. Kosten: Durchschnitt der var ok := can_upgrade(p, target, keyword)
## Kartenkosten, aufgerundet. Eine Verschmelzung ist kein Ausspielen: sie löst keine if not ok[0]:
## Feldkombo aus und lädt das Feld nicht. 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: func fuse(p: Player, a: Creature, b: Creature) -> String:
var ok := can_fuse(p, a, b) var ok := can_fuse(p, a, b)
if not ok[0]: if not ok[0]:
return ok[1] return ok[1]
p.energy -= Cards.fusion_cost(a.card, b.card) p.energy -= fusion_cost(a, b)
var card := Cards.fused(a.card, b.card, (a.attack + b.attack) / 2 + 1, (a.health + b.health) / 2 + 1) 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) var cr := Creature.new(card, p, next_uid)
next_uid += 1 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.shield = cr.shield or a.shield or b.shield
cr.frozen = a.frozen or b.frozen cr.attacks_left = mini(a.attacks_left, b.attacks_left)
cr.ready = a.ready and b.ready
cr.attacks_left = 0 if not cr.ready else mini(a.attacks_left, b.attacks_left)
var at := p.board.find(a) var at := p.board.find(a)
p.board.erase(a) p.board.erase(a)
p.board.erase(b) p.board.erase(b)
p.board.insert(mini(at, p.board.size()), cr) 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]) 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]}) emit({"type": "fuse", "player": players.find(p), "uid": cr.uid, "gone": [a.uid, b.uid]})
update_resonance() apply_auras()
cleanup() cleanup()
check_end() check_end()
return "" return ""
## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3. # --- Vier-Elemente-Regel -----------------------------------------------------
static func resonance_for(partners: int) -> int:
return DeckGen.RESONANCE_STEPS[mini(partners, DeckGen.RESONANCE_STEPS.size() - 1)]
## Offene Wesen je Element auf dem ganzen Tisch.
## Vorschau fürs Ausspielen: Angriffsbonus aus Resonanz und Feld sowie die Feldkombo, func element_counts() -> Dictionary:
## die die Karte mit dem ersten eigenen Wesen aus anderem Element auslösen würde. var counts := {}
func forecast(p: Player, card: Cards.Card) -> Dictionary: for e in Cards.ELEMENTS:
var partners := 0 counts[e] = 0
var combo := ""
var partner: Creature = null
for o in p.board:
if o.card.elements.any(func(e): return e in card.elements):
partners += 1
if combo == "":
for e1 in card.elements:
for e2 in o.card.elements:
if e1 != e2 and combo == "":
combo = Cards.combo_name([e1, e2])
partner = o
var resonance := resonance_for(partners) * (2 if "resonanz_doppelt" in p.perks else 1)
var field := 1 if field_element != "" and field_element in card.elements else 0
return {"resonance": resonance, "field": field, "combo": combo, "partner": partner}
## Resonanz: abnehmender Angriffsbonus je Partner mit gleichem Element (siehe resonance_for).
func update_resonance() -> void:
for p in players: for p in players:
for c in p.board: for c in p.board:
var bonus := 0 if not c.hidden:
for o in p.board: for e in c.card.elements:
if o != c and o.card.elements.any(func(e): return e in c.card.elements): counts[e] += 1
bonus += 1 return counts
c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1)
c.field_bonus = 1 if field_element != "" and field_element in c.card.elements else 0
func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, elements: Array = []) -> void: ## Bonus aus der Elementregel für diese Elemente: +1, wenn ELEMENT_STACK offene Wesen des
var enemies := enemies_of(p) ## Elements stehen, -1, wenn das Element, das es schlägt, den Stapel voll hat.
var enemy_creatures := [] ## Mehrere Elemente nehmen den besten Wert.
for e in enemies: static func aura_of(elements: Array, counts: Dictionary) -> int:
enemy_creatures.append_array(e.board) var best := 0
var targets := [] for i in elements.size():
if fx.target in Cards.CHOSEN_TARGETS: var e: String = elements[i]
targets = [target] if target != null else [] var a := 0
elif fx.target == "all_enemy_creatures": if int(counts[e]) >= ELEMENT_STACK:
targets = enemy_creatures a += 1
elif fx.target == "all_enemy_heroes": var predator := Cards.beaten_by(e)
targets = enemies.duplicate() if predator != "" and int(counts[predator]) >= ELEMENT_STACK:
elif fx.target == "all_enemies": a -= 1
targets = enemy_creatures + enemies best = a if i == 0 else maxi(best, a)
elif fx.target == "all_friendly_creatures": return best
targets = p.board.duplicate()
elif fx.target == "self_hero":
targets = [p] ## Bonus eines offenen Wesens aus der Elementregel; verdeckte zählen nicht.
elif fx.target == "self_creature": func aura_for(c: Creature, counts: Dictionary) -> int:
targets = [source] if source != null else [] if c.hidden:
elif fx.target == "strongest_enemy_creature": return 0
var best: Creature = null var a := aura_of(c.card.elements, counts)
for c in enemy_creatures: var o = c.owner()
if best == null or c.power() > best.power(): if o != null and "element_doppelt" in o.perks:
best = c a *= 2
targets = [best] if best != null else [] return a
elif fx.target == "weakest_enemy_hero":
var weakest: Player = null
for e in enemies: ## Rechnet die Elementregel neu; Leben folgt dem Bonus direkt (+1/+1 bzw. -1/-1).
if weakest == null or e.life < weakest.life: func apply_auras() -> void:
weakest = e var counts := element_counts()
targets = [weakest] if weakest != null else [] for p in players:
else: for c in p.board:
targets = [null] var a := aura_for(c, counts)
for t in targets: if a != c.aura:
match fx.kind: var delta: int = a - c.aura
"damage": c.health += delta
damage(t, fx.amount + (Cards.matchup(elements, t.card.elements) if t is Creature else 0)) c.max_health += delta
"heal": c.aura = a
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}) ## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme.
"mana": func forecast(p: Player, card: Cards.Card) -> int:
p.energy += fx.amount var counts := element_counts()
say("%s erhält %d Mana." % [p.name, fx.amount]) for e in card.elements:
emit({"type": "mana", "player": players.find(p), "amount": fx.amount}) counts[e] += 1
"freeze": return aura_of(card.elements, counts) * (2 if "element_doppelt" in p.perks else 1)
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"})
# --- Kampf ------------------------------------------------------------------- # --- Kampf -------------------------------------------------------------------
## Leben lassen sich erst angreifen, wenn der Gegner kein Wesen mehr hat.
func can_attack(cr: Creature, target) -> Array: func can_attack(cr: Creature, target) -> Array:
if winner != "" or cr.owner() != current(): if winner != "" or cr.owner() != current():
return [false, "Nicht dein Zug"] 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(): if not cr.can_attack():
return [false, "%s kann nicht angreifen" % cr.name] return [false, "%s kann nicht angreifen" % cr.name]
var tgt_owner = target.owner() if target is Creature else target var tgt_owner = target.owner() if target is Creature else target
if not (tgt_owner in enemies_of(cr.owner())): if not (tgt_owner in enemies_of(cr.owner())):
return [false, "Kein gültiges Ziel"] return [false, "Kein gültiges Ziel"]
var has_taunt := false if not (target is Creature) and not tgt_owner.board.is_empty():
for c in tgt_owner.board: return [false, "Erst die Wesen"]
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"]
return [true, ""] 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: func attack(cr: Creature, target) -> String:
var ok := can_attack(cr, target) var ok := can_attack(cr, target)
if not ok[0]: if not ok[0]:
return ok[1] return ok[1]
cr.attacks_left -= 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]) say("%s greift %s an." % [cr.name, target.name])
emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)}) emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)})
damage(target, cr.power() + matchup_bonus(cr, target)) var dealt := cr.power() + matchup_bonus(cr, target)
if target is Creature and target.power() > 0: # wer nichts austeilt, kontert auch nicht if target is Creature:
damage(cr, target.power() + matchup_bonus(target, cr)) 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() cleanup()
check_end() check_end()
return "" 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: static func matchup_bonus(source: Creature, target) -> int:
if not (target is Creature): if not (target is Creature):
return 0 return 0
@@ -648,7 +692,9 @@ func damage(target, amount: int) -> void:
emit({"type": "damage", "target": ref_of(target), "amount": amount}) 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: func cleanup() -> void:
for round in 8:
var died := false var died := false
for p in players: for p in players:
for c in p.board.duplicate(): for c in p.board.duplicate():
@@ -661,8 +707,9 @@ func cleanup() -> void:
say("%s stirbt." % c.name) say("%s stirbt." % c.name)
emit({"type": "death", "target": ref_of(c)}) emit({"type": "death", "target": ref_of(c)})
died = true died = true
if died: if not died:
update_resonance() return
apply_auras()
## Ende: einer von beiden hat kein Leben mehr. ## Ende: einer von beiden hat kein Leben mehr.

View File

@@ -384,23 +384,37 @@ func _apply(id: int, action: Dictionary) -> void:
if game == null or not seats.has(id): if game == null or not seats.has(id):
return return
var p: Game.Player = game.players[seats[id]] 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 return
match str(action.get("type", "")): match str(action.get("type", "")):
"play": "play":
var idx := int(action.get("hand", -1)) var idx := int(action.get("hand", -1))
if idx < 0 or idx >= p.hand.size(): if idx < 0 or idx >= p.hand.size():
return 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": "attack":
var cr := game.find_creature(int(action.get("uid", -1))) var cr := game.find_creature(int(action.get("uid", -1)))
var t = resolve_ref(action.get("target")) var t = resolve_ref(action.get("target"))
if cr != null and t != null: if cr != null and t != null:
game.attack(cr, t) game.attack(cr, t)
"buy": "draw":
game.buy(p, int(action.get("slot", -1))) game.draw(p)
"reroll":
game.reroll(p)
"fuse": "fuse":
var a := game.find_creature(int(action.get("uid", -1))) var a := game.find_creature(int(action.get("uid", -1)))
var b := game.find_creature(int(action.get("other", -1))) var b := game.find_creature(int(action.get("other", -1)))

View File

@@ -2,17 +2,17 @@ class_name NetState
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln). ## 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. ## Karten reisen als ID; eigene Exemplare mit Schlüsselwörtern und verschmolzene als Ganzes.
## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer).
## Karten reisen als ID; verschmolzene stehen nicht im Pool und gehen als Ganzes mit.
static func card_ref(c: Cards.Card): 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: 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: static func snapshot(g: Game, viewer: int) -> Dictionary:
var ps := [] var ps := []
for i in g.players.size(): 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 mine := i == viewer or viewer == -1
var board := [] var board := []
for c in p.board: for c in p.board:
board.append({"uid": c.uid, "card": card_ref(c.card), var masked: bool = c.hidden and not mine
"attack": c.attack, "resonance": c.resonance, "field_bonus": c.field_bonus, board.append({"uid": c.uid, "card": card_ref(Cards.unknown() if masked else c.card),
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen, "attack": 0 if masked else c.attack, "health": 0 if masked else c.health,
"ready": c.ready, "attacks_left": c.attacks_left}) "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, 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, "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(), "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}) "board": board})
var evs := [] var evs := []
for ev in g.events: for ev in g.events:
@@ -38,7 +39,7 @@ static func snapshot(g: Game, viewer: int) -> Dictionary:
evs.append(e) evs.append(e)
return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx, 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, "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} "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 placeholder: Cards.Card = Cards.all()[0]
var list := [] var list := []
for pd in d.players: 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.max_life = int(pd.max_life)
p.energy = int(pd.energy) p.energy = int(pd.energy)
p.max_energy = int(pd.max_energy) p.max_energy = int(pd.max_energy)
@@ -63,20 +64,19 @@ static func from_snapshot(d: Dictionary) -> Game:
else: else:
for i in int(pd.hand): for i in int(pd.hand):
p.hand.append(placeholder) p.hand.append(placeholder)
for cid in pd.get("shop", []): for cid in pd.get("offer", []):
p.shop.append(Cards.by_id(cid) if str(cid) != "" else null) p.offer.append(Cards.by_id(cid))
p.picks_left = int(pd.get("picks_left", 0))
for cd in pd.board: for cd in pd.board:
var card := card_of(cd.card) var card := card_of(cd.card)
var c := Game.Creature.new(card, p, int(cd.uid)) var c := Game.Creature.new(card, p, int(cd.uid))
c.attack = int(cd.attack) 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.health = int(cd.health)
c.max_health = int(cd.max_health) c.max_health = int(cd.max_health)
c.shield = bool(cd.shield) c.shield = bool(cd.shield)
c.frozen = bool(cd.frozen)
c.ready = bool(cd.ready)
c.attacks_left = int(cd.attacks_left) 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) p.board.append(c)
list.append(p) list.append(p)
var hi := int(d.human) var hi := int(d.human)
@@ -87,9 +87,8 @@ static func from_snapshot(d: Dictionary) -> Game:
g.winner = d.winner g.winner = d.winner
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
g.next_uid = int(d.next_uid) g.next_uid = int(d.next_uid)
g.field_element = str(d.get("field_element", "")) for i in int(d.get("deck_size", 0)):
for e in Cards.ELEMENTS: g.deck.append(placeholder) # nur die Größe zählt; gezogen wird beim Host
g.field_charge[e] = int(d.get("field_charge", {}).get(e, 0))
for line in d.log: for line in d.log:
g.log.append(str(line)) g.log.append(str(line))
for ev in d.events: for ev in d.events:

View File

@@ -32,32 +32,21 @@ func play_events(evs: Array, centers: Dictionary) -> void:
"damage": "damage":
sfx("damage") sfx("damage")
popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay) 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": "blocked":
popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay) 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": "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": "fuse":
sfx("combo") sfx("combo")
popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0) popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
"field": "draw":
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":
sfx("click") sfx("click")

View File

@@ -1,12 +1,12 @@
class_name BattleGamepad class_name BattleGamepad
extends RefCounted extends RefCounted
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Laden, Hand, eigene Wesen ## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Stapel, Hand, eigene Wesen
## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, ## und Ziele (bei offener Wahl über deren Karten), A bestätigt, B bricht ab, Start beendet den
## X macht rückgängig. ## Zug, Y öffnet das Kartenmenü (im Gegnerzug: überspringen), X macht rückgängig.
var screen # BattleScreen var screen # BattleScreen
var active := false var active := false
var zone := "shop" var zone := "deck"
var index := 0 var index := 0
var targets: Array = [] var targets: Array = []
@@ -19,6 +19,12 @@ func button(button: int) -> void:
active = true active = true
if screen.game == null: if screen.game == null:
return 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: match button:
JOY_BUTTON_START: JOY_BUTTON_START:
if screen.my_turn() and not screen.any_overlay(): if screen.my_turn() and not screen.any_overlay():
@@ -27,10 +33,20 @@ func button(button: int) -> void:
if screen.confirm.visible: if screen.confirm.visible:
screen.confirm.visible = false screen.confirm.visible = false
screen.cancel_all() screen.cancel_all()
zone = "shop" zone = "deck"
JOY_BUTTON_Y: JOY_BUTTON_Y:
if screen.b_skip.visible: if screen.b_skip.visible:
screen.skip_ai = true 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: JOY_BUTTON_X:
if screen.b_undo.visible: if screen.b_undo.visible:
screen.undo() screen.undo()
@@ -49,7 +65,7 @@ func button(button: int) -> void:
## Wechselt den Bereich und überspringt leere (eine leere Hand ist keine Station). ## Wechselt den Bereich und überspringt leere (eine leere Hand ist keine Station).
## step 0: beim aktuellen Bereich bleiben, falls er etwas enthält. ## step 0: beim aktuellen Bereich bleiben, falls er etwas enthält.
func _next_zone(step: int) -> void: func _next_zone(step: int) -> void:
var order := ["shop", "hand", "own"] var order := ["deck", "hand", "own"]
var at := order.find(zone) var at := order.find(zone)
index = 0 index = 0
if step == 0 and not items().is_empty(): if step == 0 and not items().is_empty():
@@ -64,8 +80,10 @@ func _next_zone(step: int) -> void:
func items() -> Array: func items() -> Array:
match zone: match zone:
"shop": "deck":
return screen.shop_views return [screen.deck] if screen.deck.visible else []
"pick":
return screen.pick_views
"hand": "hand":
return screen.hand_views return screen.hand_views
"own": "own":
@@ -95,24 +113,13 @@ func confirm() -> void:
index = clampi(index, 0, items.size() - 1) index = clampi(index, 0, items.size() - 1)
var item = items[index] var item = items[index]
match zone: match zone:
"shop": "deck":
screen.on_click("shop", int(item.get_meta("slot", 0))) screen.on_click("deck", null)
"pick":
screen.do_pick(index)
index = 0
"hand": "hand":
screen.on_click("hand", item.card) 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": "own":
screen.on_click("own", item.creature) screen.on_click("own", item.creature)
if screen.selected != null: if screen.selected != null:

View File

@@ -1,7 +1,7 @@
class_name BattleLayout class_name BattleLayout
## Feste Bildschirmaufteilung des Duells (1280x800), von unten nach oben: Laden mit Neuwürfeln ## Feste Bildschirmaufteilung des Duells (1280x800), von unten nach oben: Hand mit dem
## und Zug beenden, Hand, eigenes Brett (mit eigener Leiste links), Feldband, oben der Gegner ## Zieh-Stapel links und Zug beenden rechts, eigenes Brett (mit eigener Leiste links),
## mit seinem Brett. ## 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_GIVEUP := Rect2(10, 10, 96, 32)
const B_LOG := Rect2(112, 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_FUSE := Rect2(1050, 298, 210, 40)
const B_UNDO := Rect2(1050, 346, 210, 40) const B_UNDO := Rect2(1050, 346, 210, 40)
const OWN_X := 345 const OWN_X := 345
const HAND_W := 96 const HAND_W := 150
const HAND_H := 62 const HAND_H := 205
const HAND_GAP := 4 const HAND_GAP := 10
const HAND_Y := 458 const HAND_Y := 532
const SHOP_Y := 532 const DECK := Rect2(50, 540, 130, 182)
const SHOP_W := 150
const SHOP_H := 205
const B_REROLL := Rect2(20, 606, 200, 52)
const B_END := Rect2(1060, 600, 200, 60) const 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 LOG_PANEL := Rect2(240, 60, 800, 560)
const PREVIEW_SIZE := Vector2(180, 252) const PREVIEW_SIZE := Vector2(180, 252)
const CENTER_X := 240 const CENTER_X := 240
@@ -37,12 +36,15 @@ static func opp_slot(j: int) -> Rect2:
return Rect2(OWN_X + j * 120, 76, 110, 140) return Rect2(OWN_X + j * 120, 76, 110, 140)
## Laden: fünf große Angebote unten, wie in TFT. ## Angebotene Karten einer Wahl, mittig im Wahlfenster.
static func shop_rect(i: int) -> Rect2: static func pick_rect(count: int, i: int) -> Rect2:
return Rect2(245 + i * (SHOP_W + 10), SHOP_Y, SHOP_W, SHOP_H) 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: static func hand_rect(count: int, i: int) -> Rect2:
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
var x := CENTER_X + (CENTER_W - total) / 2 var x := CENTER_X + (CENTER_W - total) / 2

View File

@@ -1,6 +1,8 @@
class_name BattleScreen class_name BattleScreen
extends Control extends Control
## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx. ## 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). ## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer).
var app var app
@@ -12,7 +14,6 @@ var game: Game
var perspective: Game.Player # der Mensch, dessen Hand unten liegt var perspective: Game.Player # der Mensch, dessen Hand unten liegt
var fx: BattleFx var fx: BattleFx
var selected = null # Game.Creature var selected = null # Game.Creature
var pending = null # Cards.Card, wartet auf Ziel
var fusing := false # Verschmelzen gewählt: zwei eigene Wesen anklicken var fusing := false # Verschmelzen gewählt: zwei eigene Wesen anklicken
var fuse_first = null # Game.Creature, erstes Wesen der Verschmelzung var fuse_first = null # Game.Creature, erstes Wesen der Verschmelzung
var msg_t := 0.0 var msg_t := 0.0
@@ -39,7 +40,7 @@ var undo_t := 0.0
# Ansichten (auch für Tests) # Ansichten (auch für Tests)
var tiles := {} var tiles := {}
var hand_views: Array = [] var hand_views: Array = []
var shop_views: Array = [] var pick_views: Array = [] # Karten der offenen Wahl
var own_views: Array = [] var own_views: Array = []
var enemy_views: Array = [] var enemy_views: Array = []
var hero_panels := {} # Player -> Panel var hero_panels := {} # Player -> Panel
@@ -49,7 +50,10 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
var hero_layer: Control var hero_layer: Control
var tile_layer: Control var tile_layer: Control
var hand_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 log_overlay: Control
var b_log: Button var b_log: Button
var status_panel: Panel var status_panel: Panel
@@ -64,15 +68,14 @@ var status_label: Label
var msg_label: Label var msg_label: Label
var b_end: Button var b_end: Button
var b_fuse: Button var b_fuse: Button
var b_reroll: Button
var b_giveup: Button var b_giveup: Button
var field_view: FieldView var element_view: ElementView
var link_view: Links # Resonanzlinien unter den Feldreihen var info_label: Label # Vorschau: Elementbonus, Merkmale
var info_label: Label # Vorschau: Resonanz, Feld, Feldkombo var board_panel: Panel # eigenes Brett
var board_panel: Panel # eigenes Brett, färbt sich mit dem Feld 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 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 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 overlay: Control
var result_label: Label var result_label: Label
var reward_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_giveup.add_theme_font_size_override("font_size", 13)
b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log) b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log)
b_log.add_theme_font_size_override("font_size", 13) 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) Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8)
field_view = FieldView.new(L.FIELD) element_view = ElementView.new(L.FIELD)
add_child(field_view) add_child(element_view)
msg_label = Style.label(self, "", 15, Style.ORANGE, L.MSG, "center", true) 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) board_panel = Style.panel(self, L.OWN_PANEL, Style.darker(Style.PANEL, 0.8), 10)
var slots := Control.new() 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 = Style.button(self, "Rückgängig", L.B_UNDO, undo)
b_undo.add_theme_font_size_override("font_size", 15) b_undo.add_theme_font_size_override("font_size", 15)
b_undo.visible = false b_undo.visible = false
# unten: Laden mit Neuwürfeln links und Zug beenden rechts # unten: Zieh-Stapel links, Hand in der Mitte, Zug beenden rechts
b_reroll = Style.button(self, "", L.B_REROLL, do_reroll, Color("285a5a")) deck = Deck.new(L.DECK)
b_reroll.add_theme_font_size_override("font_size", 15) 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 = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY)
b_end.add_theme_font_size_override("font_size", 20) b_end.add_theme_font_size_override("font_size", 20)
b_skip = Style.button(self, "Gegnerzüge überspringen", L.B_END, func(): skip_ai = true) b_skip = 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 = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false wait_label.visible = false
hero_layer = _layer() hero_layer = _layer()
link_view = Links.new()
add_child(link_view)
tile_layer = _layer() tile_layer = _layer()
hand_layer = _layer() hand_layer = _layer()
shop_layer = _layer()
preview_holder = _layer() preview_holder = _layer()
info_label = Style.label(self, "", 13, Style.TEXT, Rect2(0, 0, 220, 60), "center") info_label = Style.label(self, "", 13, Style.TEXT, Rect2(0, 0, 220, 60), "center")
info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
info_label.visible = false info_label.visible = false
fx_layer = _layer() 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() cursor_layer = _layer()
# Verlauf als Überlagerung # Verlauf als Überlagerung
log_overlay = Style.overlay(self) log_overlay = Style.overlay(self)
@@ -339,14 +354,23 @@ func my_turn() -> bool:
func any_overlay() -> 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: func _input(event: InputEvent) -> void:
if game == null: if game == null:
return return
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
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() cancel_all()
get_viewport().set_input_as_handled() # sonst schließt derselbe Klick das Menü gleich wieder
return return
if event is InputEventJoypadButton and event.pressed: if event is InputEventJoypadButton and event.pressed:
pad.button(event.button_index) pad.button(event.button_index)
@@ -357,9 +381,11 @@ func _input(event: InputEvent) -> void:
KEY_ESCAPE: KEY_ESCAPE:
if confirm.visible: if confirm.visible:
confirm.visible = false confirm.visible = false
elif menu_layer.visible:
close_menu()
elif log_overlay.visible: elif log_overlay.visible:
log_overlay.visible = false log_overlay.visible = false
elif selected != null or pending != null or fusing: elif selected != null or fusing:
cancel_all() cancel_all()
elif not any_overlay(): elif not any_overlay():
confirm.visible = true confirm.visible = true
@@ -375,15 +401,20 @@ func _input(event: InputEvent) -> void:
if overlay.visible: if overlay.visible:
leave() 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 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]) 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: func cancel_all() -> void:
if confirm.visible: if confirm.visible:
confirm.visible = false confirm.visible = false
close_menu()
cancel() cancel()
refresh() refresh()
@@ -438,7 +469,7 @@ func _process(delta: float) -> void:
func _start_ai(cur: Game.Player) -> 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 copy := ai_copy
var idx := game.current_idx var idx := game.current_idx
var box := ai_box var box := ai_box
@@ -472,25 +503,24 @@ func say(m: String) -> void:
func cancel() -> void: func cancel() -> void:
selected = null selected = null
pending = null
fusing = false fusing = false
fuse_first = null fuse_first = null
func play(card: Cards.Card, target) -> void: func play(card: Cards.Card, face_down: bool = false) -> void:
if is_client(): 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 return
var before: Game = game.clone() if not is_online() else null var before: Game = game.clone() if not is_online() else null
var log_len := game.log.size() var log_len := game.log.size()
var from := _hand_rect_of(card) 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 != "": if err != "":
say(tr(err)) say(tr(err))
return return
if from.size.x > 0 and not perspective.board.is_empty() and perspective.board[-1].card == card: if from.size.x > 0 and not perspective.board.is_empty() and perspective.board[-1].card == card:
arrivals[perspective.board[-1].uid] = from 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": if before != null and game.events.size() == 1 and game.events[0].type == "play":
undo_state = before undo_state = before
undo_log_len = log_len undo_log_len = log_len
@@ -499,6 +529,84 @@ func play(card: Cards.Card, target) -> void:
clear_undo() 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). ## Verschmelzen an- und abwählen (zwei eigene Wesen, siehe Game.fuse).
func toggle_fuse() -> void: func toggle_fuse() -> void:
var on := not fusing var on := not fusing
@@ -508,37 +616,36 @@ func toggle_fuse() -> void:
after_action() after_action()
## Kauft ein Angebot aus dem Laden auf die Hand. ## Ziehen: kostet Mana, öffnet die Wahl aus drei Karten.
func do_buy(slot: int) -> void: func do_draw() -> void:
clear_undo() clear_undo()
if is_client(): if is_client():
net.send_action({"type": "buy", "slot": slot}) net.send_action({"type": "draw"})
return return
var from := BattleLayout.shop_rect(slot) var err := game.draw(perspective)
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)
if err != "": if err != "":
say(tr(err)) say(tr(err))
else: 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() after_action()
@@ -609,15 +716,6 @@ func on_click(kind: String, obj) -> void:
do_fuse(fuse_first, obj) do_fuse(fuse_first, obj)
after_action() after_action()
return 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: if selected != null:
var cr = selected var cr = selected
selected = null selected = null
@@ -632,18 +730,18 @@ func on_click(kind: String, obj) -> void:
after_action() after_action()
return return
match kind: match kind:
"shop": "deck":
do_buy(int(obj)) do_draw()
"hand": "hand":
var ok := g.can_play(perspective, obj) var ok := g.can_play(perspective, obj)
if not ok[0]: if not ok[0]:
say(tr(ok[1])) 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: else:
play(obj, null) play(obj)
"own": "own":
if obj.hidden:
do_reveal(obj)
return
if obj.can_attack(): if obj.can_attack():
selected = obj selected = obj
say(tr("Wähle ein Angriffsziel")) say(tr("Wähle ein Angriffsziel"))
@@ -669,8 +767,6 @@ func announce_turn() -> void:
banner_turn = game.turn banner_turn = game.turn
fx.banner(tr("Dein Zug"), Style.GOLD) fx.banner(tr("Dein Zug"), Style.GOLD)
fx.sfx("turn") fx.sfx("turn")
deal_shop = true
refresh()
func _hand_rect_of(card: Cards.Card) -> Rect2: 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 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: func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void:
var f := game.forecast(perspective, card) var a := game.forecast(perspective, card)
var lines: Array[String] = [] var info := ""
var parts: Array[String] = [] if a > 0:
if f.resonance > 0: info = tr("Elementbonus +%d/+%d") % [a, a]
parts.append(tr("Resonanz +%d") % f.resonance) elif a < 0:
if f.field > 0: info = tr("Elementbonus %d/%d") % [a, a]
parts.append(tr("Feld +%d") % f.field) show_preview(card, entered, perspective.looks, info, anchor)
if not parts.is_empty():
lines.append(" · ".join(parts))
if f.combo != "":
lines.append(tr("Feldkombo %s mit %s: %s") % [tr(f.combo), f.partner.card.title(), tr(Cards.FIELD_COMBO_TEXT[f.combo])])
for t in tiles.values():
t.mark = ""
t.queue_redraw()
if entered and f.partner != null and tiles.has(f.partner.uid):
tiles[f.partner.uid].mark = f.combo
tiles[f.partner.uid].queue_redraw()
show_preview(card, entered, perspective.looks, "\n".join(lines), anchor)
## Hand- und Ladenkarte tragen ihren Bonus und ihre Feldkombo als Vorschau. ## 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: func apply_forecast(v: CardView) -> void:
var f := game.forecast(perspective, v.card) v.bonus = game.forecast(perspective, v.card)
v.bonus = f.resonance + f.field
v.tag = f.combo
## Was den Angriff eines Wesens gerade hebt. ## Was den Angriff eines Wesens gerade hebt oder senkt.
func creature_info(cr: Game.Creature) -> String: func creature_info(cr: Game.Creature) -> String:
var parts: Array[String] = [] var parts: Array[String] = []
if cr.resonance > 0: if cr.aura > 0:
parts.append(tr("Resonanz +%d") % cr.resonance) parts.append(tr("Elementbonus +%d/+%d") % [cr.aura, cr.aura])
if cr.field_bonus > 0: elif cr.aura < 0:
parts.append(tr("Feld +%d") % cr.field_bonus) parts.append(tr("Elementbonus %d/%d") % [cr.aura, cr.aura])
var perk := cr.power() - cr.attack - cr.resonance - cr.field_bonus var perk := cr.power() - cr.attack - cr.aura
if perk > 0: if perk > 0:
parts.append(tr("Merkmal +%d") % perk) parts.append(tr("Merkmal +%d") % perk)
if cr.hidden:
parts.append(tr("verdeckt"))
return " · ".join(parts) return " · ".join(parts)
## Elementvorteil gegen ein mögliches Ziel: des gewählten Angreifers oder eines Schadenszaubers auf der Hand. ## Elementvorteil des gewählten Angreifers gegen ein mögliches Ziel; gegen Verdeckte unbekannt.
func matchup_for(cr: Game.Creature, valid: Array) -> int: func matchup_for(cr: Game.Creature) -> int:
if selected != null and game.can_attack(selected, cr)[0]: if selected != null and not cr.hidden and game.can_attack(selected, cr)[0]:
return Game.matchup_bonus(selected, cr) 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 return 0
## Resonanzgruppen je Reihe: Wesen mit gleichem Element werden unter der Reihe verbunden. class Deck extends Control:
func resonance_groups() -> Array: signal clicked
var out := [] var enabled := true
for p in game.players: var count := 0
var k := 0 var cost := Game.DRAW_COST
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
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_STOP
class Links extends Control: func _gui_input(event: InputEvent) -> void:
var groups: Array = [] if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
func _init() -> void: clicked.emit()
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void: func _draw() -> void:
for gr in groups: for k in range(2, -1, -1):
var y: float = gr.y var r := Rect2(Vector2(k * 3, -k * 3), size - Vector2(6, 12)).grow(0)
draw_line(Vector2(gr.xs[0], y), Vector2(gr.xs[-1], y), gr.color, 3.0) r.position.y += 6
for x in gr.xs: draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD if enabled else Style.MUTED, 2), r)
draw_circle(Vector2(x, y), 4.0, gr.color) 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 ------------------------------------------------------------------------------- # --- Netzwerk -------------------------------------------------------------------------------
@@ -845,9 +963,7 @@ func refresh() -> void:
var h := perspective var h := perspective
var now := Time.get_ticks_msec() var now := Time.get_ticks_msec()
var mine := my_turn() var mine := my_turn()
var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else []
hand_views = [] hand_views = []
shop_views = []
own_views = [] own_views = []
enemy_views = [] enemy_views = []
@@ -858,7 +974,7 @@ func refresh() -> void:
if g.current() == o: if g.current() == o:
bcol = Style.BLUE bcol = Style.BLUE
bw = 3 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 bcol = Style.ORANGE
bw = 3 bw = 3
if int(flash_until.get(g.players.find(o), 0)) > now: 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) labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED)
if o.alive(): if o.alive():
labels.life.text = "♥ %d" % maxi(o.life, 0) 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: else:
labels.life.text = "besiegt" labels.life.text = "besiegt"
labels.life.add_theme_color_override("font_color", Style.MUTED) labels.life.add_theme_color_override("font_color", Style.MUTED)
@@ -898,7 +1014,11 @@ func refresh() -> void:
tiles[cr.uid] = tile tiles[cr.uid] = tile
tile_layer.add_child(tile) tile_layer.add_child(tile)
var looks: Dictionary = p.looks 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 var uid := cr.uid
tile.clicked.connect(func(_v): tile.clicked.connect(func(_v):
var c := game.find_creature(uid) var c := game.find_creature(uid)
@@ -920,7 +1040,7 @@ func refresh() -> void:
elif fusing and p == h and mine: elif fusing and p == h and mine:
b = Style.PURPLE b = Style.PURPLE
w = 3 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 b = Style.ORANGE
w = 3 w = 3
elif p == h and mine and cr.can_attack(): elif p == h and mine and cr.can_attack():
@@ -928,8 +1048,8 @@ func refresh() -> void:
w = 3 w = 3
tile.border = b tile.border = b
tile.width = w tile.width = w
tile.mark = "" tile.secret = cr.hidden and p != h
tile.matchup = matchup_for(cr, valid) tile.matchup = matchup_for(cr)
tile.queue_redraw() tile.queue_redraw()
if p == h: if p == h:
own_views.append(tile) own_views.append(tile)
@@ -940,31 +1060,12 @@ func refresh() -> void:
var t: CreatureView = tiles[uid] var t: CreatureView = tiles[uid]
tiles.erase(uid) tiles.erase(uid)
fx.fade_out(t) fx.fade_out(t)
link_view.groups = resonance_groups() build_pick()
link_view.queue_redraw() deck.visible = not spectating()
deck.enabled = mine and g.can_draw(h)[0]
Style.clear(shop_layer) deck.count = g.deck.size()
if not spectating(): deck.cost = g.draw_cost(h)
for i in h.shop.size(): deck.queue_redraw()
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
Style.clear(hand_layer) Style.clear(hand_layer)
if spectating(): if spectating():
@@ -978,9 +1079,7 @@ func refresh() -> void:
v.position = r.position v.position = r.position
v.lift = mine v.lift = mine
apply_forecast(v) apply_forecast(v)
if card == pending: if playable:
v.border = Style.YELLOW
elif playable:
v.border = Style.GREEN v.border = Style.GREEN
v.dim = not playable v.dim = not playable
v.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(v.position, v.size))) 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: if int(flash_until.get(g.players.find(h), 0)) > now:
bar_border = Style.RED 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)) 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 name_label.text = h.name
life_label.text = "♥ %d" % maxi(h.life, 0) life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d" % h.energy 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]) 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(): if spectating():
status_label.text = tr("Zuschauer") + " · " + status_label.text 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_end.visible = not ai_running and not spectating()
b_undo.visible = undo_state != null and mine b_undo.visible = undo_state != null and mine
b_fuse.visible = mine and not spectating() and h.board.size() >= 2 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") 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 lines: Array[String] = []
var start := maxi(0, g.log.size() - 60) var start := maxi(0, g.log.size() - 60)
for i in range(start, g.log.size()): for i in range(start, g.log.size()):
var line := g.log[i] var line := g.log[i]
if line.begins_with("---"): if line.begins_with("---"):
lines.append("[color=#6eaaf0]%s[/color]" % line) 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) lines.append("[color=#8c78dc]%s[/color]" % line)
elif line.begins_with("Feldkombo"): elif " bekommt " in line:
lines.append("[color=#f5c850]%s[/color]" % line) lines.append("[color=#5ad282]%s[/color]" % line)
else: else:
lines.append(line) lines.append(line)
log_box.text = "\n".join(lines) log_box.text = "\n".join(lines)

View File

@@ -88,17 +88,9 @@ func build() -> void:
var next := selected == p.campaign_stage var next := selected == p.campaign_stage
Style.panel(self, Rect2(240, 270, 800, 300)) 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) Style.label(self, "%s · %s" % [st.name, st.title], 28, Style.TEXT, Rect2(270, 290, 740, 40), "center", true)
var dots := Control.new() 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")
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
var elems: Array = st.elements
dots.draw.connect(func():
var w := elems.size() * 26 - 6
for m in elems.size():
dots.draw_circle(Vector2(640 - w / 2.0 + 10 + m * 26, 348), 10, Cards.ELEMENT_COLORS[elems[m]]))
add_child(dots)
Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 370, 740, 24), "center")
var perks: Array = st.perks var perks: Array = st.perks
var py := 402 var py := 390
for k in perks: for k in perks:
Style.label(self, "★ " + tr(Game.PERKS[k]), 15, Style.GOLD, Rect2(270, py, 740, 22), "center") Style.label(self, "★ " + tr(Game.PERKS[k]), 15, Style.GOLD, Rect2(270, py, 740, 22), "center")
py += 24 py += 24

View File

@@ -12,8 +12,7 @@ var card: Cards.Card
var border = null # Color oder null (dann Rahmen der Seltenheitsstufe) var border = null # Color oder null (dann Rahmen der Seltenheitsstufe)
var rarity := "gewöhnlich" # nur Optik var rarity := "gewöhnlich" # nur Optik
var dim := false var dim := false
var bonus := 0 # Vorschau im Kampf: Angriff, den die Karte auf dem Feld dazubekäme var bonus := 0 # Vorschau im Kampf: Elementbonus, den die Karte offen gelegt bekäme (+1 oder -1)
var tag := "" # Vorschau im Kampf: Feldkombo, die das Ausspielen auslöst
var lift := false # hebt sich beim Zeigen an (Hand und Laden) 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 y += icon * 2 + 8
# Effekttext: so hoch wie nötig, endet über den Wertkreisen # Effekttext: so hoch wie nötig, endet über den Wertkreisen
var box := Rect2() var box := Rect2()
var fx := card.effect_text() var fx := card.text()
var box_bottom: float = size.y - r_stat * 2 - 4 var box_bottom: float = size.y - r_stat * 2 - 4
if fx != "" and box_bottom - y > Style.fs(f_text) + 6: if fx != "" and box_bottom - y > Style.fs(f_text) + 6:
var tsize := Style.fs(f_text) 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
var needed: float = Style.FONT.get_multiline_string_size(fx, HORIZONTAL_ALIGNMENT_LEFT, size.x - 20, tsize).y + 8 + (tsize + 2 if heading else 0)
var box_top: float = maxf(y, y + (box_bottom - y - needed) / 2) var box_top: float = maxf(y, y + (box_bottom - y - needed) / 2)
box = Rect2(6, box_top, size.x - 12, box_bottom - box_top) box = Rect2(6, box_top, size.x - 12, box_bottom - box_top)
draw_rect(box, Color(0.06, 0.06, 0.09, 0.72)) draw_rect(box, Color(0.06, 0.06, 0.09, 0.72))
var ty := box.position.y + 4 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))) 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)), 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) fx, HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
# Werte springen aus den unteren Ecken, dazwischen der Edelstein # 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(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) 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: if bonus != 0:
Style.text_outlined(self, "+%d" % bonus, f_text, Style.GREEN, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3) Style.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.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.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) 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) Style.rarity_sparkle(self, rect, rarity, seed, box)
@@ -170,10 +164,6 @@ func _draw_compact(rect: Rect2, frame: Color, seed: int) -> void:
var nf := Style.fit_size(card.title(), true, 12, name_w) 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.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) 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] = [] var kws: Array[String] = []
for k in Cards.KEYWORDS: for k in Cards.KEYWORDS:
if k in card.keywords: if k in card.keywords:
@@ -184,15 +174,6 @@ func _draw_compact(rect: Rect2, frame: Color, seed: int) -> void:
x += 18 x += 18
Style.stat_circle(self, Vector2(11, size.y - 11), 10, str(card.attack), Style.AMBER, 12) 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) Style.stat_circle(self, Vector2(size.x - 11, size.y - 11), 10, str(card.health), Style.CRIMSON, 12)
if bonus > 0: if bonus != 0:
Style.text_outlined(self, "+%d" % bonus, 10, Style.GREEN, Vector2(4, size.y - 29), "midleft", 3) 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) 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)

View File

@@ -11,8 +11,8 @@ var width := 2
var flash := false # kurz rot: wurde gerade angegriffen var flash := false # kurz rot: wurde gerade angegriffen
var slot: Vector2 # Zielposition im Raster var slot: Vector2 # Zielposition im Raster
var busy := false # wird gerade animiert (kein Neupositionieren) 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 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: func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
@@ -45,36 +45,31 @@ func rarity() -> String:
func _draw() -> void: func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size) var rect := Rect2(Vector2.ZERO, size)
if secret:
_draw_back(rect)
return
var small := size.x < 100 var small := size.x < 100
var mini := size.x < 60
var rar := rarity() var rar := rarity()
var seed := hash(creature.card.id) + creature.uid var seed := hash(creature.card.id) + creature.uid
if rar != "gewöhnlich": 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) 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) Style.rarity_sheen(self, rect, rar, seed)
var bcol: Color = border if border != null else Style.rarity_frame(rar, seed) var bcol: Color = border if border != null else Style.rarity_frame(rar, seed)
if flash: if flash:
bcol = Style.RED bcol = Style.RED
Style.border(self, rect, bcol, 4 if flash else width, 6) 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 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 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) 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)) 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) 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 icon := 7.0 if small else 9.0
var kws: Array[String] = [] var kws: Array[String] = []
for k in Cards.KEYWORDS: 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) kws.append(k)
if creature.shield and not ("Schild" in kws): if creature.shield and not ("Schild" in kws):
kws.append("Schild") kws.append("Schild")
@@ -85,23 +80,31 @@ func _draw() -> void:
x += icon * 2 + 6 x += icon * 2 + 6
_draw_stats(15 if small else 17, 10 if small else 12, 12.0 if small else 14.0) _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) Style.rarity_gem(self, rar, Vector2(size.x / 2, size.y - 9), 5, seed)
if mark != "": if creature.hidden:
var w := Style.FONT_BOLD.get_string_size(tr(mark), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(11)).x + 14 draw_rect(rect, Color(0.05, 0.05, 0.1, 0.55))
draw_style_box(Style.box(Color("14141c"), 6, Style.GOLD, 1), Rect2(size.x / 2 - w / 2, -9, w, 18)) Style.text_outlined(self, tr("verdeckt"), 12, Style.TEXT, Vector2(size.x / 2, size.y / 2), "center", 3)
Style.text(self, tr(mark), 11, Style.GOLD, Vector2(size.x / 2, 0), "center", true)
if creature.frozen:
draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35))
Style.text_outlined(self, "❄", 22 if small else 28, Color("dff2ff"), size / 2, "center", 3)
Style.rarity_sparkle(self, rect, rar, seed) 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: func _draw_stats(f: int, f_small: int, r: float) -> void:
var extra := creature.power() - creature.attack 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) Style.stat_circle(self, Vector2(r - 1, size.y - r + 1), r, str(creature.power()), atk_col, f)
if extra > 0: if extra != 0:
Style.text_outlined(self, "+%d" % extra, f_small, Style.GREEN, Vector2(r * 2 + 3, size.y - r + 1), "midleft", 3) 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") 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) Style.stat_circle(self, Vector2(size.x - r + 1, size.y - r + 1), r, str(creature.health), hp_col, f)
if matchup != 0: if matchup != 0:
@@ -109,26 +112,3 @@ func _draw_stats(f: int, f_small: int, r: float) -> void:
draw_circle(c, 11, Color("14141c")) draw_circle(c, 11, Color("14141c"))
Style.text(self, ("+%d" % matchup) if matchup > 0 else ("−%d" % -matchup), f_small + 1, 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) 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))

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
class_name OpponentSelectScreen class_name OpponentSelectScreen
extends Control extends Control
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf ## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf
## aus dem Laden (siehe DeckGen.shop_cards). ## per Ziehen (siehe DeckGen.offer_cards).
var app var app
var diff_buttons := {} var diff_buttons := {}

View File

@@ -344,9 +344,19 @@ static func rarity_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int) ->
var lx := x0 + (w - lw) / 2 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)]) 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): for poly in Geometry2D.intersect_polygons(band, frame):
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])) 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. ## Funkeln ab legendär: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen.
## avoid: Bereich, der frei bleibt (der Kartentext). ## avoid: Bereich, der frei bleibt (der Kartentext).
static func rarity_sparkle(ci: CanvasItem, rect: Rect2, rar: String, seed: int, avoid: Rect2 = Rect2()) -> void: static func rarity_sparkle(ci: CanvasItem, rect: Rect2, rar: String, seed: int, avoid: Rect2 = Rect2()) -> void:
@@ -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) text_outlined(ci, value, font, Color.WHITE, c, "center", 3)
## Schlüsselwort als Symbol auf dunklem Kreis: Wächter Mauer, Schild Schild, Flink Doppelpfeil, ## Schlüsselwort als Symbol auf dunklem Kreis: Schild Schild, Doppelschlag gekreuzte Klingen.
## Doppelschlag gekreuzte Klingen.
static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: Color = Color("f2f2f6")) -> void: 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)) ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.7))
var w := maxf(1.5, r * 0.22) var w := maxf(1.5, r * 0.22)

View File

@@ -19,11 +19,15 @@ func _ready() -> void:
await get_tree().process_frame await get_tree().process_frame
await get_tree().process_frame await get_tree().process_frame
var h: Game.Player = scr.game.human 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() var t0 := Time.get_ticks_usec()
for i in 30: for i in 30:
scr.refresh() 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 scr.ai_delay = 0.2
measuring = true measuring = true
var start := Time.get_ticks_msec() var start := Time.get_ticks_msec()

View File

@@ -73,22 +73,20 @@ func _act_host() -> void:
_decide(game.players[seat]) _decide(game.players[seat])
## Einfache Züge mit kurzer Bedenkzeit: erst das billigste bezahlbare Angebot kaufen, dann das ## Einfache Züge mit kurzer Bedenkzeit: erst eine offene Wahl treffen, dann das erste
## erste spielbare Wesen ohne Ziel, dann angreifen, sonst Zug beenden. ## spielbare Wesen ohne Ziel legen, dann angreifen, dann ziehen, sonst Zug beenden.
func _decide(me: Game.Player) -> void: func _decide(me: Game.Player) -> void:
var g := game var g := game
await get_tree().create_timer(0.25).timeout await get_tree().create_timer(0.25).timeout
if game != g or (hosting and (net.game == null or net.game.current_idx != seat)): if game != g or (hosting and (net.game == null or net.game.current_idx != seat)):
return # inzwischen ein neuer Zustand return # inzwischen ein neuer Zustand
for i in me.shop.size(): if not me.offer.is_empty():
var offer = me.shop[i] net.send_action({"type": "pick", "index": 0})
if offer != null and me.energy >= offer.cost and me.hand.size() < Game.MAX_HAND:
net.send_action({"type": "buy", "slot": i})
return return
for i in me.hand.size(): for i in me.hand.size():
var card: Cards.Card = me.hand[i] var card: Cards.Card = me.hand[i]
if game.can_play(me, card)[0] and card.target_type() == "": if game.can_play(me, card)[0]:
net.send_action({"type": "play", "hand": i, "target": null}) net.send_action({"type": "play", "hand": i})
return return
for cr in me.board: for cr in me.board:
if cr.can_attack(): if cr.can_attack():
@@ -100,4 +98,7 @@ func _decide(me: Game.Player) -> void:
if game.can_attack(cr, c)[0]: if game.can_attack(cr, c)[0]:
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}}) net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}})
return return
if game.can_draw(me)[0]:
net.send_action({"type": "draw"})
return
net.send_action({"type": "end"}) net.send_action({"type": "end"})

View File

@@ -14,7 +14,7 @@ func _initialize() -> void:
print("Kartenpool laden: %.1f ms" % t) print("Kartenpool laden: %.1f ms" % t)
var r := RandomNumberGenerator.new() var r := RandomNumberGenerator.new()
r.seed = 3 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)) print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20))
for n in [1]: for n in [1]:
var g := Game.new_battle(r) var g := Game.new_battle(r)

File diff suppressed because it is too large Load Diff

View File

@@ -51,9 +51,13 @@ func click(node: Control) -> void:
func right_click() -> void: func right_click() -> void:
await right_click_at(Vector2(5, 5))
func right_click_at(pos: Vector2) -> void:
var down := InputEventMouseButton.new() var down := InputEventMouseButton.new()
down.position = Vector2(5, 5) down.position = pos
down.global_position = down.position down.global_position = pos
down.button_index = MOUSE_BUTTON_RIGHT down.button_index = MOUSE_BUTTON_RIGHT
down.pressed = true down.pressed = true
get_viewport().push_input(down) get_viewport().push_input(down)
@@ -149,7 +153,7 @@ func _ready() -> void:
await frames(3) await frames(3)
var fast := OS.get_environment("DINGER_FAST") != "" var fast := OS.get_environment("DINGER_FAST") != ""
var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen, 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: if not fast:
suite += [test_kampf.bind(3), test_kampf_abbrechen] suite += [test_kampf.bind(3), test_kampf_abbrechen]
# Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten. # 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 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 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: for i in 12:
var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0]) var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0])
if playable.is_empty(): if playable.is_empty():
@@ -234,9 +245,22 @@ func human_turn(scr) -> void:
var card: Cards.Card = playable[0].card var card: Cards.Card = playable[0].card
var board_before: int = g.human.board.size() var board_before: int = g.human.board.size()
await click(playable[0]) 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) 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: for i in 12:
var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack()) var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack())
if ready.is_empty() or g.winner != "": if ready.is_empty() or g.winner != "":
@@ -249,7 +273,7 @@ func human_turn(scr) -> void:
if scr.selected != cr: if scr.selected != cr:
print("DEBUG Auswahl fehlgeschlagen: rect=", ready[0].get_global_rect(), " scale=", ready[0].scale, 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, " 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") check(scr.selected == cr, "Wesen gewählt")
var targets := [] var targets := []
for o in [g.opponent]: for o in [g.opponent]:
@@ -269,6 +293,7 @@ func start_battle() -> void:
await click(find_button("Einzelspieler")) await click(find_button("Einzelspieler"))
check(screen() is OpponentSelectScreen, "Gegnerauswahl") check(screen() is OpponentSelectScreen, "Gegnerauswahl")
await click(find_button("Starten")) await click(find_button("Starten"))
await pick_all(screen())
func test_gegnerauswahl() -> void: func test_gegnerauswahl() -> void:
@@ -285,8 +310,12 @@ func test_gegnerauswahl() -> void:
check(hard is BattleScreen and hard.game.players.size() == 2, "Duell gestartet") check(hard is BattleScreen and hard.game.players.size() == 2, "Duell gestartet")
var opp: Game.Player = hard.game.opponent 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(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(), check(hard.game.human.offer.size() == Game.START_OFFER and hard.game.human.hand.is_empty() and hard.pick_layer.visible,
"Start mit vollem Laden und leerer Hand") "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") check(hard.hero_panels[opp].position == BattleLayout.OPP_BOX.position, "der Gegner steht oben in der Mitte")
await shot("11_kampf") await shot("11_kampf")
await give_up() await give_up()
@@ -294,9 +323,10 @@ func test_gegnerauswahl() -> void:
await click(find_button("Leicht")) await click(find_button("Leicht"))
await click(find_button("Starten")) await click(find_button("Starten"))
var easy = screen() var easy = screen()
await pick_all(easy)
opp = easy.game.opponent 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(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 give_up()
await click(find_button("Einzelspieler")) await click(find_button("Einzelspieler"))
await click(find_button("Normal")) await click(find_button("Normal"))
@@ -457,39 +487,47 @@ func test_kampf_steuerung() -> void:
scr.ai_delay = 0.0 scr.ai_delay = 0.0
await wait_for_human(scr) await wait_for_human(scr)
var g: Game = scr.game 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() scr.refresh()
check(scr.shop_views.size() == Game.SHOP_SIZE and g.human.hand.is_empty(), "voller Laden, leere Hand") check(scr.hand_views.size() == 1 and scr.deck.enabled and scr.pick_views.is_empty(), "eine Handkarte, Stapel bereit, keine Wahl offen")
# Kaufen: Mana weg, Karte auf der Hand # Ziehen: 1 Mana weg, drei zur Wahl, eine landet auf der Hand
var offer: Cards.Card = scr.shop_views[0].card
var mana: int = g.human.energy var mana: int = g.human.energy
await click(scr.shop_views[0]) await click(scr.deck)
check(g.human.hand.size() == 1 and g.human.hand[0] == offer and g.human.energy == mana - offer.cost, "Angebot gekauft") 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")
# Ausspielen kostet nichts 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() var board_before: int = g.human.board.size()
mana = g.human.energy mana = g.human.energy
await key(KEY_1) await key(KEY_1)
check(g.human.board.size() == board_before + 1 and g.human.energy == mana, "Wesen per Taste gespielt, ohne Mana") 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 # Rückgängig: Wesen zurück auf die Hand, Mana zurück
check(scr.b_undo.visible, "Rückgängig angeboten") check(scr.b_undo.visible, "Rückgängig angeboten")
await click(scr.b_undo) await click(scr.b_undo)
g = scr.game 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") check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht")
await key(KEY_1) await key(KEY_1)
check(g.human.board.size() == board_before + 1, "erneut gespielt") check(g.human.board.size() == board_before + 1, "erneut gespielt")
# Neu würfeln kostet Mana # Ziehen per Taste
var shop_before: Array = g.human.shop.duplicate()
mana = g.human.energy mana = g.human.energy
await click(scr.b_reroll) await key(KEY_Z)
check(g.human.energy == mana - Game.REROLL_COST and g.human.shop != shop_before, "Laden neu gewürfelt") 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") await shot("22_kampf_steuerung")
# Gamepad: Zeiger im Laden bewegen, A kauft # Gamepad: A auf dem Stapel zieht, Steuerkreuz wählt die zweite Karte, A nimmt sie
await pad(JOY_BUTTON_DPAD_RIGHT) await pad(JOY_BUTTON_DPAD_LEFT)
check(scr.gp_zone == "shop" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweitem Angebot") 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() var hand_before: int = g.human.hand.size()
await pad(JOY_BUTTON_A) 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 scr.ai_delay = 0.75
await key(KEY_SPACE) await key(KEY_SPACE)
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") 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 g.human.energy = 100
scr.refresh() scr.refresh()
await frames(1) await frames(1)
# Hand voll kaufen # Hand voll ziehen
var guard := 0 var guard := 0
while g.human.hand.size() < Game.MAX_HAND and guard < 30: while g.human.hand.size() < Game.MAX_HAND and guard < 30:
guard += 1 guard += 1
if scr.shop_views.is_empty(): await click(scr.deck)
await click(scr.b_reroll) await pick_all(scr)
continue
await click(scr.shop_views[0])
check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND) 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") check(not scr.deck.enabled, "bei voller Hand lässt sich nichts ziehen")
if not scr.shop_views.is_empty(): await click(scr.deck)
await click(scr.shop_views[0]) check(scr.msg_label.text == tr("Deine Hand ist voll"), "Meldung bei voller Hand: " + scr.msg_label.text)
check(scr.msg_label.text == tr("Hand voll"), "Meldung bei voller Hand: " + scr.msg_label.text)
# Feld voll spielen # Feld voll spielen
guard = 0 guard = 0
while g.human.board.size() < Game.MAX_BOARD and guard < 30: while g.human.board.size() < Game.MAX_BOARD and guard < 30:
guard += 1 guard += 1
if g.human.hand.is_empty():
await click(scr.deck)
await pick_all(scr)
await key(KEY_1) await key(KEY_1)
if scr.pending != null: # Karte will ein Ziel
var valid: Array = g.valid_targets(g.human, scr.pending.target_type())
if valid.is_empty():
await right_click()
else:
await click(target_view(scr, valid))
check(g.human.board.size() == Game.MAX_BOARD, "Feld mit %d Wesen voll" % Game.MAX_BOARD) 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) await key(KEY_1)
check(scr.msg_label.text == tr("Dein Feld ist voll"), "Meldung bei vollem Feld: " + scr.msg_label.text) check(scr.msg_label.text == tr("Dein Feld ist voll"), "Meldung bei vollem Feld: " + scr.msg_label.text)
await shot("25_kampf_voll") 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[0])
await click(scr.own_views[1]) await click(scr.own_views[1])
check(g.human.board.size() == before - 1, "Verschmelzen macht einen Platz frei") 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 g.human.energy = 0
scr.refresh() scr.refresh()
await frames(1) 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() await give_up()
## Feldanzeige und Verschmelzen im Kampf. ## Elementregel, verdeckt legen, Kartenmenü und Verschmelzen im Kampf.
func test_kampf_feld_und_verschmelzen() -> void: func test_kampf_elemente_und_verschmelzen() -> void:
await start_battle() await start_battle()
var scr = screen() var scr = screen()
check(scr is BattleScreen, "Kampf gestartet") check(scr is BattleScreen, "Kampf gestartet")
@@ -570,47 +605,71 @@ func test_kampf_feld_und_verschmelzen() -> void:
await wait_for_human(scr) await wait_for_human(scr)
var g: Game = scr.game var g: Game = scr.game
g.human.board.clear() g.human.board.clear()
var put := func(p: Game.Player, id: String) -> Game.Creature: scr.opponent().board.clear() # was die KI im ersten Zug gelegt hat, stört die Zähler
var cr := Game.Creature.new(Cards.by_id(id), p, g.next_uid) 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 g.next_uid += 1
cr.ready = true
cr.attacks_left = 1 cr.attacks_left = 1
cr.hidden = hidden
p.board.append(cr) p.board.append(cr)
return cr return cr
put.call(g.human, "f_wolf") put.call(g.human, "f_wolf")
put.call(g.human, "f_asche")
put.call(g.human, "w_reiter") put.call(g.human, "w_reiter")
var falke: Game.Creature = put.call(scr.others()[0], "l_falke") var falke: Game.Creature = put.call(scr.opponent(), "l_falke")
g.human.hand = [Cards.by_id("f_kobold")] var secret: Game.Creature = put.call(scr.opponent(), "f_kobold", true)
g.human.energy = 10 g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()]
g.field_charge["Wasser"] = 2 g.human.energy = 20
g.apply_auras()
scr.refresh() scr.refresh()
await frames(2) 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()) check(scr.element_view.text() == "Feuer 2 · Wasser 1 · Erde 0 · Luft 1", "Zähler ohne das verdeckte Wesen: " + scr.element_view.text())
for i in Cards.FIELD_CHARGE: 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")
g.charge_field(Cards.by_id("f_kobold")) # verdeckt legen über das Kartenmenü (Rechtsklick auf die Handkarte)
scr.refresh() var menu_text := "Verdeckt legen ◆ %d" % Cards.by_id("f_kobold").cost
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")
await hover(scr.hand_views[0]) 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]) 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") 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")
await shot("23b_angriffsziel") check(not g.can_attack(wolf, scr.opponent())[0], "Held nicht angreifbar, solange Wesen stehen")
await right_click() # Auswahl wieder aufheben await click(scr.tiles[secret.uid])
check(scr.selected == null and scr.tiles[falke.uid].matchup == 0, "Auswahl aufgehoben, Vorteil verschwindet") 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) await click(scr.b_fuse)
check(scr.fusing and scr.b_fuse.text == "Abbrechen", "Verschmelzen ausgewählt") 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") check(scr.fuse_first != null, "erstes Wesen gewählt")
await shot("24_verschmelzen") await shot("24_verschmelzen")
await click(scr.own_views[1]) mana = g.human.energy
check(g.human.board.size() == 1 and g.human.board[0].name == "Dampf", "die beiden Wesen sind zu Dampf verschmolzen") await click(scr.own_views[2])
check(not scr.fusing and g.human.energy == 8, "Modus beendet und Mana bezahlt") check(g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 1, "zu Dampf verschmolzen, 1 Mana bezahlt")
check(scr.own_views.size() == 1, "nur noch eine eigene Kachel") check(not scr.fusing, "Modus beendet")
await give_up() await give_up()
@@ -642,6 +701,7 @@ func test_netzwerk() -> void:
var b = screen() var b = screen()
check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host") check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host")
b.ai_delay = 0.0 b.ai_delay = 0.0
await pick_all(b)
var rounds := 0 var rounds := 0
while b.game != null and b.game.winner == "" and rounds < 2: while b.game != null and b.game.winner == "" and rounds < 2:
if b.game.current() == b.perspective: if b.game.current() == b.perspective:
@@ -747,6 +807,7 @@ func test_host_wechsel() -> void:
await frames(1) await frames(1)
w += 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") 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 main.net.rejoin_seconds = 3.0
_bot_quit(true) _bot_quit(true)
w = 0 w = 0
@@ -820,6 +881,7 @@ func test_kampagne() -> void:
await click(find_button("Kämpfen")) await click(find_button("Kämpfen"))
var battle = screen() var battle = screen()
check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet") 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.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") check(battle.game.opponent.perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv")
await shot("16_kampagne_kampf") await shot("16_kampagne_kampf")
@@ -835,7 +897,8 @@ func test_kampagne() -> void:
await shot("27_kampagne_kapitel2") await shot("27_kampagne_kapitel2")
await click(find_button("Kämpfen")) await click(find_button("Kämpfen"))
var b2 = screen() 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") check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf")
await give_up() await give_up()
main.profile.campaign_stage = 0 main.profile.campaign_stage = 0
@@ -884,7 +947,7 @@ func test_kampf_abbrechen() -> void:
scr.ai_delay = 0.0 scr.ai_delay = 0.0
var g: Game = scr.game var g: Game = scr.game
await wait_for_human(scr) 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 # Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit
g.human.energy = 10 g.human.energy = 10
var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000) var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000)
@@ -894,18 +957,15 @@ func test_kampf_abbrechen() -> void:
scr.refresh() scr.refresh()
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0] var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
check(reiter_view.rarity == "episch", "Handkarte trägt die eigene Stufe") 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) await hover(reiter_view)
var previews = scr.preview_holder.get_children().filter(func(c): return c is CardView) 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") check(previews.size() == 1 and previews[0].rarity == "episch", "Vorschau ebenfalls")
await shot("21_kampf_vorschau") 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 scr.own_views.is_empty():
if not targeted.is_empty(): await click(scr.own_views[0])
var card: Cards.Card = targeted[0].card
await click(targeted[0])
check(scr.pending == card, "Ziel wird erwartet")
await right_click() await right_click()
check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab") check(scr.selected == null, "Rechtsklick bricht ab")
await hover(scr.hand_views[0]) await hover(scr.hand_views[0])
if scr.preview_holder.get_child_count() == 0: if scr.preview_holder.get_child_count() == 0:
print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(), print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(),

View File

@@ -43,7 +43,6 @@ Kapitel 2,Chapter 2
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d" "Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
Zug %d · Du bist dran,Turn %d · Your turn Zug %d · Du bist dran,Turn %d · Your turn
Zug %d · %s ist dran …,Turn %d · %s is playing … 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 Wähle ein Angriffsziel,Choose an attack target
Abgebrochen,Cancelled Abgebrochen,Cancelled
%s kann nicht angreifen,%s cannot attack %s kann nicht angreifen,%s cannot attack
@@ -65,26 +64,6 @@ Große Schrift,Large text
%d Münzen,%d coins %d Münzen,%d coins
Abbrechen,Cancel Abbrechen,Cancel
"Ja, zurücksetzen","Yes, reset" "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 Flink,Swift
Schild,Shield Schild,Shield
Doppelschlag,Double Strike Doppelschlag,Double Strike
@@ -97,16 +76,6 @@ Feuer,Fire
Wasser,Water Wasser,Water
Erde,Earth Erde,Earth
Luft,Air 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) %s zerlegt (+%d Staub),%s dismantled (+%d dust)
Kapitel %d,Chapter %d Kapitel %d,Chapter %d
Zuschauen,Watch Zuschauen,Watch
@@ -119,14 +88,6 @@ Zwei eigene Wesen wählen,Pick two of your creatures
Zweites Wesen wählen,Pick the second creature Zweites Wesen wählen,Pick the second creature
Zwei verschiedene Wesen wählen,Pick two different creatures Zwei verschiedene Wesen wählen,Pick two different creatures
Nur eigene Wesen,Only your own 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 Aufgewertet: %d von %d Karten · Kampagne: %d/%d,Upgraded: %d of %d cards · Campaign: %d/%d
%s ist jetzt %s,%s is now %s %s ist jetzt %s,%s is now %s
Alle Kapitel abgeschlossen,All chapters completed 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 Gewöhnliche Karten lassen sich nicht zerlegen,Common cards cannot be dismantled
Aufgewertet,Upgraded Aufgewertet,Upgraded
+%d Staub,+%d dust +%d Staub,+%d dust
+%d je Zug,+%d per turn
Neu würfeln ◆ %d,Reroll ◆ %d
Dein Zug,Your turn Dein Zug,Your turn
Beim Ausspielen,On play
Nicht genug Mana,Not enough mana Nicht genug Mana,Not enough mana
Kein Angebot,No offer
Hand voll,Hand full
Profil wirklich zurücksetzen?,Really reset your profile? Profil wirklich zurücksetzen?,Really reset your profile?
"Sammlung, Münzen und Fortschritt gehen verloren.","Collection, coins and progress are lost." "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 %d Leben,%d life
Gegner mit %d Leben · %d Münzen bei Sieg,Opponent with %d life · %d coins for a win 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 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 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
1 keys en
43 Münzen: %d · Staub: %d Coins: %d · Dust: %d
44 Zug %d · Du bist dran Turn %d · Your turn
45 Zug %d · %s ist dran … Turn %d · %s is playing …
Wähle ein Ziel Choose a target
46 Wähle ein Angriffsziel Choose an attack target
47 Abgebrochen Cancelled
48 %s kann nicht angreifen %s cannot attack
64 %d Münzen %d coins
65 Abbrechen Cancel
66 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
67 Flink Swift
68 Schild Shield
69 Doppelschlag Double Strike
76 Wasser Water
77 Erde Earth
78 Luft Air
Inferno Inferno
Tiefsee Deep Sea
Berg Mountain
Orkan Hurricane
Dampf Steam
Lava Lava
Blitz Lightning
Schlamm Mud
Sturm Storm
Sand Sand
79 %s zerlegt (+%d Staub) %s dismantled (+%d dust)
80 Kapitel %d Chapter %d
81 Zuschauen Watch
88 Zweites Wesen wählen Pick the second creature
89 Zwei verschiedene Wesen wählen Pick two different creatures
90 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
91 Aufgewertet: %d von %d Karten · Kampagne: %d/%d Upgraded: %d of %d cards · Campaign: %d/%d
92 %s ist jetzt %s %s is now %s
93 Alle Kapitel abgeschlossen All chapters completed
102 Gewöhnliche Karten lassen sich nicht zerlegen Common cards cannot be dismantled
103 Aufgewertet Upgraded
104 +%d Staub +%d dust
+%d je Zug +%d per turn
Neu würfeln ◆ %d Reroll ◆ %d
105 Dein Zug Your turn
Beim Ausspielen On play
106 Nicht genug Mana Not enough mana
Kein Angebot No offer
Hand voll Hand full
107 Profil wirklich zurücksetzen? Really reset your profile?
108 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
109 %d Leben %d life
110 Gegner mit %d Leben · %d Münzen bei Sieg Opponent with %d life · %d coins for a win
111 Start gegen die KI Start against the AI
112 Wähle %d von %d Pick %d of %d
113 Ziehen Draw
114 Erst wählen Pick first
115 Deine Hand ist voll Your hand is full
116 Keine Wahl offen Nothing to pick
117 Keine solche Karte No such card
118 Vorbei Over
119 Verdeckt legen ◆ %d Set face-down ◆ %d
120 Aufdecken Reveal
121 verdeckt face-down
122 Aufgedeckt! Revealed!
123 Elementbonus +%d/+%d Element bonus +%d/+%d
124 Elementbonus %d/%d Element bonus %d/%d
125 Merkmal +%d Perk +%d
126 nächster Zug ◆ %d next turn ◆ %d
127 Erst die Wesen Creatures first
128 %s liegt verdeckt %s is face-down
129 %s liegt schon offen %s is already face-up
130 Unbekannte Eigenschaft Unknown trait
131 %s hat schon %s %s already has %s
132 Wesen kommen mit +1 Angriff Creatures enter with +1 attack
133 Hat im ersten Zug 2 Mana mehr Has 2 extra mana on the first turn
134 Elementbonus zählt doppelt Element bonus counts twice
135 Beginnt mit 5 Leben mehr Starts with 5 extra life
136 Blockt den ersten Schaden Blocks the first damage
137 Greift zweimal je Zug Attacks twice per turn
138 Verdeckt Face-down
139 Inferno Inferno
140 Tiefsee Deep Sea
141 Berg Mountain
142 Orkan Hurricane
143 Dampf Steam
144 Lava Lava
145 Blitz Lightning
146 Schlamm Mud
147 Sturm Storm
148 Sand Sand
149 Feuerwesen haben +1 Angriff Fire creatures have +1 attack
150 Wesen kommen mit +1 Leben Creatures enter with +1 health
151 Bekommt jeden Zug 1 Mana mehr Gets 1 extra mana each turn
152 Wesen kommen mit Schild Creatures enter with a shield
153 Ziehen kostet kein Mana Drawing costs no mana
154 Eigenschaften kosten 1 weniger Traits cost 1 less

Binary file not shown.