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-10 14:30:29 +02:00
parent c245c7af87
commit a11cee2e0a
37 changed files with 2190 additions and 2414 deletions

View File

@@ -20,23 +20,22 @@ Der Spielstand liegt unter `user://spielstand.json`
## Spielsysteme ## Spielsysteme
- 81 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos sind die Namen verschmolzener Wesen - 120 Karten in `data/karten.json`: je Element alle Werte von 0/1 bis 5/5, die ID verrät sie (`f_3_2` = Feuer 3/2), Namen aus Eigenschaft und Wesen; Kombos sind die Namen verschmolzener Wesen
- Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet; je Element gibt es ein 0/2-Wesen für 0 Mana - Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts)
- Mana: 1 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen, Legen und Verschmelzen - Mana: 1 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen, Legen und Verschmelzen
- Kein Deckbau: beide ziehen vom gemeinsamen Stapel (jede Karte dreimal). Zu Beginn fünf Karten, alle im ersten Zug spielbar. Auf dem Stapel liegen drei Karten offen, nie teurer als das Mana des Zugs; beim Ziehen wählt man eine, eine neue rückt nach. Das erste Ziehen im Zug ist frei, jedes weitere kostet 2 Mana; eine Handkarte abwerfen bringt 1 Mana (Rechtsklick) - Jeder spielt mit seinem eigenen Stapel aus 40 bis 60 Karten (Menü „Deck“: eigene Karten rein oder raus, „Auffüllen“ nimmt die teuersten des gewählten Filters). Zu Beginn fünf im ersten Zug spielbare Karten. Ziehen bringt eine Karte, die erste im Zug ist frei, dann 1, 2, 3 … Mana; ist der Stapel leer, kostet jeder Zug 1, 2, 3 … Leben (Erschöpfung); eine Handkarte abwerfen bringt 1 Mana (Rechtsklick)
- Eigenschaften gibt es nur über Elementpaare (siehe Elementboni): Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug). Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink; verletzte Wesen heilen nicht von selbst - Eigenschaften gibt es nur über Elementpaare (siehe Elementboni): Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug). Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink; verletzte Wesen heilen nicht von selbst
- Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar und bekommt den Elementbonus erst offen; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf - Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar und bekommt den Elementbonus erst offen; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf
- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik wie bei Yu-Gi-Oh: Common matt, Rare silberner Name und Rahmen mit Glanzstreifen, Super Rare Holo-Bild, Ultra Rare goldener Name und Rahmen mit Holo-Bild, Ultimate Rare Relief, Holo auf Bild und Rahmen, Funkeln und schillernde Farben; auch auf dem Feld für alle sichtbar, Werte bleiben gleich. Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern - Beim ersten Start wählt man eines von vier Startdecks (40 Karten: 25 eines Elements bis Angriff 4, je fünf billige der anderen), der Rest kommt aus Boostern oder der Werkstatt. Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare) und ist je Stufe +1/+1 stärker, bei gleichen Kosten; die Stufe zeigt sich auch in der Optik (Glanz, Holo, Relief, Funkeln). Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern
- Booster (100 Münzen): fünf Karten mit gewürfelter Stufe (50/25/15/8/2 %, mindestens eine Rare), höhere werten die Karte auf, der Rest wird zu Staub (50/100/200/500/1000). Aufwerten kostet 100/200/500/1000 Staub, eine Stufe abgeben bringt den Zerlegewert zurück - Booster (100 Münzen): fünf Karten mit gewürfelter Stufe (45/25/15/10/5 %, mindestens eine Rare); neue kommen in Sammlung und Stapel, eine höhere Stufe ersetzt die alte Version (die wird zu Staub), der Rest wird zu Staub (50/100/200/500/1000). Werkstatt in der Sammlung: Aufwerten kostet 100/200/500/1000 Staub, Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Elementboni, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); je 10 Mana an ausgespielten Karten eines Elements geben den eigenen Wesen dieses Elements +1 Angriff (verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen). Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Boni. Zwei Leisten in der Mitte, oben die des Gegners, unten die eigene, zeigen je Element zwei Symbole, gefüllt je offenem eigenen Wesen, und das Mana der ausgespielten Karten - Elementboni, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); je fünf ausgespielte Karten eines Elements geben den eigenen Wesen dieses Elements +1/+1 (verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen). Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Boni. Die Zähler stehen links neben dem jeweiligen Brett und zeigen nur Elemente, bei denen etwas passiert ist: zwei Symbole, gefüllt je offenem eigenen Wesen, und die Zahl der ausgespielten Karten
- Elementvorteil beim Kampf (Wasser > Feuer > Luft > Erde > Wasser): wer im Vorteil ist, macht 1 Schaden mehr, wer im Nachteil ist, 1 weniger, beim Angriff wie beim Konter, gegen Helden nichts; so greift und kontert auch ein Wesen mit 0 Angriff, nur nicht ohne Vorteil; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht. Ist ein Angreifer gewählt, zeigen die Gegnerkarten ihren Konter gegen ihn (grün mehr, rot weniger); über einem Ziel zeigt der Angreifer seinen Angriff mit Vorteil, und wer stirbt, trägt die Marke „stirbt“ - Elementvorteil beim Kampf (Wasser > Feuer > Luft > Erde > Wasser): wer im Vorteil ist, macht 1 Schaden mehr, wer im Nachteil ist, 1 weniger, beim Angriff wie beim Konter, gegen Helden nichts; so greift und kontert auch ein Wesen mit 0 Angriff, nur nicht ohne Vorteil; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht. Ist ein Angreifer gewählt, zeigen die Gegnerkarten ihren Konter gegen ihn (grün mehr, rot weniger); über einem Ziel zeigt der Angreifer seinen Angriff mit Vorteil, und wer stirbt, trägt die Marke „stirbt“
- Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar, per Doppelklick auf das eigene Wesen direkt. Nichts schlägt durch - Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar, per Doppelklick auf das eigene Wesen direkt. Nichts schlägt durch. Den Zug beendet die Leertaste oder der Rechtsklick ins Leere; Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden)
- Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde halten, auf dem Brett wie auf der Hand (Gamepad: Y, „Verschmelzen mit …“). Das neue Wesen hat je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten passend dazu, Elemente und Eigenschaften beider; kostet 1 Mana, verdoppelt je weiterer Verschmelzung (1, 2, 4, 8); drei oder mehr Elemente ergeben eine Chimäre - Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde halten, auf dem Brett wie auf der Hand (Gamepad: Y, „Verschmelzen mit …“). Das neue Wesen hat je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten aus den neuen Werten, Elemente und Eigenschaften beider; es kostet die halbe Manasumme beider Karten (aufgerundet); drei oder mehr Elemente ergeben eine Chimäre
- Duell mit ausgeloster Zugreihenfolge, beide beginnen mit 1 Mana; die Hand fasst 5 Karten, das Feld 5 Wesen - Duell mit ausgeloster Zugreihenfolge, beide beginnen mit 1 Mana; die Hand fasst 5 Karten, das Feld 5 Wesen
- Schwierigkeit Leicht/Normal/Schwer: Gegner mit 25/30/35 Leben, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach - 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 und eigenen Stapeln (Kapitel 1 bis Kosten 3, Kapitel 2 bis 5 als Rare, Kapitel 3 alles als Super Rare), eigenes Merkmal nach Kapitel 1
- Netzwerkspiel im lokalen Netz: Host führt das Spiel, Mitspieler sehen nur ihre Hand, Zuschauer keine. Fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung, die anderen verbinden neu (Platz bleibt 60 s frei)
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`) - Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
- Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`) - Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`)
- Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen; Ton und Musik sind standardmäßig aus - Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen; Ton und Musik sind standardmäßig aus
@@ -53,7 +52,6 @@ godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spiel
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots DINGER_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)
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Stapel, 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,13 +69,11 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json` - `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
- `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementboni, Kampf, Verschmelzen - `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementboni, Kampf, Verschmelzen
- `scripts/deckgen.gd` – gemeinsamer Stapel und Gegner - `scripts/deckgen.gd` – Stapel: zufällige für Gegner, Standardstapel aus der Sammlung
- `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, Stapel, Booster, Werkstatt, 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/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 - `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

@@ -5,18 +5,17 @@
## Grundidee ## Grundidee
- Kartenspiel wie zum Beispiel Magic - Kartenspiel wie zum Beispiel Magic
- PC-Spiel - PC-Spiel
- Duell: Spieler gegen einen KI-Gegner (oder einen Mitspieler im Netz), beide mit gleichen Regeln - Duell: Spieler gegen einen KI-Gegner, beide mit gleichen Regeln
- Einzelspieler-Knopf führt zur Wahl der Schwierigkeit - Einzelspieler-Knopf führt zur Wahl der Schwierigkeit
- Kampagne gegen benannte Magier als Fortschritt - Kampagne gegen benannte Magier als Fortschritt
- Im lokalen Netz zu zweit spielbar, weitere als Zuschauer
## Sammeln ## Sammeln
- Jede Karte gehört jedem von Anfang an: wer zum ersten Mal spielt, kann so gut sein wie wer tausend Partien hat - Man startet mit einem von vier Startdecks (40 Karten: 25 eines Elements bis Angriff 4, je fünf billige der anderen); alle Karten sammeln ist das lange Ziel
- Gesammelt wird die Seltenheitsstufe einer Karte (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare), und die ist reine Optik wie bei Yu-Gi-Oh: silberner oder goldener Name, Holo-Bild, Relief, Funkeln, Schillern; auch im Netzwerkspiel sichtbar; Fusionen tragen die höhere Stufe der Eltern - Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare); je Stufe +1/+1 bei gleichen Kosten, dazu Optik (Glanz, Holo, Relief, Funkeln); Fusionen tragen die höhere Stufe der Eltern
- Booster öffnen (Chancen 50/25/15/8/2 %, mindestens eine Rare): höhere Stufen werten Karten auf, der Rest wird zu Staub (50/100/200/500/1000) - Booster öffnen (Chancen 45/25/15/10/5 %, mindestens eine Rare): neue Karten kommen in Sammlung und Stapel, von jeder Karte zählt die seltenste Version, die alte wird zu Staub, der Rest wird zu Staub (50/100/200/500/1000)
- Staub wertet gezielt auf (100/200/500/1000), eine Stufe abgeben bringt den Zerlegewert zurück - Werkstatt: Staub wertet gezielt auf (100/200/500/1000), Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Kein Deckbau: alle ziehen vom gemeinsamen Stapel (jede Karte dreimal); zu Beginn fünf Karten, alle im ersten Zug spielbar; auf dem Stapel liegen drei Karten offen, nie teurer als das Mana des Zugs, beim Ziehen wählt man eine, eine neue rückt nach; das erste Ziehen im Zug ist frei, jedes weitere kostet 2 Mana - Jeder hat sein Deck: 40 bis 60 eigene Karten, im Menü „Deck“ zusammengestellt (Auffüllen folgt dem Filter); zu Beginn fünf im ersten Zug spielbare Karten daraus; Ziehen bringt eine Karte, die erste im Zug frei, dann 1, 2, 3 … Mana; leerer Stapel: jeder Zug kostet 1, 2, 3 … Leben
- Mana für Ziehen, Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet; 0/2 kostet 0), und Verschmelzen, Abwerfen bringt 1; 1 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten - Mana für Ziehen, Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet, höchstens 10; 0/2 kostet 0) und Verschmelzen (halbe Manasumme beider Karten), Abwerfen bringt 1; 1 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten
- Karten haben nur Angriff und Leben; Eigenschaften kommen allein über Elementpaare: Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug) - Karten haben nur Angriff und Leben; Eigenschaften kommen allein über Elementpaare: Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug)
- Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink (auch im ersten Zug); verletzte Wesen heilen nicht von selbst - Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink (auch im ersten Zug); verletzte Wesen heilen nicht von selbst
- Wesen lassen sich verdeckt legen (mittlere Maustaste oder Kartenmenü); aufdecken ist frei, frühestens im nächsten Zug, dann greift es sofort an; ein Angriff deckt auf - Wesen lassen sich verdeckt legen (mittlere Maustaste oder Kartenmenü); aufdecken ist frei, frühestens im nächsten Zug, dann greift es sofort an; ein Angriff deckt auf
@@ -24,10 +23,10 @@
- Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen - 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; je Element gibt es alle Werte von 0/1 bis 5/5 (120 Karten)
- Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) - Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan)
- Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag) - Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag)
- Je 10 Mana an ausgespielten Karten eines Elements: +1 Angriff für die eigenen Wesen des Elements, jeder Spieler zählt für sich; verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen; die Karte zeigt vorher, was sie bekäme; verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte und Boni - Je fünf ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements, jeder Spieler zählt für sich; verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen; die Karte zeigt vorher, was sie bekäme; verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte und Boni
- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (wer im Vorteil ist, macht 1 Schaden mehr, wer im Nachteil ist, 1 weniger, beim Angriff wie beim Konter; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht) - Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (wer im Vorteil ist, macht 1 Schaden mehr, wer im Nachteil ist, 1 weniger, beim Angriff wie beim Konter; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht)
- Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde stillhalten, auf dem Brett oder auf der Hand (Gamepad: über das Kartenmenü); das neue erbt Elemente und Eigenschaften beider, je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten folgen den Werten; Fusionskosten 1 Mana, verdoppelt je weiterer Verschmelzung - Verschmelzen: eine Karte auf eine andere ziehen und eine Sekunde stillhalten, auf dem Brett oder auf der Hand (Gamepad: über das Kartenmenü); das neue erbt Elemente und Eigenschaften beider, je Wert den besseren plus ein Drittel des schwächeren (aufgerundet, mindestens 1), Kosten folgen den Werten; Fusionskosten 1 Mana, verdoppelt je weiterer Verschmelzung
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus - 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

@@ -6,7 +6,7 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw
- **Hearthstone** – Kosten und Werte springen aus dem Kartenrahmen (Kristall oben links, Angriff - **Hearthstone** – Kosten und Werte springen aus dem Kartenrahmen (Kristall oben links, Angriff
und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt
kurz. Auf dem Brett: eigene Seite unten, Gegner oben, links Kästen und Stapel, rechts oben Knöpfe, Wesen kurz. Auf dem Brett: eigene Seite unten, Gegner oben, links Elementzähler und Meldungen, rechts Stapel und Kästen beider Seiten, Wesen
als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt. als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt.
- **Teamfight Tactics** – Synergien durch gleiche Elemente: zwei eigene geben den Effekt des - **Teamfight Tactics** – Synergien durch gleiche Elemente: zwei eigene geben den Effekt des
Elements, drei auf dem Tisch den Wertebonus; die Elementleiste zeigt den Stand. Elements, drei auf dem Tisch den Wertebonus; die Elementleiste zeigt den Stand.
@@ -41,14 +41,14 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
- **Hand** als bis zu fünf Karten in Feldgröße unten, in einem leichten Bogen, die des Gegners - **Hand** als bis zu fünf Karten in Feldgröße unten, in einem leichten Bogen, die des Gegners
gleich groß als Rückseiten oben; der gleich groß als Rückseiten oben; der
Stapel links auf halber Höhe als drei offene Karten übereinander (Klick zieht die Karte, das erste eigener Stapel rechts unten als Kartenstoß mit den Kosten des nächsten Ziehens (das erste im Zug
Ziehen im Zug frei), darüber der Gegnerkasten, darunter der eigene. frei), darüber der eigene Kasten; der Stapel des Gegners rechts oben, sein Kasten darunter.
- **Verschmelzen** per Ziehen: eine Hand- oder Brettkarte auf eine passende ziehen, ein - **Verschmelzen** per Ziehen: eine Hand- oder Brettkarte auf eine passende ziehen, ein
goldener Ring füllt sich eine Sekunde lang, dann verschmelzen die beiden. Mit Gamepad über goldener Ring füllt sich eine Sekunde lang, dann verschmelzen die beiden. Mit Gamepad über
das Y-Menü („Verschmelzen mit …“, dann A auf dem Partner). das Y-Menü („Verschmelzen mit …“, dann A auf dem Partner).
- **Eigenes Brett** mit fünf sichtbaren Plätzen; links daneben die eigene Leiste (Leben, Mana, - **Eigenes Brett** mit fünf sichtbaren Plätzen; links daneben die eigene Leiste (Leben, Mana,
Einkommen, Zug), rechts „Rückgängig“, nur wenn es gerade möglich ist. Einkommen, Zug), rechts „Rückgängig“, nur wenn es gerade möglich ist.
- **Elementleiste** je Spieler, die des Gegners darüber. - **Elementzähler** je Spieler als Liste links neben seinem Brett, nur Elemente mit Wesen oder gespielten Karten.
Je Element zwei Symbole in seiner Form, gefüllt je offenem eigenen Wesen (beide voll: Sondereffekt), Je Element zwei Symbole in seiner Form, gefüllt je offenem eigenen Wesen (beide voll: Sondereffekt),
daneben das Mana der ausgespielten Karten; je 10 gibt es +1 Angriff. daneben das Mana der ausgespielten Karten; je 10 gibt es +1 Angriff.
- **Mittlere Maustaste** auf eine Handkarte legt sie verdeckt. **Kartenmenü** per Rechtsklick - **Mittlere Maustaste** auf eine Handkarte legt sie verdeckt. **Kartenmenü** per Rechtsklick
@@ -59,7 +59,8 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
macht Ziele, Texte und Balance eindeutig. macht Ziele, Texte und Balance eindeutig.
- **Vorschau** steht fest rechts auf halber Höhe, darunter die Zusatzzeile, mit Resonanz, Feld - **Vorschau** steht fest rechts auf halber Höhe, darunter die Zusatzzeile, mit Resonanz, Feld
und Feldkombo darunter. und Feldkombo darunter.
- **Verlauf**, **Aufgeben** und **Zug beenden** als Knöpfe oben rechts; der Verlauf öffnet als Überlagerung. - **Optionen** per Esc als Überlagerung: Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden; der Verlauf
öffnet darüber. Zug beenden per Leertaste oder Rechtsklick ins Leere, oben rechts nur Rückgängig.
- Handkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen - Handkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen
steht der Elementkonter (+1/−1) am Ziel, bei Verdeckten nicht. steht der Elementkonter (+1/−1) am Ziel, bei Verdeckten nicht.
@@ -75,14 +76,47 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
## Menüs ## Menüs
- Hauptmenü: Titel und drei Spielarten links, darunter Booster, Sammlung, Einstellungen, - Hauptmenü: Titel, Einzelspieler, Kampagne und Deck links, darunter Booster, Sammlung, Einstellungen,
Beenden; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und Beenden; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und
Staub oben rechts. Staub oben rechts.
- Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter. - Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter.
- Kampagne als Pfad mit zehn Stationen je Kapitel, die nächste pulsiert; darunter die gewählte - Kampagne als Pfad mit zehn Stationen je Kapitel, die nächste pulsiert; darunter die gewählte
Stufe mit Gegner, Leben, Belohnung und Merkmalen. Die Bosse werden über Leben und Merkmale Stufe mit Gegner, Leben, Belohnung und Merkmalen. Die Bosse werden über Leben und Merkmale
stärker, nicht über Begleiter. stärker, nicht über Begleiter.
- Sammlung: sechs Spalten, aufgewertete Karten zuerst, rechts die große Vorschau. - Sammlung: sechs Spalten, eigene Karten zuerst (höchste Stufen vorn), fehlende abgedunkelt, rechts
die große Vorschau mit Aufwerten, Zerlegen oder Herstellen. Deck: gleiches Raster, nur eigene Karten,
Stapelkarten hell, die anderen abgedunkelt; Klick wechselt.
## Grafik
Ziel
- Wie ein normales Godot/Unity-Spiel, nicht primitiv
- Keine Kunstwerke
Stil
- Dunkler Tisch, Elemente leuchten
- Rahmen wie Hearthstone, Glanz wie Pokémon TCG
- Eine Palette, ein Font, eine Formensprache
- Palette aus style.gd erweitern, nicht ersetzen
- Font DejaVu Sans
Regeln
- Zahlen groß, Kontrast hoch
- Zustände sichtbar: Hover, Pressed, Disabled, Fokus
- Icons als SVG, ein Strichstil
Verbote
- Flache graue Panels
- Lila Verläufe
- Effekte ohne Zweck
Entschieden
- Richtung Stein: warm, Holz und Bronze, Bevel-Rahmen, Vignette
- Werte und Kristall bleiben im Kartenrahmen
- Rundungen und Kanten glatt, keine Treppchen
Offen
- Radius, Ränder, Schatten im Spiel nachziehen
## Offen ## Offen

View File

@@ -201,7 +201,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
best = firsts[i].action best = firsts[i].action
if best.type == "end": if best.type == "end":
if game.can_draw(p)[0]: if game.can_draw(p)[0]:
return {"type": "draw", "index": pick_index(game, p)} # Mana übrig und Platz auf der Hand: lieber ziehen als passen return {"type": "draw"} # Mana übrig und Platz auf der Hand: lieber ziehen als passen
return best return best
return map_action(game, view, best) return map_action(game, view, best)
@@ -258,24 +258,10 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
if t != null: if t != null:
return {"type": "attack", "creature": cr, "target": t} return {"type": "attack", "creature": cr, "target": t}
if game.can_draw(p)[0]: if game.can_draw(p)[0]:
return {"type": "draw", "index": pick_index(game, p)} return {"type": "draw"}
return {"type": "end"} return {"type": "end"}
## Welche der offenen Karten: die wertvollste, die nach dem Ziehen noch bezahlbar wäre, sonst die billigste.
static func pick_index(game: Game, p: Game.Player) -> int:
var left: int = p.energy - game.draw_cost(p)
var best := 0
var best_score := -INF
for i in p.offer.size():
var c: Cards.Card = p.offer[i]
var score := float(c.attack + c.health) if c.cost <= left else -10.0 - c.cost
if score > best_score:
best_score = score
best = i
return best
## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen; ## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
## der Held nur, wenn kein Wesen mehr steht. ## der Held nur, wenn kein Wesen mehr steht.
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):
@@ -302,7 +288,7 @@ static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void
"play": "play":
game.play_card(p, action.card, bool(action.get("face_down", false))) game.play_card(p, action.card, bool(action.get("face_down", false)))
"draw": "draw":
game.draw(p, int(action.get("index", 0))) game.draw(p)
"reveal": "reveal":
game.reveal(p, action.creature) game.reveal(p, action.creature)
"attack": "attack":

View File

@@ -64,9 +64,16 @@ 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 mit dem Leben und den Merkmalen der Stufe. ## Boss einer Stufe mit dem Leben, den Merkmalen und einem Stapel der Stufe; ab Kapitel 2 sind
static func opponent(stage_index: int) -> Game.Player: ## seine Karten eine Seltenheitsstufe höher (+1/+1), ab Kapitel 3 zwei.
static func opponent(stage_index: int, r: RandomNumberGenerator = null) -> Game.Player:
var st := stage(stage_index) var st := stage(stage_index)
var boss := Game.Player.new(st.name, false, int(st.life), st.title) var boss := Game.Player.new(st.name, false, int(st.life), st.title)
boss.perks = Array(st.perks) boss.perks = Array(st.perks)
if r == null:
r = Game.make_rng()
var ch := chapter(stage_index)
boss.deck = DeckGen.opponent_deck(r, ch)
for id in boss.deck:
boss.looks[id] = ch - 1
return boss return boss

View File

@@ -93,9 +93,9 @@ static func best_element(own: Array, other: Array) -> String:
return best return best
## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet (0/2 kostet nichts). ## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts).
static func cost_of(attack: int, health: int) -> int: static func cost_of(attack: int, health: int) -> int:
return (2 * attack + health) / 3 return mini(10, (2 * attack + health) / 3)
## Wert eines verschmolzenen Wesens: der bessere Wert plus ein Drittel des schwächeren (mindestens 1), ## Wert eines verschmolzenen Wesens: der bessere Wert plus ein Drittel des schwächeren (mindestens 1),
@@ -132,8 +132,7 @@ static func fused(a: Card, b: Card, attack: int = -1, health: int = -1) -> Card:
for k in KEYWORDS: 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 c.look = maxi(a.look, b.look) # die höhere Stufe gewinnt
c.look = maxi(a.look, b.look) # die höhere Stufe gewinnt; Game.fuse rechnet die Sammlung ein
return c return c
@@ -157,19 +156,18 @@ class Card extends RefCounted:
var elements: Array var elements: Array
var attack: int = 0 var attack: int = 0
var health: int = 0 var health: int = 0
var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet, höchstens 10 (aus den Grundwerten)
var keywords: Array = [] # feste Eigenschaften der Karte (Elementpaare geben ihre zur Laufzeit) var keywords: Array = [] # feste Eigenschaften der Karte (Elementpaare geben ihre zur Laufzeit)
var fusions := 0 # wie oft verschmolzen (Verschmelzen kostet 1, 2, 4, 8 …) var look := -1 # Seltenheitsstufe des Exemplars (gibt +1/+1 je Stufe); -1: Grundkarte aus dem Pool
var look := -1 # Seltenheitsstufe verschmolzener Karten (die höhere der Eltern); -1: aus der Sammlung des Besitzers
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health} var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health}
if not keywords.is_empty(): if not keywords.is_empty():
d["keywords"] = keywords d["keywords"] = keywords
if fusions > 0:
d["fusions"] = fusions
if look >= 0: if look >= 0:
d["look"] = look d["look"] = look
if cost != Cards.cost_of(attack, health):
d["cost"] = cost
return d return d
## Stufe dieser Karte für einen Besitzer mit dieser Sammlung (Game.Player.looks). ## Stufe dieser Karte für einen Besitzer mit dieser Sammlung (Game.Player.looks).
@@ -184,9 +182,8 @@ class Card extends RefCounted:
c.elements = Array(d.elements) c.elements = Array(d.elements)
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.cost = int(d.get("cost", Cards.cost_of(c.attack, c.health)))
c.keywords = Array(d.get("keywords", [])) c.keywords = Array(d.get("keywords", []))
c.fusions = int(d.get("fusions", 0))
c.look = int(d.get("look", -1)) c.look = int(d.get("look", -1))
return c return c
@@ -194,6 +191,15 @@ class Card extends RefCounted:
func copy() -> Card: func copy() -> Card:
return Card.from_dict(to_dict()) return Card.from_dict(to_dict())
## Exemplar in einer Seltenheitsstufe: +1/+1 je Stufe, die Kosten bleiben die der Grundkarte.
func leveled(level: int) -> Card:
var c := copy()
c.look = level
c.attack += level
c.health += level
c.cost = cost
return c
## Anzeigename in der aktuellen Sprache. ## 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"):
@@ -206,10 +212,6 @@ class Card extends RefCounted:
func combo() -> String: func combo() -> String:
return Cards.combo_name(elements) return Cards.combo_name(elements)
## Mana für die nächste Verschmelzung mit dieser Karte.
func fusion_cost() -> int:
return 1 << fusions
## Kartentext: die festen Eigenschaften. ## Kartentext: die festen Eigenschaften.
func text() -> String: func text() -> String:
if keywords.is_empty(): if keywords.is_empty():

View File

@@ -1,16 +1,54 @@
class_name DeckGen class_name DeckGen
## Der gemeinsame Stapel (jede Karte dreimal) und Gegner. ## Stapel: die vier Startdecks, Auffüllen aus der Sammlung, zufällige Stapel für Gegner.
const COPIES := 3 const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens
## Frisch gemischter Stapel; das Ende des Arrays ist oben. ## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang.
static func deck(r: RandomNumberGenerator) -> Array: static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array:
var cheap := Cards.all().filter(func(c): return c.cost <= Game.START_MANA)
var rest := Cards.all().filter(func(c): return c.cost > Game.START_MANA and c.cost <= max_cost)
Game.shuffle(cheap, r)
Game.shuffle(rest, r)
var out := []
for c in cheap.slice(0, CHEAP_IN_RANDOM) + rest + cheap.slice(CHEAP_IN_RANDOM):
if out.size() >= Game.DECK_MIN:
break
out.append(c.id)
return out
## Stapel eines Kampagnengegners: Kapitel 1 bis Kosten 3, Kapitel 2 bis 5, Kapitel 3 alles.
static func opponent_deck(r: RandomNumberGenerator, chapter: int) -> Array:
return random_deck(r, [3, 5, 99][clampi(chapter - 1, 0, 2)])
## Startdeck für ein Element: die 25 Karten des Elements bis Angriff 4, dazu je fünf billige der
## anderen Elemente (DECK_MIN Karten).
static func start_deck(element: String) -> Array:
var out := [] var out := []
for c in Cards.all(): for c in Cards.all():
for i in COPIES: if c.elements[0] == element and c.attack <= 4:
out.append(c) out.append(c.id)
Game.shuffle(out, r) for e in Cards.ELEMENTS:
if e == element:
continue
var cheap := Cards.all().filter(func(c): return c.elements[0] == e and c.cost <= Game.START_MANA)
cheap.sort_custom(func(a, b): return a.cost < b.cost if a.cost != b.cost else a.attack > b.attack)
for c in cheap.slice(0, 5):
out.append(c.id)
return out
## Füllt deck aus pool (Karten-IDs) bis limit auf, die teuersten zuerst; nichts doppelt.
static func fill(deck: Array, pool: Array, limit: int) -> Array:
var out := deck.duplicate()
var cards := pool.filter(func(id): return not (id in out)).map(func(id): return Cards.by_id(id))
cards.sort_custom(func(a, b): return a.cost > b.cost if a.cost != b.cost else a.id < b.id)
for c in cards:
if out.size() >= limit:
break
out.append(c.id)
return out return out

View File

@@ -7,11 +7,11 @@ const MAX_BOARD := 5
const MAX_HAND := 5 const MAX_HAND := 5
const START_HAND := 5 # Startkarten, alle im ersten Zug bezahlbar const START_HAND := 5 # Startkarten, alle im ersten Zug bezahlbar
const START_MANA := 1 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt const START_MANA := 1 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt
const DRAW_COST := 2 # Ziehen: eine bezahlbare Karte vom Stapel const DECK_MIN := 40 # Karten je Stapel: mindestens so viele (Gegner und Startdecks genau so viele)
const DECK_MAX := 60 # und höchstens so viele
const START_LIFE := 30 const START_LIFE := 30
const ELEMENT_PAIR := 2 # so viele eigene offene Wesen eines Elements: Sondereffekt des Elements const ELEMENT_PAIR := 2 # so viele eigene offene Wesen eines Elements: Sondereffekt des Elements
const ELEMENT_MANA := 10 # je so viel Mana an ausgespielten Karten eines Elements: +1 Angriff für die eigenen Wesen des Elements const ELEMENT_COUNT := 5 # je so viele ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements
const OFFER := 3 # offene Karten auf dem Stapel, höchstens so teuer wie das Mana des Zugs; beim Ziehen wählt man eine, eine neue rückt nach
const DISCARD_GAIN := 1 # Abwerfen einer Handkarte bringt so viel Mana const DISCARD_GAIN := 1 # Abwerfen einer Handkarte bringt so viel Mana
# Merkmale der Kampagnen-Bosse (Player.perks). # Merkmale der Kampagnen-Bosse (Player.perks).
@@ -129,17 +129,18 @@ 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 hand: Array = [] # eigene Exemplare (Cards.Card.copy) var hand: Array = [] # eigene Exemplare (Cards.Card, in der eigenen Seltenheitsstufe)
var board: Array = [] var board: Array = []
var deck: Array = [] # eigener Stapel: Karten-IDs, das Ende ist oben
var energy := 0 # Mana in diesem Zug; der Rest verfällt var energy := 0 # Mana in diesem Zug; der Rest verfällt
var max_energy := 0 # Mana zu Zugbeginn (START_MANA + eigene Züge) var max_energy := 0 # Mana zu Zugbeginn (START_MANA + eigene Züge)
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 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
var played := {} # Element -> Mana der bisher ausgespielten Karten (je ELEMENT_MANA: +1 Angriff) var played := {} # Element -> bisher ausgespielte Karten (je ELEMENT_COUNT: +1/+1)
var offer: Array = [] # die OFFER offenen Karten auf dem Stapel (nur der Besitzer sieht sie) var draws := 0 # in diesem Zug gezogene Karten; die n-te kostet n-1 Mana
var drew := false # in diesem Zug schon gezogen? Das erste Ziehen ist frei var fatigue := 0 # Züge ohne Karten im Stapel: so viel Leben kostet der nächste
func _init(n: String, h: bool, l: int, t: String = "") -> void: func _init(n: String, h: bool, l: int, t: String = "") -> void:
name = n name = n
@@ -153,6 +154,25 @@ class Player extends RefCounted:
func alive() -> bool: func alive() -> bool:
return life > 0 return life > 0
## Eine Karte vom eigenen Stapel als Exemplar in der eigenen Stufe; null, wenn er leer ist.
## Mit max_cost kommt die oberste bezahlbare, zu teure wandern nach unten (Starthand).
func draw_from_deck(max_cost: int = 99) -> Cards.Card:
var skipped := []
var id := ""
while not deck.is_empty():
var top: String = deck.pop_back()
if Cards.by_id(top).cost <= max_cost:
id = top
break
skipped.append(top)
if id == "" and not skipped.is_empty(): # nichts Bezahlbares: dann eben die oberste
id = skipped.pop_front()
for sk in skipped:
deck.insert(0, sk)
if id == "":
return null
return Cards.by_id(id).leveled(int(looks.get(id, 0)))
## Offene eigene Wesen mit diesem Element. ## Offene eigene Wesen mit diesem Element.
func element_count(e: String) -> int: func element_count(e: String) -> int:
var n := 0 var n := 0
@@ -172,20 +192,19 @@ class Player extends RefCounted:
p.looks = looks p.looks = looks
p.turns = turns p.turns = turns
p.played = played.duplicate() p.played = played.duplicate()
p.offer = offer.duplicate() p.deck = deck.duplicate()
p.drew = drew p.draws = draws
p.fatigue = fatigue
for c in board: for c in board:
p.board.append(c.clone(p)) p.board.append(c.clone(p))
return p return p
var human: Player # der (erste) menschliche Spieler var human: Player # der menschliche Spieler
var humans: Array = [] # alle menschlichen Spieler (im Netz zwei)
var opponent: Player # der andere am Tisch 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] = []
@@ -199,7 +218,6 @@ func _init(h: Player, o: Player, r: RandomNumberGenerator = null) -> void:
human = h human = h
opponent = o opponent = o
players = [h, o] players = [h, o]
humans = players.filter(func(p): return p.is_human)
if r == null: if r == null:
r = make_rng() r = make_rng()
rng = r rng = r
@@ -250,7 +268,6 @@ 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
@@ -278,16 +295,21 @@ func masked_clone(viewer: Player) -> Game:
# --- Zugablauf --------------------------------------------------------------- # --- Zugablauf ---------------------------------------------------------------
## Jeder spielt mit seinem eigenen Stapel (leer: ein zufälliger); die Starthand sind fünf im
## ersten Zug bezahlbare Karten daraus.
func start(fixed_order: bool = false) -> void: 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
for c in draw_cards(START_HAND, START_MANA): if p.deck.is_empty():
p.hand.append(c.copy()) p.deck = DeckGen.random_deck(rng)
p.offer = draw_cards(OFFER, START_MANA) shuffle(p.deck, rng)
for k in START_HAND:
var c := p.draw_from_deck(START_MANA)
if c != null:
p.hand.append(c)
turn = 1 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)))
@@ -297,54 +319,31 @@ func start(fixed_order: bool = false) -> void:
begin_turn(current()) begin_turn(current())
## Nimmt n Karten vom Stapel, die höchstens max_cost kosten; zu teure wandern unter den Stapel. ## Ziehen: eine Karte vom eigenen Stapel. Die erste im Zug ist frei, jede weitere kostet eins
## Ist der Stapel leer, kommt ein frischer. ## mehr (0, 1, 2 …).
func draw_cards(n: int, max_cost: int = 999) -> Array:
var out := []
var skipped := []
var looked := 0
while out.size() < n:
if deck.is_empty():
deck = DeckGen.deck(rng)
say("Der Stapel wird neu gemischt.")
var c: Cards.Card = deck.pop_back()
looked += 1
if c.cost <= max_cost or looked > deck.size() + skipped.size() + n:
out.append(c)
else:
skipped.append(c)
for c in skipped:
deck.insert(0, c)
return out
## Ziehen: eine der offenen Karten kommt auf die Hand, eine neue rückt nach. Das erste Ziehen
## im Zug ist frei, jedes weitere kostet DRAW_COST.
func can_draw(p: Player) -> Array: func can_draw(p: Player) -> Array:
if winner != "" or p != current(): if winner != "" or p != current():
return [false, "Nicht dein Zug"] return [false, "Nicht dein Zug"]
if p.hand.size() >= MAX_HAND: if p.hand.size() >= MAX_HAND:
return [false, "Deine Hand ist voll"] return [false, "Deine Hand ist voll"]
if p.deck.is_empty():
return [false, "Dein Stapel ist leer"]
if p.energy < draw_cost(p): if p.energy < draw_cost(p):
return [false, "Nicht genug Mana"] return [false, "Nicht genug Mana"]
return [true, ""] return [true, ""]
func draw_cost(p: Player) -> int: func draw_cost(p: Player) -> int:
return 0 if not p.drew or "ziehen_gratis" in p.perks else DRAW_COST return maxi(0, p.draws - (1 if "ziehen_gratis" in p.perks else 0))
func draw(p: Player, index: int = 0) -> String: func draw(p: Player) -> String:
var ok := can_draw(p) var ok := can_draw(p)
if not ok[0]: if not ok[0]:
return ok[1] return ok[1]
if index < 0 or index >= p.offer.size():
return "Keine Karte gewählt"
p.energy -= draw_cost(p) p.energy -= draw_cost(p)
p.drew = true p.draws += 1
var card: Cards.Card = p.offer[index] p.hand.append(p.draw_from_deck())
p.hand.append(card.copy())
p.offer[index] = draw_cards(1, maxi(p.max_energy, START_MANA))[0] # nie teurer als das Mana dieses Zugs
say("%s zieht." % p.name) say("%s zieht." % p.name)
emit({"type": "draw", "player": players.find(p)}) emit({"type": "draw", "player": players.find(p)})
return "" return ""
@@ -359,9 +358,15 @@ func begin_turn(p: Player) -> void:
c.ready = true # lag eine Runde: greift jetzt an, Verdecktes darf aufgedeckt werden c.ready = true # lag eine Runde: greift jetzt an, Verdecktes darf aufgedeckt werden
c.attacks_done = 0 c.attacks_done = 0
p.turns += 1 p.turns += 1
p.drew = false # das erste Ziehen im Zug ist wieder frei p.draws = 0 # das erste Ziehen im Zug ist wieder frei
say("--- Zug %d: %s ---" % [turn, p.name]) say("--- Zug %d: %s ---" % [turn, p.name])
emit({"type": "turn", "player": players.find(p)}) emit({"type": "turn", "player": players.find(p)})
if p.deck.is_empty(): # Erschöpfung: ohne Stapel kostet jeder Zug mehr Leben, damit es ein Ende gibt
p.fatigue += 1
p.life -= p.fatigue
say("%s hat keine Karten mehr und verliert %d Leben." % [p.name, p.fatigue])
emit({"type": "damage", "target": ref_of(p), "amount": p.fatigue})
check_end()
## Zugende: Wesen mit Regeneration heilen ganz, dann ist der andere dran. ## Zugende: Wesen mit Regeneration heilen ganz, dann ist der andere dran.
@@ -417,7 +422,7 @@ func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String:
cr.max_health += 1 cr.max_health += 1
p.board.append(cr) p.board.append(cr)
for e in card.elements: for e in card.elements:
p.played[e] = int(p.played.get(e, 0)) + card.cost p.played[e] = int(p.played.get(e, 0)) + 1
if face_down: if face_down:
say("%s legt ein Wesen verdeckt." % p.name) say("%s legt ein Wesen verdeckt." % p.name)
else: else:
@@ -492,9 +497,9 @@ static func look_of(p: Player, c: Cards.Card) -> int:
return c.look if c.look >= 0 else int(p.looks.get(c.id, 0)) return c.look if c.look >= 0 else int(p.looks.get(c.id, 0))
## Kosten: 1 Mana, verdoppelt je Verschmelzung, die schon in einer der beiden steckt. ## Kosten: die Hälfte der Manasumme beider Karten, aufgerundet.
static func fusion_cost(a, b) -> int: static func fusion_cost(a, b) -> int:
return 1 << maxi(card_of(a).fusions, card_of(b).fusions) return ceili((card_of(a).cost + card_of(b).cost) / 2.0)
## a und b: zwei eigene Wesen auf dem Brett oder zwei eigene Handkarten. ## a und b: zwei eigene Wesen auf dem Brett oder zwei eigene Handkarten.
@@ -539,11 +544,11 @@ func fuse(p: Player, a, b) -> String:
## das Ergebnis ist nur angriffsbereit, wenn beide es waren. ## das Ergebnis ist nur angriffsbereit, wenn beide es waren.
func _fuse_board(p: Player, a: Creature, b: Creature) -> String: func _fuse_board(p: Player, a: Creature, b: Creature) -> String:
var card := Cards.fused(a.card, b.card, Cards.fusion_value(a.attack, b.attack), var card := Cards.fused(a.card, b.card, Cards.fusion_value(a.attack, b.attack),
Cards.fusion_value(a.max_health, b.max_health)) Cards.fusion_value(a.max_health - a.aura, b.max_health - b.aura))
card.look = maxi(look_of(p, a.card), look_of(p, b.card)) card.look = maxi(look_of(p, a.card), look_of(p, b.card))
var cr := Creature.new(card, p, next_uid) var cr := Creature.new(card, p, next_uid)
next_uid += 1 next_uid += 1
cr.health = mini(card.health, Cards.fusion_value(a.health, b.health)) cr.health = mini(card.health, Cards.fusion_value(mini(a.health, a.max_health - a.aura), mini(b.health, b.max_health - b.aura)))
cr.ready = a.ready and b.ready cr.ready = a.ready and b.ready
cr.attacks_done = maxi(a.attacks_done, b.attacks_done) cr.attacks_done = maxi(a.attacks_done, b.attacks_done)
var at := p.board.find(a) var at := p.board.find(a)
@@ -560,11 +565,11 @@ func _fuse_board(p: Player, a: Creature, b: Creature) -> String:
# --- Elementboni --------------------------------------------------------------- # --- Elementboni ---------------------------------------------------------------
## +1 Angriff je volle ELEMENT_MANA an ausgespielten Karten eines Elements des Spielers; mehrere Elemente zählen alle. ## +1/+1 je volle ELEMENT_COUNT ausgespielte Karten eines Elements des Spielers; mehrere Elemente zählen alle.
static func aura_of(elements: Array, played: Dictionary) -> int: static func aura_of(elements: Array, played: Dictionary) -> int:
var a := 0 var a := 0
for e in elements: for e in elements:
a += int(played.get(e, 0)) / ELEMENT_MANA a += int(played.get(e, 0)) / ELEMENT_COUNT
return a return a
@@ -579,18 +584,24 @@ func aura_for(c: Creature) -> int:
return a return a
## Rechnet die Elementboni neu; sie wirken nur auf den Angriff (Creature.power). ## Rechnet die Elementboni neu. Kommt ein Bonus, steigen Leben und Höchstleben; geht er
## (beim Verdecken), sinkt nur das Höchstleben, das Leben wird gekappt.
func apply_auras() -> void: func apply_auras() -> void:
for p in players: for p in players:
for c in p.board: for c in p.board:
c.aura = aura_for(c) var a := aura_for(c)
if a != c.aura:
var delta: int = a - c.aura
c.max_health += delta
c.health = mini(c.health + maxi(delta, 0), c.max_health)
c.aura = a
## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme (sie zählt selbst mit). ## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme (sie zählt selbst mit).
func forecast(p: Player, card: Cards.Card) -> int: func forecast(p: Player, card: Cards.Card) -> int:
var played := p.played.duplicate() var played := p.played.duplicate()
for e in card.elements: for e in card.elements:
played[e] = int(played.get(e, 0)) + card.cost played[e] = int(played.get(e, 0)) + 1
return aura_of(card.elements, played) * (2 if "element_doppelt" in p.perks else 1) return aura_of(card.elements, played) * (2 if "element_doppelt" in p.perks else 1)
@@ -685,20 +696,7 @@ func check_end() -> void:
return return
winner_player = alive[0] winner_player = alive[0]
winner = "human" if winner_player.is_human else "opponents" winner = "human" if winner_player.is_human else "opponents"
if humans.size() == 1: say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.")
say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.")
else:
say("%s gewinnt!" % winner_player.name)
# --- Zustand für das Netzwerk (siehe NetState) ------------------------------------
func snapshot(viewer: int) -> Dictionary:
return NetState.snapshot(self, viewer)
static func from_snapshot(d: Dictionary) -> Game:
return NetState.from_snapshot(d)
# --- Gegner und Kampfstart --------------------------------------------------- # --- Gegner und Kampfstart ---------------------------------------------------
@@ -711,7 +709,7 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
arr[j] = tmp arr[j] = tmp
## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}} ## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}, "deck": [ids]}
static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game: static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
if r == null: if r == null:
r = make_rng() r = make_rng()
@@ -724,6 +722,7 @@ static func new_battle_with(opp: Player, r: RandomNumberGenerator = null, fixed_
var human := Player.new("Spieler", true, START_LIFE) var human := Player.new("Spieler", true, START_LIFE)
human.perks = Array(rules.get("human_perks", [])) human.perks = Array(rules.get("human_perks", []))
human.looks = rules.get("looks", {}) human.looks = rules.get("looks", {})
human.deck = rules.get("deck", []).duplicate()
var g := Game.new(human, opp, r) var g := Game.new(human, opp, r)
for p in g.players: for p in g.players:
if "leben_35" in p.perks: if "leben_35" in p.perks:

View File

@@ -1,443 +0,0 @@
class_name Net
extends Node
## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten
## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen).
signal lobby_changed
signal started(seat: int)
signal snapshot_received(snap: Dictionary)
signal action_applied
signal ended(reason: String)
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
signal status_changed
const PORT := 24455
var peer: ENetMultiplayerPeer
var is_host := false
var connected := false
var my_name := "Spieler"
var my_pick := {"looks": {}} # eigene Kartenoptik (Seltenheit), die der Gegner sieht
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
var picks := {} # peer_id -> {"looks": {...}} (Host)
var seats := {} # peer_id -> Spielerindex (Host)
var my_seat := -1
var game: Game # Host
var status := ""
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
var rejoin_seconds := 60.0
var spectator := false # eigene Rolle: nur zuschauen
var spectators := {} # Host: peer_id -> Name
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
var i_am_successor := false
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
var reconnecting := false
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(func():
if not reconnecting:
_fail("Verbindung fehlgeschlagen"))
multiplayer.server_disconnected.connect(_on_server_lost)
func host(port: int = PORT) -> Error:
peer = ENetMultiplayerPeer.new()
var err := peer.create_server(port, 8) # ein Mitspieler, der Rest Zuschauer
if err != OK:
status = "Kann nicht hosten (Port %d belegt?)" % port
return err
multiplayer.multiplayer_peer = peer
is_host = true
connected = true
names = {1: my_name}
picks = {1: my_pick}
spectators = {}
status = "Warte auf Mitspieler …"
lobby_changed.emit()
return OK
func join(ip: String, port: int = PORT) -> Error:
peer = ENetMultiplayerPeer.new()
var err := peer.create_client(ip, port)
if err != OK:
status = "Adresse ungültig"
return err
multiplayer.multiplayer_peer = peer
is_host = false
status = "Verbinde mit %s …" % ip
return OK
func leave() -> void:
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
peer = null
is_host = false
connected = false
names = {}
picks = {}
seats = {}
waiting = {}
my_seat = -1
game = null
status = ""
spectator = false
spectators = {}
successor_ip = ""
i_am_successor = false
backup = {}
backup_names = {}
last_winner = ""
func local_addresses() -> Array:
return Array(IP.get_local_addresses()).filter(func(a): return "." in a and not a.begins_with("127."))
func _fail(reason: String) -> void:
status = reason
ended.emit(reason)
leave()
func _on_connected() -> void:
connected = true
status = "Verbunden"
register.rpc_id(1, my_name, my_pick, spectator)
# --- Host-Wechsel ---------------------------------------------------------------
# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen
# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der
# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück.
func _successor_id() -> int:
var ids: Array = seats.keys().filter(func(id): return id != 1)
ids.sort()
return ids[0] if not ids.is_empty() else -1
func _update_successor() -> void:
if not is_host or game == null:
return
var succ := _successor_id()
var ip := ""
if succ > 0 and peer != null and peer.get_peer(succ) != null:
ip = peer.get_peer(succ).get_remote_address()
for id in seats:
if id != 1:
set_successor.rpc_id(id, ip, id == succ)
## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel.
func _seat_names() -> Dictionary:
var out := {}
for id in seats:
out[seats[id]] = names.get(id, "")
for pname in waiting:
out[waiting[pname].seat] = pname
return out
@rpc("authority", "reliable")
func set_successor(ip: String, me: bool) -> void:
successor_ip = ip
i_am_successor = me
@rpc("authority", "reliable")
func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void:
backup = snap
backup_names = seat_names
func _on_server_lost() -> void:
if is_host:
return
if last_winner != "":
_fail("Host hat das Spiel beendet")
elif i_am_successor and not backup.is_empty():
_become_host()
elif successor_ip != "":
status = "Host weg, verbinde mit dem Nachfolger …"
status_changed.emit()
_reconnect(successor_ip)
else:
_fail("Host hat das Spiel beendet")
func _become_host() -> void:
var snap := backup
var seat_names := backup_names
var seat := my_seat
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
var err := FAILED
for attempt in 5:
err = host()
if err == OK:
break
await get_tree().create_timer(1.0).timeout
if err != OK:
_fail("Host weg, Übernahme fehlgeschlagen")
return
game = Game.from_snapshot(snap)
seats = {1: seat}
my_seat = seat
waiting = {}
for s in seat_names:
if int(s) != seat:
waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()}
game.say("%s übernimmt als Host." % my_name)
status = "Du bist jetzt Host, die anderen verbinden neu …"
successor_ip = ""
i_am_successor = false
backup = {}
migrated.emit()
func _reconnect(ip: String) -> void:
reconnecting = true
await get_tree().create_timer(2.0).timeout
for attempt in 5:
if join(ip) == OK:
var t := 0.0
while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING:
await get_tree().process_frame
t += get_process_delta_time()
if connected:
reconnecting = false
return
await get_tree().create_timer(1.0).timeout
reconnecting = false
_fail("Nachfolge-Host nicht erreichbar")
func _on_peer_connected(_id: int) -> void:
pass
func _on_peer_disconnected(id: int) -> void:
if not is_host:
return
var pname: String = names.get(id, "")
names.erase(id)
picks.erase(id)
spectators.erase(id)
if game != null and seats.has(id):
var p: Game.Player = game.players[seats[id]]
if game.winner == "":
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)])
seats.erase(id)
_update_successor()
action_applied.emit()
lobby_update.rpc(names, spectators)
lobby_changed.emit()
## Host, je Bild: wartende Spieler überspringen; nach Ablauf der Frist scheiden sie aus.
func tick() -> void:
if not is_host or game == null or game.winner != "":
return
var now := Time.get_unix_time_from_system()
for pname in waiting.keys():
var w: Dictionary = waiting[pname]
var p: Game.Player = game.players[w.seat]
if now - float(w.since) > rejoin_seconds:
waiting.erase(pname)
game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
game.damage(p, 999)
game.cleanup()
game.check_end()
if game.winner == "" and game.current() == p:
game.end_turn() # sonst bliebe das Spiel beim Ausgeschiedenen hängen
action_applied.emit()
elif game.current() == p:
game.say("%s ist nicht da, der Zug wird übersprungen." % p.name)
game.end_turn()
action_applied.emit()
@rpc("any_peer", "reliable")
func register(name: String, pick: Dictionary, as_spectator: bool = false) -> void:
if not is_host:
return
var id := multiplayer.get_remote_sender_id()
if as_spectator:
spectators[id] = name
lobby_update.rpc(names, spectators)
lobby_changed.emit()
if game != null:
start_game.rpc_id(id, -1)
receive_snapshot.rpc_id(id, game.snapshot(-2))
return
if game != null:
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
if waiting.has(name):
seats[id] = waiting[name].seat
names[id] = name
waiting.erase(name)
game.say("%s ist zurück." % name)
start_game.rpc_id(id, seats[id])
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
_update_successor()
action_applied.emit()
else:
status = "%s wollte beitreten, das Spiel läuft schon" % name
return
names[id] = name
picks[id] = pick
lobby_update.rpc(names, spectators)
lobby_changed.emit()
@rpc("authority", "reliable")
func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
names = n
spectators = sp
lobby_changed.emit()
## Host: Duell mit dem ersten Mitspieler starten; allein gegen die KI.
func start_battle() -> void:
var ids: Array = names.keys()
ids.sort()
ids = ids.slice(0, 2)
var r := Game.make_rng()
var players := []
seats = {}
for id in ids:
var pick: Dictionary = picks.get(id, {})
var p := Game.Player.new(names[id], true, Game.START_LIFE)
for cid in pick.get("looks", {}):
if Cards.has_id(cid):
p.looks[cid] = int(pick.looks[cid])
players.append(p)
if players.size() < 2:
players.append(DeckGen.random_opponent())
game = Game.new(players[0], players[1], r)
game.start()
for id in ids:
seats[id] = game.players.find(players[ids.find(id)])
my_seat = seats[1]
for id in ids:
if id != 1:
start_game.rpc_id(id, seats[id])
for id in spectators:
start_game.rpc_id(id, -1)
_update_successor()
started.emit(my_seat)
@rpc("authority", "reliable")
func start_game(seat: int) -> void:
my_seat = seat
started.emit(seat)
## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes,
## der Nachfolger zusätzlich den vollen Spielstand).
func broadcast() -> void:
if not is_host or game == null:
return
for id in seats:
if id != 1:
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
for id in spectators:
receive_snapshot.rpc_id(id, game.snapshot(-2))
var succ := _successor_id()
if succ > 0:
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
@rpc("authority", "reliable")
func receive_snapshot(snap: Dictionary) -> void:
last_winner = str(snap.get("winner", ""))
snapshot_received.emit(snap)
## Mitspieler: Aktion an den Host schicken.
func send_action(action: Dictionary) -> void:
if is_host:
_apply(1, action)
else:
request_action.rpc_id(1, action)
@rpc("any_peer", "reliable")
func request_action(action: Dictionary) -> void:
if not is_host:
return
_apply(multiplayer.get_remote_sender_id(), action)
func _apply(id: int, action: Dictionary) -> void:
if game == null or not seats.has(id):
return
var p: Game.Player = game.players[seats[id]]
if game.winner != "":
return
if game.current() != p:
return
match str(action.get("type", "")):
"play":
var idx := int(action.get("hand", -1))
if idx < 0 or idx >= p.hand.size():
return
game.play_card(p, p.hand[idx], bool(action.get("face_down", false)))
"reveal":
var cr := game.find_creature(int(action.get("uid", -1)))
if cr != null:
game.reveal(p, cr)
"discard":
var hi := int(action.get("hand", -1))
if hi >= 0 and hi < p.hand.size():
game.discard(p, p.hand[hi])
"attack":
var cr := game.find_creature(int(action.get("uid", -1)))
var t = resolve_ref(action.get("target"))
if cr != null and t != null:
game.attack(cr, t)
"draw":
game.draw(p, int(action.get("index", 0)))
"fuse": # zwei Wesen (uid, other) oder zwei Handkarten (hand, other_hand)
var a = game.find_creature(int(action.get("uid", -1)))
var b = game.find_creature(int(action.get("other", -1)))
var i := int(action.get("hand", -1))
var j := int(action.get("other_hand", -1))
if a == null and i >= 0 and j >= 0 and i < p.hand.size() and j < p.hand.size():
a = p.hand[i]
b = p.hand[j]
if a != null and b != null:
game.fuse(p, a, b)
"end":
game.end_turn()
_:
return
action_applied.emit()
func resolve_ref(ref):
if ref == null or not (ref is Dictionary):
return null
if ref.get("kind") == "creature":
return game.find_creature(int(ref.get("uid", -1)))
var i := int(ref.get("index", -1))
if i >= 0 and i < game.players.size():
return game.players[i]
return null
static func ref_dict(target) -> Dictionary:
if target is Game.Creature:
return {"kind": "creature", "uid": target.uid}
return {"kind": "player", "index": target.order}

View File

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

View File

@@ -1,109 +0,0 @@
class_name NetState
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln).
## Karten reisen als ID; eigene Exemplare mit Schlüsselwörtern und verschmolzene als Ganzes.
static func card_ref(c: Cards.Card):
return c.id if Cards.has_id(c.id) and c.keywords.is_empty() and c.fusions == 0 else c.to_dict()
static func card_of(ref) -> Cards.Card:
return Cards.Card.from_dict(ref) if ref is Dictionary else Cards.by_id(ref).copy()
## Zustand aus Sicht eines Spielers: fremde Hand nur als Anzahl, fremde
## verdeckte Wesen ohne Karte und Werte. viewer -1: alles sichtbar (Host-Wechsel); -2: Zuschauer.
static func snapshot(g: Game, viewer: int) -> Dictionary:
var ps := []
for i in g.players.size():
var p: Game.Player = g.players[i]
var mine := i == viewer or viewer == -1
var board := []
for c in p.board:
var masked: bool = c.hidden and not mine
board.append({"uid": c.uid, "card": card_ref(Cards.unknown() if masked else c.card),
"attack": 0 if masked else c.attack, "health": 0 if masked else c.health,
"max_health": 0 if masked else c.max_health, "attacks_done": c.attacks_done,
"ready": c.ready, "hidden": c.hidden, "aura": c.aura})
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
"energy": p.energy, "max_energy": p.max_energy,
"order": p.order, "perks": p.perks, "looks": p.looks, "turns": p.turns, "played": p.played,
"hand": p.hand.map(card_ref) if mine else p.hand.size(), "board": board,
"offer": p.offer.map(card_ref) if mine else [], "drew": p.drew})
var evs := []
for ev in g.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = card_ref(e.card)
evs.append(e)
return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx,
"winner": g.winner, "winner_player": g.players.find(g.winner_player) if g.winner_player != null else -1,
"next_uid": g.next_uid, "deck_size": g.deck.size(),
"log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs}
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
static func from_snapshot(d: Dictionary) -> Game:
var placeholder: Cards.Card = Cards.all()[0]
var list := []
for pd in d.players:
var p := Game.Player.new(pd.name, pd.is_human, int(pd.life), pd.title)
p.max_life = int(pd.max_life)
p.energy = int(pd.energy)
p.max_energy = int(pd.max_energy)
p.order = int(pd.order)
p.perks = Array(pd.perks)
for cid in pd.get("looks", {}):
p.looks[cid] = int(pd.looks[cid])
p.turns = int(pd.turns)
for e in pd.get("played", {}):
p.played[e] = int(pd.played[e])
if pd.hand is Array:
for ref in pd.hand:
p.hand.append(card_of(ref))
else:
for i in int(pd.hand):
p.hand.append(placeholder)
for ref in pd.get("offer", []):
p.offer.append(card_of(ref))
p.drew = bool(pd.get("drew", false))
for cd in pd.board:
var card := card_of(cd.card)
var c := Game.Creature.new(card, p, int(cd.uid))
c.attack = int(cd.attack)
c.health = int(cd.health)
c.max_health = int(cd.max_health)
c.attacks_done = int(cd.get("attacks_done", 0))
c.ready = bool(cd.get("ready", false))
c.hidden = bool(cd.get("hidden", false))
c.aura = int(cd.get("aura", 0))
p.board.append(c)
list.append(p)
var hi := int(d.human)
var g := Game.new(list[hi], list[1 - hi], RandomNumberGenerator.new())
g.players = list
g.turn = int(d.turn)
g.current_idx = int(d.current_idx)
g.winner = d.winner
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
g.next_uid = int(d.next_uid)
for i in int(d.get("deck_size", 0)):
g.deck.append(placeholder) # nur die Größe zählt; gezogen wird beim Host
for line in d.log:
g.log.append(str(line))
for ev in d.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = card_of(e.card)
for key in ["player", "uid", "amount"]:
if e.has(key):
e[key] = int(e[key])
for key in ["target", "attacker"]:
if e.has(key):
var r: Dictionary = e[key]
if r.has("uid"):
r["uid"] = int(r.uid)
if r.has("index"):
r["index"] = int(r.index)
g.events.append(e)
return g

View File

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

View File

@@ -1,14 +1,16 @@
class_name Profile class_name Profile
extends RefCounted extends RefCounted
## Spielstand: Münzen, Staub, Sammlung, Kampagne, Einstellungen. ## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen.
## Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ## Man startet mit einem von vier Startdecks (je ein Element im Mittelpunkt); Booster und die
## ist reine Optik: sie ändert weder Werte noch Text einer Karte. ## Werkstatt bringen den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe
## gibt (Cards.Card.leveled); kommt eine seltenere Version, ersetzt sie die alte, die zu Staub wird.
const SAVE_VERSION := 6 const SAVE_VERSION := 7
const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
const SAVE_PATH := "user://spielstand.json" const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100 const BOOSTER_COST := 100
const BOOSTER_SIZE := 5 const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"Common": 50.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 8.0, "Ultimate Rare": 2.0} const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0}
const START_COINS := 150 const START_COINS := 150
const REWARD_WIN := 100 const REWARD_WIN := 100
const REWARD_LOSS := 50 const REWARD_LOSS := 50
@@ -24,19 +26,34 @@ const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultim
var coins := 0 var coins := 0
var dust := 0 var dust := 0
var collection := {} # id -> Seltenheitsstufe (Index in Cards.RARITIES), fehlend = Common var collection := {} # eigene Karten: id -> Seltenheitsstufe (Index in Cards.RARITIES)
var deck: Array = [] # Karten-IDs des eigenen Stapels (Game.DECK_MIN bis DECK_MAX)
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false} var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt) var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1) var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
## Neues Profil: noch ohne Karten, das Startdeck wählt der Spieler (choose_start).
static func starter() -> Profile: static func starter() -> Profile:
var p := Profile.new() var p := Profile.new()
p.coins = START_COINS p.coins = START_COINS
return p return p
## Hat der Spieler schon ein Startdeck gewählt?
func started() -> bool:
return not collection.is_empty()
## Das Startdeck eines Elements wird Sammlung und Stapel.
func choose_start(element: String) -> void:
collection = {}
deck = DeckGen.start_deck(element)
for id in deck:
collection[id] = 0
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil. ## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
static func load_or_new(path: String = SAVE_PATH) -> Profile: static func load_or_new(path: String = SAVE_PATH) -> Profile:
for candidate in [path, path + ".bak"]: for candidate in [path, path + ".bak"]:
@@ -52,7 +69,7 @@ static func load_or_new(path: String = SAVE_PATH) -> Profile:
## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und ## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und
## Einstellungen, die alte Sammlung (Anzahl je Karte) hat keine Entsprechung mehr. ## Einstellungen und bekommen die Startsammlung.
static func from_dict(d: Dictionary) -> Profile: static func from_dict(d: Dictionary) -> Profile:
var p := Profile.new() var p := Profile.new()
p.coins = int(d.get("coins", 0)) p.coins = int(d.get("coins", 0))
@@ -63,6 +80,11 @@ static func from_dict(d: Dictionary) -> Profile:
for cid in coll: for cid in coll:
if Cards.has_id(cid): if Cards.has_id(cid):
p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1) p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
var ids := []
for cid in d.get("deck", []):
if p.collection.has(cid) and not (cid in ids):
ids.append(cid)
p.deck = ids.slice(0, Game.DECK_MAX)
var se = d.get("settings", {}) var se = d.get("settings", {})
p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)), p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)),
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)), "ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
@@ -77,7 +99,7 @@ static func from_dict(d: Dictionary) -> Profile:
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, "deck": deck,
"campaign_stage": campaign_stage, "settings": settings, "perk": perk} "campaign_stage": campaign_stage, "settings": settings, "perk": perk}
@@ -97,7 +119,11 @@ func take_notices() -> Array[String]:
return out return out
# --- Sammlung: Seltenheit als Aussehen -------------------------------------------- # --- Sammlung und Stapel -----------------------------------------------------------
func owns(cid: String) -> bool:
return collection.has(cid)
func level(cid: String) -> int: func level(cid: String) -> int:
return int(collection.get(cid, 0)) return int(collection.get(cid, 0))
@@ -116,17 +142,29 @@ func looks() -> Dictionary:
return out return out
## Anzahl Karten über Common. ## Der Stapel für den Kampf: der gewählte, mit weiteren eigenen Karten bis DECK_MIN aufgefüllt,
func upgraded_count() -> int: ## falls er zu klein ist (Karten können durch Zerlegen wegfallen).
var n := 0 func deck_cards() -> Array:
for cid in collection: return DeckGen.fill(deck.filter(func(id): return collection.has(id)), collection.keys(), Game.DECK_MIN)
if int(collection[cid]) > 0:
n += 1
return n ## Karte in den Stapel oder heraus. Gibt "" zurück oder eine Fehlermeldung.
func toggle_deck(cid: String) -> String:
if cid in deck:
deck.erase(cid)
return ""
if not owns(cid):
return "Karte nicht in der Sammlung"
if deck.size() >= Game.DECK_MAX:
return "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX
deck.append(cid)
return ""
## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung. ## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
func upgrade(cid: String) -> String: func upgrade(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid) var lvl := level(cid)
if lvl >= Cards.RARITIES.size() - 1: if lvl >= Cards.RARITIES.size() - 1:
return "Schon auf der höchsten Stufe" return "Schon auf der höchsten Stufe"
@@ -138,13 +176,29 @@ func upgrade(cid: String) -> String:
return "" return ""
## Gibt eine Stufe ab und bekommt Staub dafür. Gibt "" zurück oder eine Fehlermeldung. ## Entzaubern: eine Stufe abgeben bringt Staub; eine Common-Karte verlässt die Sammlung.
## Gibt "" zurück oder eine Fehlermeldung.
func dismantle(cid: String) -> String: func dismantle(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid) var lvl := level(cid)
if lvl == 0:
return "Common-Karten lassen sich nicht zerlegen"
dust += DUST_DISMANTLE[Cards.RARITIES[lvl]] dust += DUST_DISMANTLE[Cards.RARITIES[lvl]]
collection[cid] = lvl - 1 if lvl == 0:
collection.erase(cid)
deck.erase(cid)
else:
collection[cid] = lvl - 1
return ""
## Herstellen: eine fehlende Karte für Staub als Common. Gibt "" zurück oder eine Fehlermeldung.
func craft(cid: String) -> String:
if owns(cid):
return "Karte schon in der Sammlung"
if dust < CRAFT_COST:
return "Nicht genug Staub (%d nötig)" % CRAFT_COST
dust -= CRAFT_COST
collection[cid] = 0
return "" return ""
@@ -164,8 +218,9 @@ static func roll_booster(r: RandomNumberGenerator) -> Array:
return out return out
## Öffnet einen Booster. Höhere Stufen werten die Karte auf, alles andere wird zu Staub. ## Öffnet einen Booster. Neue Karten kommen in Sammlung und Stapel (solange Platz ist). Von jeder
## Gibt die Ergebnisse zurück, leer wenn die Münzen nicht reichen. ## Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine gleiche
## oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn die Münzen nicht reichen.
func open_booster(r: RandomNumberGenerator = null) -> Array: func open_booster(r: RandomNumberGenerator = null) -> Array:
if coins < BOOSTER_COST: if coins < BOOSTER_COST:
return [] return []
@@ -175,14 +230,20 @@ func open_booster(r: RandomNumberGenerator = null) -> Array:
var out := roll_booster(r) var out := roll_booster(r)
for res in out: for res in out:
var lvl: int = Cards.RARITIES.find(res.rarity) var lvl: int = Cards.RARITIES.find(res.rarity)
if lvl > level(res.card.id): res["new"] = not owns(res.card.id)
res["upgraded"] = false
res["dust"] = 0
if res.new:
collection[res.card.id] = lvl collection[res.card.id] = lvl
if deck.size() < Game.DECK_MAX:
deck.append(res.card.id)
elif lvl > level(res.card.id):
res["upgraded"] = true res["upgraded"] = true
res["dust"] = 0 res["dust"] = DUST_DISMANTLE[rarity(res.card.id)]
collection[res.card.id] = lvl
else: else:
res["upgraded"] = false
res["dust"] = DUST_DISMANTLE[res.rarity] res["dust"] = DUST_DISMANTLE[res.rarity]
dust += res.dust dust += res.dust
return out return out

View File

@@ -76,7 +76,7 @@ func _next_zone(step: int) -> void:
func items() -> Array: func items() -> Array:
match zone: match zone:
"deck": "deck":
return screen.offer_views return [screen.deck] if screen.deck.visible else []
"hand": "hand":
return screen.hand_views return screen.hand_views
"own": "own":
@@ -105,7 +105,7 @@ func confirm() -> void:
var item = items[index] var item = items[index]
match zone: match zone:
"deck": "deck":
screen.on_click("deck", index) screen.on_click("deck", null)
"hand": "hand":
screen.on_click("hand", item.card) screen.on_click("hand", item.card)
"own": "own":

View File

@@ -1,31 +1,29 @@
class_name BattleLayout class_name BattleLayout
## Feste Bildschirmaufteilung des Duells (1280x800). Mitte von oben nach unten: Hand des Gegners ## Feste Bildschirmaufteilung des Duells (1280x800). Mitte von oben nach unten: Hand des Gegners
## (Rückseiten), sein Brett, seine Elementleiste, die eigene Elementleiste (die Naht liegt auf halber ## (Rückseiten), sein Brett, eigenes Brett, eigene Hand. Links: Kasten des Gegners, daneben die
## Höhe), eigenes Brett, eigene Hand. Links: Kasten des Gegners, die drei offenen Karten des ## Elementzähler beider Seiten je neben ihrem Brett, Zusatzzeile und Meldungen. Rechts von oben:
## Stapels (mittig), eigener Kasten. Rechts oben die Knöpfe, rechts mittig die Vorschau, darunter Meldungen. ## Stapel und Kasten des Gegners, die Vorschau, eigener Kasten und Stapel. Optionen per Esc.
const B_GIVEUP := Rect2(1070, 10, 96, 32) const B_UNDO := Rect2(20, 10, 210, 32) # links oben: Rückgängig, darunter Überspringen (Gegnerzug)
const B_LOG := Rect2(1172, 10, 96, 32) const B_SKIP := Rect2(20, 48, 210, 32)
const B_END := Rect2(1070, 48, 198, 32) const OPP_DECK := Rect2(1090, 10, 130, 182) # Stapel des Gegners rechts oben
const OPP_BOX := Rect2(20, 11, 210, 96) const OPP_BOX := Rect2(1050, 200, 210, 70) # Kasten des Gegners darunter
const OPP_HAND_Y := 11 const OPP_HAND_Y := 11
const FIELD_OPP := Rect2(240, 364, 800, 32) const OPP_ELEMENTS := Rect2(20, 220, 210, 128) # Elementzähler des Gegners, links von seinem Brett
const FIELD := Rect2(240, 404, 800, 32) const OWN_ELEMENTS := Rect2(20, 440, 210, 128) # eigene Elementzähler, links vom eigenen Brett
const MSG := Rect2(1040, 630, 220, 60) const INFO := Rect2(20, 580, 210, 60) # Zusatzzeile zur Vorschau
const STATUS := Rect2(20, 694, 210, 96) const MSG := Rect2(20, 650, 210, 80) # Meldungen
const B_UNDO := Rect2(1070, 86, 198, 32) const STATUS := Rect2(1050, 526, 210, 70) # eigener Kasten rechts über dem Stapel
const DECK := Rect2(1090, 606, 130, 182) # eigener Stapel rechts unten
const OPTIONS := Rect2(440, 180, 400, 420) # Optionsmenü (Esc)
const OWN_X := 345 const OWN_X := 345
const HAND_W := 110 # Handkarten so groß wie die Wesen auf dem Feld const HAND_W := 110 # Handkarten so groß wie die Wesen auf dem Feld
const HAND_H := 140 const HAND_H := 140
const HAND_GAP := 10 const HAND_GAP := 10
const HAND_Y := 610 const HAND_Y := 610
const HAND_ARC := 3.0 # Grad Neigung und Pixel Absenkung je Platz von der Mitte (leichter Bogen) const HAND_ARC := 3.0 # Grad Neigung und Pixel Absenkung je Platz von der Mitte (leichter Bogen)
const OFFER_X := 70 # die drei offenen Karten des Stapels, links übereinander um die Mitte
const OFFER_Y := 180
const OFFER_GAP := 10
const DECK_LABEL := Rect2(20, 632, 210, 22)
const LOG_PANEL := Rect2(240, 60, 800, 560) const LOG_PANEL := Rect2(240, 60, 800, 560)
const PREVIEW_SIZE := Vector2(180, 252) const PREVIEW_SIZE := Vector2(170, 238)
const CENTER_X := 240 const CENTER_X := 240
const CENTER_W := 800 const CENTER_W := 800
@@ -61,11 +59,11 @@ static func hand_arc(count: int, i: int, mirrored: bool = false) -> Array:
return [t * HAND_ARC * sign, t * t * HAND_ARC * sign] return [t * HAND_ARC * sign, t * t * HAND_ARC * sign]
## Vorschau der gezeigten Karte: fest rechts auf halber Höhe. ## Vorschau der gezeigten Karte: fest rechts zwischen den beiden Kästen.
static func preview_rect(_anchor: Rect2) -> Rect2: static func preview_rect(_anchor: Rect2) -> Rect2:
return Rect2(Vector2(1050, 400 - PREVIEW_SIZE.y / 2), PREVIEW_SIZE) return Rect2(Vector2(1070, 278), PREVIEW_SIZE)
## Zusatzzeile (Elementbonus, Eigenschaften) unter der Vorschau. ## Zusatzzeile (Elementbonus, Eigenschaften) links unter den Elementzählern.
static func info_rect(preview: Rect2) -> Rect2: static func info_rect(_preview: Rect2) -> Rect2:
return Rect2(1030, preview.end.y + 8, 230, 80) return INFO

View File

@@ -1,17 +1,16 @@
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.
## Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden); den Zug beendet
## die Leertaste oder der Rechtsklick ins Leere.
## Mittlere Maustaste auf eine Handkarte legt sie verdeckt. Rechtsklick auf eine Handkarte oder ## Mittlere Maustaste auf eine Handkarte legt sie verdeckt. Rechtsklick auf eine Handkarte oder
## ein eigenes Wesen öffnet das Kartenmenü: verdeckt legen, aufdecken, Eigenschaften kaufen. ## ein eigenes Wesen öffnet das Kartenmenü: verdeckt legen, aufdecken, Eigenschaften kaufen.
## Verschmelzen: eine Karte auf eine andere ziehen und dort eine Sekunde stillhalten. Mit ## Verschmelzen: eine Karte auf eine andere ziehen und dort eine Sekunde stillhalten. Mit
## Gamepad öffnet Y das Menü, dort zusätzlich mit „Verschmelzen mit …“. ## Gamepad öffnet Y das Menü, dort zusätzlich mit „Verschmelzen mit …“.
## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer). ## Modi: Einzelspieler und Kampagne.
var app var app
var stage := -1 # Kampagnenstufe oder -1 var stage := -1 # Kampagnenstufe oder -1
var net_play := false # Netzwerkspiel (auch als Zuschauer)
var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1
var net: Net
var game: Game var 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
@@ -60,12 +59,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 offer_views: Array = [] # die offenen Karten des Stapels (CardView) var deck: Deck
var offer_layer: Control var opp_deck: Deck # Stapel des Gegners, nur zum Ansehen
var deck_label: Label
var draw_enabled := false
var log_overlay: Control var log_overlay: Control
var b_log: Button var options: Control # Optionsmenü (Esc): Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden
var status_panel: Panel var status_panel: Panel
var fx_layer: Control var fx_layer: Control
var log_box: RichTextLabel var log_box: RichTextLabel
@@ -74,8 +71,6 @@ var name_label: Label
var life_label: Label var life_label: Label
var energy_label: Label var energy_label: Label
var msg_label: Label var msg_label: Label
var b_end: Button
var b_giveup: Button
var element_view: ElementView # eigene Elementleiste var element_view: ElementView # eigene Elementleiste
var opp_element_view: ElementView # die des Gegners darüber var opp_element_view: ElementView # die des Gegners darüber
var info_label: Label # Vorschau: Elementbonus, Merkmale var info_label: Label # Vorschau: Elementbonus, Merkmale
@@ -89,7 +84,6 @@ var reward_label: Label
var notice_label: Label var notice_label: Label
var confirm: Control var confirm: Control
var cursor_layer: Control var cursor_layer: Control
var wait_label: Label
var b_skip: Button var b_skip: Button
var b_undo: Button var b_undo: Button
@@ -98,30 +92,6 @@ func _init(campaign_stage: int = -1) -> void:
stage = campaign_stage stage = campaign_stage
static func online(seat: int) -> BattleScreen:
var b := BattleScreen.new(-1)
b.net_play = true
b.online_seat = seat
return b
func is_online() -> bool:
return net_play
## Zuschauer im Netzwerk: kein eigener Platz, keine Hand.
func spectating() -> bool:
return is_online() and online_seat < 0
func is_client() -> bool:
return is_online() and not net.is_host
func counts_for_profile() -> bool:
return not is_online()
func _ready() -> void: func _ready() -> void:
var s: Dictionary = app.profile.settings var s: Dictionary = app.profile.settings
fx = BattleFx.new(self) fx = BattleFx.new(self)
@@ -129,31 +99,19 @@ func _ready() -> void:
pad = BattleGamepad.new(self) pad = BattleGamepad.new(self)
ai_delay = 0.75 / float(s.ai_speed) ai_delay = 0.75 / float(s.ai_speed)
_build_static() _build_static()
if is_online(): if stage >= 0:
net = app.net
net.ended.connect(_on_net_ended)
net.migrated.connect(_on_migrated)
net.status_changed.connect(_on_net_status)
if net.is_host:
game = net.game
net.action_applied.connect(after_action)
else:
net.snapshot_received.connect(_on_snapshot)
wait_label.visible = true
return # der erste Zustand kommt vom Host
elif stage >= 0:
var r := Game.make_rng() var r := Game.make_rng()
game = Game.new_battle_with(Campaign.opponent(stage), r, false, own_rules()) game = Game.new_battle_with(Campaign.opponent(stage, r), r, false, own_rules())
else: else:
game = Game.new_battle(Game.make_rng(), own_rules()) game = Game.new_battle(Game.make_rng(), own_rules())
smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile() smart_ai = bool(app.profile.difficulty().smart)
_begin() _begin()
## Kartenoptik, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1). ## Eigener Stapel und Kartenstufen, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1).
func own_rules() -> Dictionary: func own_rules() -> Dictionary:
var p: Profile = app.profile var p: Profile = app.profile
var rules := {"looks": p.looks()} var rules := {"looks": p.looks(), "deck": p.deck_cards()}
if stage < 0: if stage < 0:
rules["opponent_life"] = int(p.difficulty().life) rules["opponent_life"] = int(p.difficulty().life)
if p.perk != "" and p.perk_unlocked(): if p.perk != "" and p.perk_unlocked():
@@ -162,37 +120,22 @@ func own_rules() -> Dictionary:
func _begin() -> void: func _begin() -> void:
if is_online(): game.events.clear()
perspective = game.players[maxi(online_seat, 0)] perspective = game.human
else:
game.events.clear()
perspective = game.current() if game.current().is_human else game.humans[0]
build_hero_boxes() build_hero_boxes()
if is_online() and net.is_host:
net.broadcast()
game.events.clear()
refresh() refresh()
announce_turn() announce_turn()
func _build_static() -> void: func _build_static() -> void:
var L := BattleLayout var L := BattleLayout
# oben links: Aufgeben und Verlauf, klein b_skip = Style.button(self, "Überspringen", L.B_SKIP, func(): skip_ai = true)
b_giveup = Style.button(self, "Aufgeben", L.B_GIVEUP, func(): confirm.visible = true) b_skip.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.add_theme_font_size_override("font_size", 13)
b_end = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY)
b_end.add_theme_font_size_override("font_size", 15)
b_skip = Style.button(self, "Überspringen", L.B_END, func(): skip_ai = true)
b_skip.add_theme_font_size_override("font_size", 15)
b_skip.visible = false b_skip.visible = false
# Mitte: Elementzähler, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben # Elementzähler beider Seiten links neben ihrem Brett
Style.panel(self, L.FIELD_OPP, Style.darker(Style.PANEL, 0.9), 8) opp_element_view = ElementView.new(L.OPP_ELEMENTS)
opp_element_view = ElementView.new(L.FIELD_OPP)
add_child(opp_element_view) add_child(opp_element_view)
Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8) element_view = ElementView.new(L.OWN_ELEMENTS)
element_view = ElementView.new(L.FIELD)
add_child(element_view) add_child(element_view)
msg_label = Style.label(self, "", 14, Style.ORANGE, L.MSG, "center", true) msg_label = Style.label(self, "", 14, Style.ORANGE, L.MSG, "center", true)
msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@@ -207,18 +150,21 @@ func _build_static() -> void:
var sx: float = L.STATUS.position.x var sx: float = L.STATUS.position.x
var sy: float = L.STATUS.position.y var sy: float = L.STATUS.position.y
var sw: float = L.STATUS.size.x var sw: float = L.STATUS.size.x
name_label = Style.label(self, "", 18, Style.TEXT, Rect2(sx + 8, sy + 6, sw - 16, 26), "center", true) name_label = Style.label(self, "", 16, Style.TEXT, Rect2(sx + 8, sy + 4, sw - 16, 22), "center", true)
name_label.clip_text = true name_label.clip_text = true
life_label = Style.label(self, "", 30, Style.RED, Rect2(sx + 8, sy + 42, sw / 2 - 8, 40), "left", true) life_label = Style.label(self, "", 26, Style.RED, Rect2(sx + 10, sy + 30, sw / 2 - 10, 34), "left", true)
energy_label = Style.label(self, "", 30, Style.BLUE, Rect2(sx + sw / 2, sy + 42, sw / 2 - 8, 40), "right", true) energy_label = Style.label(self, "", 26, Style.BLUE, Rect2(sx + sw / 2, sy + 30, sw / 2 - 10, 34), "right", true)
b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo) b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo)
b_undo.add_theme_font_size_override("font_size", 13) b_undo.add_theme_font_size_override("font_size", 13)
b_undo.visible = false b_undo.visible = false
deck_label = Style.label(self, "", 13, Style.MUTED, L.DECK_LABEL, "center") deck = Deck.new(L.DECK)
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center") deck.clicked.connect(func(): on_click("deck", null))
wait_label.visible = false add_child(deck)
opp_deck = Deck.new(L.OPP_DECK)
opp_deck.interactive = false
opp_deck.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(opp_deck)
hero_layer = _layer() hero_layer = _layer()
offer_layer = _layer()
tile_layer = _layer() tile_layer = _layer()
hand_layer = _layer() hand_layer = _layer()
preview_holder = _layer() preview_holder = _layer()
@@ -252,6 +198,28 @@ func _build_static() -> void:
log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
log_overlay.add_child(log_box) log_overlay.add_child(log_box)
Style.button(log_overlay, "Schließen", Rect2(L.LOG_PANEL.end.x - 180, L.LOG_PANEL.end.y - 52, 160, 40), func(): log_overlay.visible = false) Style.button(log_overlay, "Schließen", Rect2(L.LOG_PANEL.end.x - 180, L.LOG_PANEL.end.y - 52, 160, 40), func(): log_overlay.visible = false)
# Optionsmenü (Esc)
options = Style.overlay(self)
options.get_child(0).gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed:
options.visible = false)
Style.panel(options, L.OPTIONS, Style.PANEL, 10, Style.GOLD, 2)
Style.label(options, "Optionen", 24, Style.GOLD, Rect2(L.OPTIONS.position.x, L.OPTIONS.position.y + 18, L.OPTIONS.size.x, 34), "center", true)
var oy := L.OPTIONS.position.y + 76
for entry in [["Verlauf", func():
options.visible = false
log_overlay.visible = true],
["Aufgeben", func():
options.visible = false
confirm.visible = true],
["Einstellungen", func():
options.visible = false
app.show_screen(SettingsScreen.new(), true)],
["Hauptmenü", leave],
["Beenden", func(): get_tree().quit()]]:
var ob := Style.button(options, entry[0], Rect2(L.OPTIONS.position.x + 60, oy, L.OPTIONS.size.x - 120, 48), entry[1])
ob.add_theme_font_size_override("font_size", 18)
oy += 60
# Spielende # Spielende
overlay = _overlay() overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true) result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
@@ -261,8 +229,8 @@ func _build_static() -> void:
confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen") confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen")
func toggle_log() -> void: func toggle_options() -> void:
log_overlay.visible = not log_overlay.visible options.visible = not options.visible
func _layer() -> Control: func _layer() -> Control:
@@ -277,8 +245,6 @@ func _overlay() -> Control:
func leave() -> void: func leave() -> void:
if is_online():
net.leave()
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())
@@ -336,24 +302,22 @@ func build_hero_boxes() -> void:
var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name
if not o.perks.is_empty(): if not o.perks.is_empty():
title += " ★" title += " ★"
labels["name"] = Style.label(hero_layer, title, 18, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 6, box.size.x - 16, 26), "center", true) labels["name"] = Style.label(hero_layer, title, 16, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 4, box.size.x - 16, 22), "center", true)
labels.name.clip_text = true labels.name.clip_text = true
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
labels["life"] = Style.label(hero_layer, "", 30, Style.RED, Rect2(box.position.x + 8, box.position.y + 42, box.size.x / 2 - 8, 40), "left", true) labels["life"] = Style.label(hero_layer, "", 26, Style.RED, Rect2(box.position.x + 10, box.position.y + 30, box.size.x / 2 - 10, 34), "left", true)
labels["stats"] = Style.label(hero_layer, "", 30, Style.BLUE, Rect2(box.position.x + box.size.x / 2, box.position.y + 42, box.size.x / 2 - 8, 40), "right", true) labels["stats"] = Style.label(hero_layer, "", 26, Style.BLUE, Rect2(box.position.x + box.size.x / 2, box.position.y + 30, box.size.x / 2 - 10, 34), "right", true)
hero_labels[o] = labels hero_labels[o] = labels
# --- Ablauf und Eingabe ------------------------------------------------------------------ # --- Ablauf und Eingabe ------------------------------------------------------------------
func my_turn() -> bool: func my_turn() -> bool:
if spectating():
return false
return game != null and game.winner == "" and game.current() == perspective return game != null and game.winner == "" and game.current() == perspective
func any_overlay() -> bool: func any_overlay() -> bool:
return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible or options.visible
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
@@ -400,10 +364,12 @@ func _input(event: InputEvent) -> void:
close_menu() close_menu()
elif log_overlay.visible: elif log_overlay.visible:
log_overlay.visible = false log_overlay.visible = false
elif options.visible:
options.visible = false
elif selected != null or fuse_first != null: elif selected != null or fuse_first != null:
cancel_all() cancel_all()
elif not any_overlay(): elif not any_overlay():
confirm.visible = true options.visible = true
KEY_SPACE: KEY_SPACE:
if my_turn() and not any_overlay(): if my_turn() and not any_overlay():
end_turn() end_turn()
@@ -451,8 +417,6 @@ func _process(delta: float) -> void:
_end_drag() _end_drag()
do_fuse(a, b) do_fuse(a, b)
after_action() after_action()
if is_online() and net.is_host:
net.tick()
if game.winner != "": if game.winner != "":
if not ended: if not ended:
ended = true ended = true
@@ -461,8 +425,6 @@ func _process(delta: float) -> void:
var cur := game.current() var cur := game.current()
if cur.is_human: if cur.is_human:
return return
if is_client():
return
if skip_ai: if skip_ai:
ai_timer = 0.0 ai_timer = 0.0
else: else:
@@ -506,15 +468,10 @@ func _exit_tree() -> void:
func _finish() -> void: func _finish() -> void:
var won := game.winner == "human" var won := game.winner == "human"
if counts_for_profile(): var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0 reward = app.profile.record_battle(won, stage, factor)
reward = app.profile.record_battle(won, stage, factor) app.save()
app.save() app.sfx.play("win" if won else "lose")
app.sfx.play("win" if won else "lose")
else:
var wp := game.winner_player
var mine: bool = wp != null and wp == perspective
app.sfx.play("win" if mine else "lose")
refresh() refresh()
@@ -631,10 +588,7 @@ func fusion_partners(target) -> Array:
func play(card: Cards.Card, face_down: bool = false) -> void: func play(card: Cards.Card, face_down: bool = false) -> void:
if is_client(): var before: Game = game.clone()
net.send_action({"type": "play", "hand": perspective.hand.find(card), "face_down": face_down})
return
var before: Game = game.clone() if not is_online() else null
var log_len := game.log.size() var log_len := game.log.size()
var from := _hand_rect_of(card) var from := _hand_rect_of(card)
var err := game.play_card(perspective, card, face_down) var err := game.play_card(perspective, card, face_down)
@@ -655,9 +609,6 @@ func play(card: Cards.Card, face_down: bool = false) -> void:
## Deckt ein eigenes verdecktes Wesen auf (kostenlos). ## Deckt ein eigenes verdecktes Wesen auf (kostenlos).
func do_reveal(cr: Game.Creature) -> void: func do_reveal(cr: Game.Creature) -> void:
clear_undo() clear_undo()
if is_client():
net.send_action({"type": "reveal", "uid": cr.uid})
return
var err := game.reveal(perspective, cr) var err := game.reveal(perspective, cr)
if err != "": if err != "":
say(tr(err)) say(tr(err))
@@ -667,7 +618,7 @@ func do_reveal(cr: Game.Creature) -> void:
## Kartenmenü neben einer Handkarte oder einem eigenen Wesen. full: auch verschmelzen anbieten ## Kartenmenü neben einer Handkarte oder einem eigenen Wesen. full: auch verschmelzen anbieten
## (Gamepad, das nicht ziehen kann). ## (Gamepad, das nicht ziehen kann).
func open_menu(target, anchor: Rect2, full: bool = false) -> void: func open_menu(target, anchor: Rect2, full: bool = false) -> void:
if spectating() or not my_turn(): if not my_turn():
return return
cancel() cancel()
Style.clear(menu_layer) Style.clear(menu_layer)
@@ -685,7 +636,7 @@ func open_menu(target, anchor: Rect2, full: bool = false) -> void:
entries.append([tr("Aufdecken"), func(): do_reveal(target), game.can_reveal(perspective, target)[0]]) entries.append([tr("Aufdecken"), func(): do_reveal(target), game.can_reveal(perspective, target)[0]])
if full: if full:
var partners := fusion_partners(target) var partners := fusion_partners(target)
entries.append([tr("Verschmelzen mit … ◆ %d") % card.fusion_cost(), func(): entries.append([tr("Verschmelzen mit …"), func():
fuse_first = target fuse_first = target
say(tr("Ziel wählen")) say(tr("Ziel wählen"))
refresh(), not partners.is_empty()]) refresh(), not partners.is_empty()])
@@ -729,22 +680,16 @@ func close_menu() -> void:
## Abwerfen: eine Handkarte gegen Mana loswerden. ## Abwerfen: eine Handkarte gegen Mana loswerden.
func do_discard(card: Cards.Card) -> void: func do_discard(card: Cards.Card) -> void:
clear_undo() clear_undo()
if is_client():
net.send_action({"type": "discard", "hand": perspective.hand.find(card)})
return
var err := game.discard(perspective, card) var err := game.discard(perspective, card)
if err != "": if err != "":
say(tr(err)) say(tr(err))
after_action() after_action()
## Ziehen: kostet Mana, eine Karte kommt auf die Hand. ## Ziehen: eine Karte vom eigenen Stapel, die erste im Zug frei.
func do_draw(index: int = 0) -> void: func do_draw() -> void:
clear_undo() clear_undo()
if is_client(): var err := game.draw(perspective)
net.send_action({"type": "draw", "index": index})
return
var err := game.draw(perspective, index)
if err != "": if err != "":
say(tr(err)) say(tr(err))
else: else:
@@ -755,12 +700,6 @@ func do_draw(index: int = 0) -> void:
func do_fuse(a, b) -> void: func do_fuse(a, b) -> void:
cancel() cancel()
clear_undo() clear_undo()
if is_client():
if a is Cards.Card:
net.send_action({"type": "fuse", "hand": perspective.hand.find(a), "other_hand": perspective.hand.find(b)})
else:
net.send_action({"type": "fuse", "uid": a.uid, "other": b.uid})
return
var err := game.fuse(perspective, a, b) var err := game.fuse(perspective, a, b)
if err != "": if err != "":
say(tr(err)) say(tr(err))
@@ -784,8 +723,6 @@ func undo() -> void:
restored.say("Rückgängig.") restored.say("Rückgängig.")
game = restored game = restored
perspective = game.players[idx] perspective = game.players[idx]
if is_online() and net.is_host:
net.game = game
clear_undo() clear_undo()
cancel() cancel()
build_hero_boxes() build_hero_boxes()
@@ -797,9 +734,6 @@ func end_turn() -> void:
return return
cancel() cancel()
clear_undo() clear_undo()
if is_client():
net.send_action({"type": "end"})
return
game.end_turn() game.end_turn()
ai_timer = ai_delay ai_timer = ai_delay
after_action() after_action()
@@ -823,17 +757,14 @@ func on_click(kind: String, obj) -> void:
selected = null selected = null
if kind == "enemy" or kind == "hero": if kind == "enemy" or kind == "hero":
clear_undo() clear_undo()
if is_client(): var err := g.attack(cr, obj)
net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)}) if err != "":
else: say(tr(err))
var err := g.attack(cr, obj)
if err != "":
say(tr(err))
after_action() after_action()
return return
match kind: match kind:
"deck": "deck":
do_draw(int(obj) if obj != null else 0) 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]:
@@ -949,6 +880,41 @@ func update_attack_preview() -> void:
v.queue_redraw() v.queue_redraw()
## Der eigene Stapel: Kartenstoß mit Kosten des nächsten Ziehens und Restzahl.
class Deck extends Control:
signal clicked
var enabled := true
var interactive := true # false: der Stapel des Gegners, nur Anzahl
var count := 0
var cost := 0
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_STOP
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit()
func _draw() -> void:
for k in range(2, -1, -1):
var r := Rect2(Vector2(k * 3, -k * 3), size - Vector2(6, 12))
r.position.y += 6
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD if enabled and interactive else Style.MUTED, 2), r)
if k == 0:
Style.border(self, r.grow(-12), Color("5a3c82"), 2, 6)
if interactive:
Style.text(self, tr("Ziehen"), 16, Style.TEXT if enabled else Style.MUTED, r.get_center() + Vector2(0, -12), "center", true)
Style.text(self, "◆ %d" % cost, 20, Style.BLUE if enabled else Style.MUTED, r.get_center() + Vector2(0, 14), "center", true)
Style.text(self, str(count), 12, Style.MUTED, Vector2(r.get_center().x, r.end.y - 18), "center")
else:
Style.text(self, str(count), 22, Style.TEXT, r.get_center(), "center", true)
if interactive and not enabled:
draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35))
## Eine verdeckte Handkarte des Gegners in voller Größe. ## Eine verdeckte Handkarte des Gegners in voller Größe.
class CardBack extends Control: class CardBack extends Control:
func _init(rect: Rect2) -> void: func _init(rect: Rect2) -> void:
@@ -967,51 +933,10 @@ class CardBack extends Control:
# --- Netzwerk -------------------------------------------------------------------------------
func _on_snapshot(snap: Dictionary) -> void:
var first := game == null
game = Game.from_snapshot(snap)
wait_label.visible = false
if first:
_begin()
return
perspective = game.players[maxi(online_seat, 0)]
build_hero_boxes()
after_action()
## Host-Wechsel: das Spiel läuft jetzt auf diesem Rechner weiter.
func _on_migrated() -> void:
game = net.game
if not net.action_applied.is_connected(after_action):
net.action_applied.connect(after_action)
wait_label.visible = false
perspective = game.players[online_seat]
build_hero_boxes()
say("Du bist jetzt Host.")
after_action()
func _on_net_status() -> void:
wait_label.text = net.status
wait_label.visible = true
func _on_net_ended(reason: String) -> void:
if game == null or game.winner == "":
say(reason)
await get_tree().create_timer(1.5).timeout
if is_inside_tree():
app.show_screen(MenuScreen.new())
# --- Ereignisse ---------------------------------------------------------------------------- # --- Ereignisse ----------------------------------------------------------------------------
## Nach jeder Aktion: Ereignisse sichern, Ansicht angleichen, Animationen abspielen. ## Nach jeder Aktion: Ereignisse sichern, Ansicht angleichen, Animationen abspielen.
func after_action() -> void: func after_action() -> void:
if is_online() and net.is_host:
net.broadcast()
var evs := game.events.duplicate() var evs := game.events.duplicate()
game.events.clear() game.events.clear()
var centers := {} var centers := {}
@@ -1138,23 +1063,12 @@ func refresh() -> void:
tiles.erase(uid) tiles.erase(uid)
fx.fade_out(t) fx.fade_out(t)
update_attack_preview() update_attack_preview()
Style.clear(offer_layer) deck.enabled = mine and g.can_draw(h)[0]
offer_views = [] deck.count = h.deck.size()
draw_enabled = mine and g.can_draw(h)[0] deck.cost = g.draw_cost(h)
if not spectating(): deck.queue_redraw()
for i in h.offer.size(): opp_deck.count = opponent().deck.size()
var oc: Cards.Card = h.offer[i] opp_deck.queue_redraw()
var orect := Rect2(BattleLayout.OFFER_X, BattleLayout.OFFER_Y + i * (BattleLayout.HAND_H + BattleLayout.OFFER_GAP), BattleLayout.HAND_W, BattleLayout.HAND_H)
var ov := CardView.new(oc, orect.size, false, oc.rarity_in(h.looks))
ov.position = orect.position
ov.dim = not draw_enabled
var idx := i
ov.clicked.connect(func(_v): on_click("deck", idx))
ov.hover.connect(func(c, e): show_preview(c, e, h.looks, "", orect))
offer_layer.add_child(ov)
offer_views.append(ov)
deck_label.text = "%s ◆ %d · %d" % [tr("Ziehen"), g.draw_cost(h), g.deck.size()]
deck_label.visible = not spectating()
Style.clear(hand_layer) Style.clear(hand_layer)
for o in others(): # die Hand des Gegners oben, nur Rückseiten, spiegelverkehrter Bogen for o in others(): # die Hand des Gegners oben, nur Rückseiten, spiegelverkehrter Bogen
@@ -1165,9 +1079,7 @@ func refresh() -> void:
back.rotation_degrees = arc[0] back.rotation_degrees = arc[0]
back.position.y += arc[1] back.position.y += arc[1]
hand_layer.add_child(back) hand_layer.add_child(back)
if spectating(): if true:
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(240, BattleLayout.HAND_Y + 18, 800, 30), "center")
else:
for i in h.hand.size(): for i in h.hand.size():
var card: Cards.Card = h.hand[i] var card: Cards.Card = h.hand[i]
var playable: bool = mine and g.can_play(h, card)[0] var playable: bool = mine and g.can_play(h, card)[0]
@@ -1199,10 +1111,8 @@ func refresh() -> void:
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
b_end.disabled = not mine var ai_running: bool = g.winner == "" and not g.current().is_human
var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client()
b_skip.visible = ai_running b_skip.visible = ai_running
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
element_view.update_from(g, h) element_view.update_from(g, h)
opp_element_view.update_from(g, opponent()) opp_element_view.update_from(g, opponent())
@@ -1222,20 +1132,13 @@ func refresh() -> void:
pad.draw_cursor() pad.draw_cursor()
if g.winner != "": if g.winner != "":
var wp := g.winner_player var won := g.winner == "human"
if counts_for_profile(): result_label.text = "Sieg!" if won else "Niederlage"
var won := g.winner == "human" result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED)
result_label.text = "Sieg!" if won else "Niederlage" reward_label.text = "+%d Münzen" % maxi(reward, 0)
result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED) var notes: Array[String] = app.profile.take_notices()
reward_label.text = "+%d Münzen" % maxi(reward, 0) if not notes.is_empty():
var notes: Array[String] = app.profile.take_notices() notice_label.text = "\n".join(notes)
if not notes.is_empty():
notice_label.text = "\n".join(notes)
else:
var mine_win: bool = wp != null and wp == perspective
result_label.text = ("%s gewinnt!" % wp.name) if wp != null and wp.is_human else "Die KI gewinnt"
result_label.add_theme_color_override("font_color", Style.GREEN if mine_win else Style.RED)
reward_label.text = "Gemeinsame Partie, keine Münzen"
overlay.visible = true overlay.visible = true
confirm.visible = false confirm.visible = false

View File

@@ -137,13 +137,15 @@ func refresh() -> void:
elif revealed.has(i): elif revealed.has(i):
var res: Dictionary = cards[i] var res: Dictionary = cards[i]
var c: Cards.Card = res.card var c: Cards.Card = res.card
var v := CardView.new(c, r.size, false, res.rarity) var v := CardView.new(c.leveled(Cards.RARITIES.find(res.rarity)), r.size, false, res.rarity)
v.position = r.position v.position = r.position
slots.add_child(v) slots.add_child(v)
if fresh.has(i): if fresh.has(i):
flip(v, fresh[i], res.rarity) flip(v, fresh[i], res.rarity)
Style.label(slots, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true) Style.label(slots, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if res.upgraded: if res.get("new", false):
Style.label(slots, "Neu!", 16, Style.GOLD, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
elif res.upgraded:
Style.label(slots, "Aufgewertet", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true) Style.label(slots, "Aufgewertet", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
else: else:
Style.label(slots, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center") Style.label(slots, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")

View File

@@ -1,7 +1,7 @@
class_name CollectionScreen class_name CollectionScreen
extends Control extends Control
## Sammlung: alle Karten gehören dir. Gesammelt wird die Seltenheitsstufe — reine Optik. ## Sammlung: alle Karten des Spiels, die fehlenden abgedunkelt. Eigene Karten lassen sich
## Aufwerten kostet Staub, eine Stufe abgeben bringt Staub. ## aufwerten (Staub) oder zerlegen (bringt Staub), fehlende herstellen (Staub).
var app var app
var filter := "" var filter := ""
@@ -15,6 +15,7 @@ var preview_holder: Control
var info_label: Label var info_label: Label
var b_dismantle: Button var b_dismantle: Button
var b_upgrade: Button var b_upgrade: Button
var b_craft: Button
func _ready() -> void: func _ready() -> void:
@@ -43,6 +44,7 @@ func _ready() -> void:
info_label = Style.label(self, "", 15, Style.MUTED, Rect2(840, 476, 380, 30), "center") info_label = Style.label(self, "", 15, Style.MUTED, Rect2(840, 476, 380, 30), "center")
b_upgrade = Style.button(self, "", Rect2(840, 530, 380, 46), upgrade, Style.PRIMARY) b_upgrade = Style.button(self, "", Rect2(840, 530, 380, 46), upgrade, Style.PRIMARY)
b_dismantle = Style.button(self, "", Rect2(840, 586, 380, 46), dismantle, Color("5a3c28")) b_dismantle = Style.button(self, "", Rect2(840, 586, 380, 46), dismantle, Color("5a3c28"))
b_craft = Style.button(self, "", Rect2(840, 530, 380, 46), craft, Style.PRIMARY)
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(840, 650, 380, 48), "center") msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(840, 650, 380, 48), "center")
refresh() refresh()
@@ -57,7 +59,7 @@ func set_filter(e: String) -> void:
refresh() refresh()
## Aufgewertete Karten zuerst, sonst in Kartenreihenfolge. ## Eigene Karten zuerst (höhere Stufen vorn), dann die fehlenden, sonst in Kartenreihenfolge.
func cards() -> Array: func cards() -> Array:
var out: Array = Cards.all().duplicate() var out: Array = Cards.all().duplicate()
if filter != "": if filter != "":
@@ -69,7 +71,10 @@ func cards() -> Array:
var order := {} var order := {}
for i in out.size(): for i in out.size():
order[out[i].id] = i order[out[i].id] = i
out.sort_custom(func(a, b): return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id]) out.sort_custom(func(a, b):
if p.owns(a.id) != p.owns(b.id):
return p.owns(a.id)
return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id])
return out return out
@@ -111,11 +116,31 @@ func dismantle() -> void:
refresh() refresh()
func craft() -> void:
if selected == null:
return
var err: String = app.profile.craft(selected.id)
if err == "":
app.sfx.play("combo")
app.save()
say(tr("%s hergestellt") % selected.title(), true)
else:
say(err, false)
refresh()
func refresh() -> void: func refresh() -> void:
var p: Profile = app.profile var p: Profile = app.profile
dust_label.text = "Staub: %d" % p.dust dust_label.text = "Staub: %d" % p.dust
grid.fill(cards(), func(c): return p.rarity(c.id)) var shown := cards()
grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
var views := grid.views()
for i in views.size():
views[i].dim = not p.owns(shown[i].id)
Style.clear(preview_holder) Style.clear(preview_holder)
b_craft.visible = false
b_upgrade.visible = true
b_dismantle.visible = true
if selected == null: if selected == null:
info_label.text = "Keine Karte gewählt" info_label.text = "Keine Karte gewählt"
b_upgrade.text = "Aufwerten" b_upgrade.text = "Aufwerten"
@@ -123,11 +148,19 @@ func refresh() -> void:
b_upgrade.disabled = true b_upgrade.disabled = true
b_dismantle.disabled = true b_dismantle.disabled = true
return return
var v := CardView.new(selected, Vector2(220, 308), false, p.rarity(selected.id)) var v := CardView.new(selected.leveled(p.level(selected.id)), Vector2(220, 308), false, p.rarity(selected.id))
v.position = Vector2(920, 152) v.position = Vector2(920, 152)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v) preview_holder.add_child(v)
var lvl := p.level(selected.id) var lvl := p.level(selected.id)
if not p.owns(selected.id):
info_label.text = tr("Nicht in der Sammlung")
b_upgrade.visible = false
b_dismantle.visible = false
b_craft.visible = true
b_craft.text = tr("Herstellen (%d Staub)") % Profile.CRAFT_COST
b_craft.disabled = p.dust < Profile.CRAFT_COST
return
info_label.text = "%s · %s" % [tr(selected.combo()), tr(p.rarity(selected.id))] info_label.text = "%s · %s" % [tr(selected.combo()), tr(p.rarity(selected.id))]
if lvl >= Cards.RARITIES.size() - 1: if lvl >= Cards.RARITIES.size() - 1:
b_upgrade.text = "Höchste Stufe" b_upgrade.text = "Höchste Stufe"
@@ -136,5 +169,5 @@ func refresh() -> void:
var next: String = Cards.RARITIES[lvl + 1] var next: String = Cards.RARITIES[lvl + 1]
b_upgrade.text = "Aufwerten zu %s (%d Staub)" % [tr(next), Profile.DUST_UPGRADE[next]] b_upgrade.text = "Aufwerten zu %s (%d Staub)" % [tr(next), Profile.DUST_UPGRADE[next]]
b_upgrade.disabled = p.dust < Profile.DUST_UPGRADE[next] b_upgrade.disabled = p.dust < Profile.DUST_UPGRADE[next]
b_dismantle.text = "Zerlegen" if lvl == 0 else "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]] b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]
b_dismantle.disabled = lvl == 0 b_dismantle.disabled = false

93
scripts/ui/deck_screen.gd Normal file
View File

@@ -0,0 +1,93 @@
class_name DeckScreen
extends Control
## Deck zusammenstellen: alle eigenen Karten im Raster, ein Klick nimmt eine Karte in den
## Stapel oder wieder heraus. Karten außerhalb des Stapels sind abgedunkelt. Der Stapel hält
## 40 bis 60 Karten; „Auffüllen“ nimmt die teuersten eigenen Karten des gewählten Filters.
var app
var filter := ""
var grid: CardGrid
var count_label: Label
var msg_label: Label
func _ready() -> void:
Style.header(self, "Deck", func(): app.show_screen(MenuScreen.new()))
Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var fb := Style.button(self, e, Rect2(108 + i * 82, 86, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14)
grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
grid.card_clicked.connect(toggle)
add_child(grid)
Style.panel(self, Rect2(820, 86, 420, 696))
count_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
var hint := Style.label(self, "Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die teuersten Karten des Filters.", 15, Style.MUTED, Rect2(840, 160, 380, 60), "center")
hint.autowrap_mode = TextServer.AUTOWRAP_WORD
Style.button(self, "Auffüllen", Rect2(840, 240, 380, 46), fill_up)
Style.button(self, "Leeren", Rect2(840, 296, 380, 46), clear_deck, Color("5a3c28"))
msg_label = Style.label(self, "", 15, Style.RED, Rect2(840, 360, 380, 48), "center")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
func set_filter(e: String) -> void:
filter = e
refresh()
func toggle(card: Cards.Card) -> void:
var err: String = app.profile.toggle_deck(card.id)
msg_label.text = err
if err == "":
app.sfx.play("click")
app.save()
refresh()
## Füllt mit den teuersten eigenen Karten des gewählten Filters bis zum Höchstwert auf.
func fill_up() -> void:
var p: Profile = app.profile
var pool: Array = p.collection.keys()
if filter != "":
pool = pool.filter(func(id): return filter in Cards.by_id(id).elements)
p.deck = DeckGen.fill(p.deck, pool, Game.DECK_MAX)
app.save()
refresh()
func clear_deck() -> void:
app.profile.deck = []
app.save()
refresh()
## Eigene Karten, die im Stapel zuerst, sonst nach Kosten.
func cards() -> Array:
var p: Profile = app.profile
var out: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
if filter != "":
out = out.filter(func(c): return filter in c.elements)
out.sort_custom(func(a, b):
var ia: bool = a.id in p.deck
var ib: bool = b.id in p.deck
if ia != ib:
return ia
return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
return out
func refresh() -> void:
var p: Profile = app.profile
count_label.text = tr("Stapel: %d Karten (%d bis %d)") % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX]
count_label.add_theme_color_override("font_color", Style.RED if p.deck.size() < Game.DECK_MIN else Style.BLUE)
var shown := cards()
grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
var views := grid.views()
for i in views.size():
views[i].dim = not (shown[i].id in p.deck)

View File

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

View File

@@ -1,10 +1,11 @@
class_name ElementView class_name ElementView
extends Control extends Control
## Elementzähler der eigenen Seite, mittig: je Element zweimal das Symbol seines Sondereffekts in ## Elementzähler einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente da, bei denen
## der Elementfarbe, leuchtend je offenem eigenen Wesen (beide: Effekt aktiv), daneben in der ## etwas passiert ist: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je
## Elementfarbe das Mana der bisher ausgespielten Karten (je Game.ELEMENT_MANA gibt es +1 Angriff). ## offenem Wesen, beide: Effekt aktiv), die Zahl der ausgespielten Karten und ab
## Game.ELEMENT_COUNT der Bonus +1/+1.
const ENTRY_W := 180.0 const ROW := 32.0
var own := {} # offene eigene Wesen je Element var own := {} # offene eigene Wesen je Element
var played := {} # ausgespielte Karten je Element var played := {} # ausgespielte Karten je Element
@@ -31,37 +32,41 @@ func paired(e: String) -> bool:
func bonus(e: String) -> int: func bonus(e: String) -> int:
return int(played.get(e, 0)) / Game.ELEMENT_MANA return int(played.get(e, 0)) / Game.ELEMENT_COUNT
## Elemente, bei denen sich etwas getan hat (offene Wesen oder ausgespielte Karten).
func shown() -> Array:
return Cards.ELEMENTS.filter(func(e): return int(own.get(e, 0)) > 0 or int(played.get(e, 0)) > 0)
## Der Inhalt als Text (Tests, Protokoll): Element offen/gespielt, dazu Effekt und Bonus. ## Der Inhalt als Text (Tests, Protokoll): Element offen/gespielt, dazu Effekt und Bonus.
func text() -> String: func text() -> String:
var parts: Array[String] = [] var parts: Array[String] = []
for e in Cards.ELEMENTS: for e in shown():
var s := "%s %d/%d" % [tr(e), int(own.get(e, 0)), int(played.get(e, 0))] var s := "%s %d/%d" % [tr(e), int(own.get(e, 0)), int(played.get(e, 0))]
if paired(e): if paired(e):
s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e]) s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e])
if bonus(e) > 0: if bonus(e) > 0:
s += " (+%d)" % bonus(e) s += " (+%d/+%d)" % [bonus(e), bonus(e)]
parts.append(s) parts.append(s)
return " · ".join(parts) return " · ".join(parts)
func _draw() -> void: func _draw() -> void:
var x0 := (size.x - ENTRY_W * Cards.ELEMENTS.size()) / 2 var y := ROW / 2
var cy := size.y / 2 for e in shown():
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var col: Color = Cards.ELEMENT_COLORS[e] var col: Color = Cards.ELEMENT_COLORS[e]
var active := paired(e) or bonus(e) > 0 var active := paired(e) or bonus(e) > 0
var px := x0 + i * ENTRY_W + 10 draw_style_box(Style.box(Color(col, 0.18 if active else 0.1), 6, Color(col, 0.6 if active else 0.0), 1 if active else 0), Rect2(0, y - ROW / 2 + 2, size.x, ROW - 4))
Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, cy), "midleft", active) var px := 10.0
px += Style.FONT_BOLD.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 10 Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, y), "midleft", active)
px = 76
for k in Game.ELEMENT_PAIR: for k in Game.ELEMENT_PAIR:
var filled := k < int(own.get(e, 0)) var filled := k < int(own.get(e, 0))
Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, cy), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3)) Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, y), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3))
px += Game.ELEMENT_PAIR * 24 + 8 px += Game.ELEMENT_PAIR * 24 + 8
Style.text(self, str(int(played.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, cy), "midleft", true) Style.text(self, str(int(played.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true)
px += 20
if bonus(e) > 0: if bonus(e) > 0:
Style.text(self, "+%d" % bonus(e), 12, Style.GREEN, Vector2(px, cy), "midleft", true) Style.text(self, "+%d/+%d" % [bonus(e), bonus(e)], 12, Style.GREEN, Vector2(size.x - 6, y), "midright", true)
y += ROW

View File

@@ -1,107 +0,0 @@
class_name LobbyScreen
extends Control
## Netzwerkspiel im lokalen Netz: Spiel eröffnen oder beitreten.
var app
var net: Net
var status_label: Label
var list_label: Label
var ip_box: LineEdit
var b_host: Button
var b_join: Button
var b_watch: Button
var b_start: Button
var addr_label: Label
func _ready() -> void:
net = app.net
net.my_name = "Spieler"
net.my_pick = {"looks": app.profile.looks()}
Style.header(self, "Netzwerkspiel", _back)
var col := Style.column(self, 760, 110, 10)
var hr := Style.row(col, 12)
hr.alignment = BoxContainer.ALIGNMENT_CENTER
b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c"))
ip_box = LineEdit.new()
ip_box.custom_minimum_size = Vector2(220, 46)
ip_box.placeholder_text = "Adresse des Hosts"
ip_box.text = "127.0.0.1"
hr.add_child(ip_box)
b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join)
b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch)
addr_label = Style.clabel(col, "", 14, Style.MUTED)
status_label = Style.clabel(col, "", 16, Style.BLUE)
Style.spacer(col, 6)
Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true)
list_label = Style.clabel(col, "", 16, Style.TEXT)
Style.spacer(col, 10)
var br := Style.row(col, 20)
br.alignment = BoxContainer.ALIGNMENT_CENTER
b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c"))
net.lobby_changed.connect(refresh)
net.started.connect(_started)
net.ended.connect(func(_reason): refresh())
refresh()
func _exit_tree() -> void:
if net.lobby_changed.is_connected(refresh):
net.lobby_changed.disconnect(refresh)
if net.started.is_connected(_started):
net.started.disconnect(_started)
func _host() -> void:
net.host()
refresh()
func _join() -> void:
net.join(ip_box.text.strip_edges())
refresh()
## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit.
func _watch() -> void:
net.spectator = true
net.join(ip_box.text.strip_edges())
refresh()
func _start() -> void:
net.start_battle()
func _started(seat: int) -> void:
app.show_screen(BattleScreen.online(seat))
func _back() -> void:
net.leave()
app.show_screen(MenuScreen.new())
func refresh() -> void:
status_label.text = net.status
var lines: Array[String] = []
var ids: Array = net.names.keys()
ids.sort()
for id in ids:
lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""])
for id in net.spectators:
lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")])
list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
b_host.disabled = net.connected
b_join.disabled = net.connected
b_watch.disabled = net.connected
ip_box.editable = not net.connected
var humans := ids.size()
b_start.disabled = not net.is_host
b_start.text = tr("Start") if humans >= 2 else tr("Start gegen die KI")
addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else ""
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not ip_box.has_focus():
_back()

View File

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

View File

@@ -5,7 +5,6 @@ var profile: Profile
var screen: Control var screen: Control
var save_path: String = Profile.SAVE_PATH var save_path: String = Profile.SAVE_PATH
var sfx: Sfx var sfx: Sfx
var net: Net
func _ready() -> void: func _ready() -> void:
@@ -17,14 +16,9 @@ func _ready() -> void:
add_child(bg) add_child(bg)
sfx = Sfx.new() sfx = Sfx.new()
add_child(sfx) add_child(sfx)
# Fester Pfad /root/Net: Netzwerkaufrufe werden über den Knotenpfad zugestellt,
# der bei Host und Mitspielern gleich sein muss.
net = Net.new()
net.name = "Net"
get_tree().root.add_child.call_deferred(net)
profile = Profile.load_or_new(save_path) profile = Profile.load_or_new(save_path)
apply_settings() apply_settings()
show_screen(MenuScreen.new()) show_screen(MenuScreen.new() if profile.started() else StartDeckScreen.new())
func apply_settings() -> void: func apply_settings() -> void:
@@ -36,20 +30,32 @@ func apply_settings() -> void:
theme = Style.make_theme() theme = Style.make_theme()
## Neues Profil: Sammlung und Fortschritt werden verworfen. ## Neues Profil: Sammlung und Fortschritt werden verworfen, das Startdeck wird neu gewählt.
func reset_profile() -> void: func reset_profile() -> void:
profile = Profile.starter() profile = Profile.starter()
save() save()
apply_settings() apply_settings()
show_screen(StartDeckScreen.new())
func set_language(code: String) -> void: func set_language(code: String) -> void:
TranslationServer.set_locale(code) TranslationServer.set_locale(code)
func show_screen(s: Control) -> void: var kept: Array = [] # vorige Bildschirme, zu denen back() zurückführt (Einstellungen aus dem Kampf)
## keep: der aktuelle Bildschirm bleibt erhalten und kommt mit back() zurück.
func show_screen(s: Control, keep: bool = false) -> void:
if screen != null: if screen != null:
screen.queue_free() if keep:
remove_child(screen)
kept.append(screen)
else:
screen.queue_free()
for k in kept:
k.queue_free()
kept.clear()
screen = s screen = s
s.app = self s.app = self
s.set_anchors_preset(Control.PRESET_FULL_RECT) s.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -106,11 +112,17 @@ func _first_button(node: Node) -> Button:
return null return null
## Zurück: zum gemerkten Bildschirm, sonst ins Hauptmenü.
func back() -> void:
if kept.is_empty():
show_screen(MenuScreen.new())
return
var prev: Control = kept.pop_back()
screen.queue_free()
screen = prev
add_child(prev)
func save() -> void: func save() -> void:
profile.save(save_path) profile.save(save_path)
func _exit_tree() -> void:
if net != null and net.is_inside_tree():
net.leave()
net.queue_free()

View File

@@ -17,7 +17,7 @@ func _ready() -> void:
var y := 230 var y := 230
for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())], for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())],
["Kampagne", func(): app.show_screen(CampaignScreen.new())], ["Kampagne", func(): app.show_screen(CampaignScreen.new())],
["Netzwerkspiel", func(): app.show_screen(LobbyScreen.new())]]: ["Deck", func(): app.show_screen(DeckScreen.new())]]:
var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY) var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY)
b.add_theme_font_size_override("font_size", 24) b.add_theme_font_size_override("font_size", 24)
y += 74 y += 74
@@ -35,9 +35,9 @@ func _ready() -> void:
refresh() refresh()
## Drei Karten der Sammlung, die höchsten Stufen zuerst, leicht gefächert. ## Drei eigene Karten, die höchsten Stufen zuerst, leicht gefächert.
func _showcase(p: Profile) -> void: func _showcase(p: Profile) -> void:
var cards: Array = Cards.all().duplicate() var cards: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
cards.sort_custom(func(a, b): cards.sort_custom(func(a, b):
if p.level(a.id) != p.level(b.id): if p.level(a.id) != p.level(b.id):
return p.level(a.id) > p.level(b.id) return p.level(a.id) > p.level(b.id)
@@ -45,7 +45,7 @@ func _showcase(p: Profile) -> void:
var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]] var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]]
for i in 3: for i in 3:
var c: Cards.Card = cards[i] var c: Cards.Card = cards[i]
var v := CardView.new(c, Vector2(180, 252), false, p.rarity(c.id)) var v := CardView.new(c.leveled(p.level(c.id)), Vector2(180, 252), false, p.rarity(c.id))
v.position = spots[i][0] v.position = spots[i][0]
v.rotation = spots[i][1] v.rotation = spots[i][1]
v.mouse_filter = Control.MOUSE_FILTER_IGNORE v.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -55,8 +55,8 @@ func _showcase(p: Profile) -> void:
func refresh() -> void: func refresh() -> void:
var p: Profile = app.profile var p: Profile = app.profile
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust] coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
coll_label.text = tr("Aufgewertet: %d von %d Karten · Kampagne: %d/%d") % [ coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d") % [
p.upgraded_count(), Cards.all().size(), p.campaign_stage, Campaign.total()] p.collection.size(), Cards.all().size(), p.campaign_stage, Campaign.total()]
var notes := p.take_notices() var notes := p.take_notices()
notice_label.text = " · ".join(notes) notice_label.text = " · ".join(notes)
if not notes.is_empty(): if not notes.is_empty():

View File

@@ -16,7 +16,7 @@ var speed_label: Label
func _ready() -> void: func _ready() -> void:
var s: Dictionary = app.profile.settings var s: Dictionary = app.profile.settings
Style.header(self, "Einstellungen", func(): app.show_screen(MenuScreen.new())) Style.header(self, "Einstellungen", func(): app.back())
var col := Style.column(self, 700, 110, 24) var col := Style.column(self, 700, 110, 24)
var grid := GridContainer.new() var grid := GridContainer.new()
grid.columns = 3 grid.columns = 3
@@ -86,7 +86,6 @@ func _ready() -> void:
func _reset() -> void: func _reset() -> void:
app.reset_profile() app.reset_profile()
app.show_screen(MenuScreen.new())
func _on_big_font(on: bool) -> void: func _on_big_font(on: bool) -> void:
@@ -139,4 +138,4 @@ func _on_anim(on: bool) -> void:
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new()) app.back()

View File

@@ -0,0 +1,37 @@
class_name StartDeckScreen
extends Control
## Erster Start: eines von vier Startdecks wählen, je eines mit einem Element im Mittelpunkt
## (25 Karten davon, je fünf der anderen). Danach geht es ins Menü.
var app
func _ready() -> void:
Style.label(self, tr("Wähle dein Startdeck"), 40, Style.GOLD, Rect2(0, 60, 1280, 60), "center", true)
Style.label(self, tr("25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest."), 17, Style.MUTED, Rect2(140, 124, 1000, 30), "center")
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var x := 70 + i * 290
var col: Color = Cards.ELEMENT_COLORS[e]
Style.panel(self, Rect2(x, 190, 260, 470), Style.darker(col, 0.75), 12, col, 2)
var ids := DeckGen.start_deck(e)
var sample: Array = ids.filter(func(id): return Cards.by_id(id).elements[0] == e)
sample.sort_custom(func(a, b): return Cards.by_id(a).cost > Cards.by_id(b).cost)
for k in 3:
var c: Cards.Card = Cards.by_id(sample[k * 7])
var v := CardView.new(c, Vector2(110, 140), false, "Common")
v.position = Vector2(x + 20 + k * 40, 210 + k * 14)
v.rotation_degrees = -6 + k * 6
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
Style.label(self, tr(e), 28, Style.TEXT, Rect2(x, 400, 260, 40), "center", true)
Style.label(self, tr(Cards.KEYWORD_TEXT[Cards.ELEMENT_KEYWORD[e]]), 15, Style.MUTED, Rect2(x + 16, 446, 228, 60), "center")
var b := Style.button(self, tr("%s wählen") % tr(e), Rect2(x + 30, 590, 200, 50), choose.bind(e), Style.PRIMARY)
b.add_theme_font_size_override("font_size", 18)
func choose(element: String) -> void:
app.profile.choose_start(element)
app.save()
app.sfx.play("combo")
app.show_screen(MenuScreen.new())

View File

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

View File

@@ -1,101 +0,0 @@
extends Node
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge; mit --host eröffnet er
## selbst und startet, sobald ein zweiter Mensch am Tisch sitzt (ohne KI).
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
## godot --path . tests/net_bot.tscn -- --host --name=BotHost
var net: Net
var seat := -1
var game: Game
var t := 0.0
var hosting := false
func _ready() -> void:
var ip := "127.0.0.1"
var bot_name := "Bot"
for a in OS.get_cmdline_user_args():
if a.begins_with("--ip="):
ip = a.substr(5)
if a.begins_with("--name="):
bot_name = a.substr(7)
if a == "--host":
hosting = true
net = Net.new()
net.name = "Net"
get_tree().root.add_child.call_deferred(net)
net.my_name = bot_name
net.my_pick = {"elements": ["Feuer", "Erde"], "looks": {}}
net.started.connect(func(s):
seat = s
if hosting:
net.broadcast() # erster Zustand für alle, wie es sonst der Kampfbildschirm tut
_act_host.call_deferred())
net.snapshot_received.connect(_on_snapshot)
net.ended.connect(func(_r): get_tree().quit())
await get_tree().process_frame
if hosting:
net.host()
net.lobby_changed.connect(func():
if net.game == null and net.names.size() >= 2:
net.start_battle())
net.action_applied.connect(func():
net.broadcast()
_act_host.call_deferred())
else:
net.join(ip)
func _process(delta: float) -> void:
t += delta
if hosting and net.game != null:
net.tick()
if t > 180.0 or FileAccess.file_exists("user://bot_quit"):
net.leave()
get_tree().quit()
func _on_snapshot(snap: Dictionary) -> void:
game = Game.from_snapshot(snap)
if game.winner != "":
await get_tree().create_timer(1.0).timeout
get_tree().quit()
return
if game.current_idx != seat:
return
_decide(game.players[seat])
func _act_host() -> void:
if not hosting or net.game == null or net.game.winner != "" or seat < 0 or net.game.current_idx != seat:
return
game = net.game
_decide(game.players[seat])
## Einfache Züge mit kurzer Bedenkzeit: das erste spielbare Wesen legen, dann angreifen,
## dann ziehen, sonst Zug beenden.
func _decide(me: Game.Player) -> void:
var g := game
await get_tree().create_timer(0.25).timeout
if game != g or (hosting and (net.game == null or net.game.current_idx != seat)):
return # inzwischen ein neuer Zustand
for i in me.hand.size():
var card: Cards.Card = me.hand[i]
if game.can_play(me, card)[0]:
net.send_action({"type": "play", "hand": i})
return
for cr in me.board:
if cr.can_attack():
for e in game.enemies_of(me):
if game.can_attack(cr, e)[0]:
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "player", "index": game.players.find(e)}})
return
for c in e.board:
if game.can_attack(cr, c)[0]:
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}})
return
if game.can_draw(me)[0]:
net.send_action({"type": "draw"})
return
net.send_action({"type": "end"})

View File

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

View File

@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/net_bot.gd" id="1"]
[node name="NetBot" type="Node"]
script = ExtResource("1")

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("Stapel mischen: %.2f ms" % ms(func(): DeckGen.deck(r), 20)) print("Stapel würfeln: %.2f ms" % ms(func(): DeckGen.random_deck(r), 20))
print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20)) 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)
@@ -22,7 +22,7 @@ func _initialize() -> void:
var uid := 5000 var uid := 5000
for p in g.players: for p in g.players:
for k in 4: for k in 4:
var c := Game.Creature.new(Cards.by_id("f_wolf"), p, uid) var c := Game.Creature.new(Cards.by_id("f_3_2"), p, uid)
uid += 1 uid += 1
c.ready = true c.ready = true
p.board.append(c) p.board.append(c)
@@ -30,7 +30,7 @@ func _initialize() -> void:
h.energy = 10 h.energy = 10
h.max_energy = 10 h.max_energy = 10
for k in 6: for k in 6:
h.hand.append(Cards.by_id("f_ritter")) h.hand.append(Cards.by_id("f_4_3"))
AI.time_budget_ms = 100000 AI.time_budget_ms = 100000
var t_full := ms(func(): AI.choose_action(g, h)) var t_full := ms(func(): AI.choose_action(g, h))
AI.time_budget_ms = 150 AI.time_budget_ms = 150
@@ -39,5 +39,4 @@ func _initialize() -> void:
print("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget]) print("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget])
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(r).clone(), 20)) print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(r).clone(), 20))
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music())) print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
print("Snapshot: %.2f ms" % ms(func(): Game.new_battle(r).snapshot(0), 10))
quit() quit()

File diff suppressed because it is too large Load Diff

View File

@@ -117,10 +117,11 @@ func screen():
return main.screen return main.screen
## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist. ## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist; ohne Startdeck wird
## Feuer gewählt.
func ensure_menu() -> void: func ensure_menu() -> void:
if main.net.connected: if screen() is StartDeckScreen:
main.net.leave() await click(find_button("Feuer wählen"))
if TranslationServer.get_locale().begins_with("en"): if TranslationServer.get_locale().begins_with("en"):
main.set_language("de") main.set_language("de")
if not (screen() is MenuScreen): if not (screen() is MenuScreen):
@@ -169,15 +170,11 @@ func _ready() -> void:
add_child(main) add_child(main)
main.sfx.muted = true # Tests bleiben still main.sfx.muted = true # Tests bleiben still
await frames(3) await frames(3)
var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen, var suite := [test_start, test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen,
test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen] test_kampagne, test_sammlung, test_deck, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen]
suite += [test_kampf.bind(3), test_kampf_abbrechen] suite += [test_kampf.bind(3), test_kampf_abbrechen]
# Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten.
# Sie laufen nur auf Wunsch: DINGER_NETZ=1 (oder gezielt über DINGER_ONLY).
if OS.get_environment("DINGER_NETZ") != "":
suite += [test_netzwerk, test_host_wechsel, test_zuschauer]
suite.append(test_profil_zuruecksetzen) suite.append(test_profil_zuruecksetzen)
var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_host_wechsel,test_zuschauer var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_sammlung,test_kampf
if only != "": if only != "":
suite = suite.filter(func(t: Callable): return t.get_method() in only.split(",")) suite = suite.filter(func(t: Callable): return t.get_method() in only.split(","))
for t in suite: for t in suite:
@@ -190,6 +187,18 @@ func _ready() -> void:
get_tree().quit(1 if failed > 0 else 0) get_tree().quit(1 if failed > 0 else 0)
## Erster Start: eines von vier Startdecks wählen (ensure_menu hat schon eins gewählt, darum frisch).
func test_start() -> void:
main.reset_profile()
await frames(2)
check(screen() is StartDeckScreen and not main.profile.started(), "neues Profil: Startdeck wählen")
await shot("00_startdeck")
await click(find_button("Feuer wählen"))
var p: Profile = main.profile
check(screen() is MenuScreen and p.started() and p.deck.size() == Game.DECK_MIN and p.deck.filter(func(id): return Cards.by_id(id).elements[0] == "Feuer").size() == 25, "Feuer gewählt: 40 Karten, 25 davon Feuer")
check(Profile.load_or_new(SAVE).started(), "gespeichert")
func test_menu() -> void: func test_menu() -> void:
check(screen() is MenuScreen, "Menü sichtbar") check(screen() is MenuScreen, "Menü sichtbar")
check(find_button("Einzelspieler") != null and find_button("Beenden") != null, "Menüknöpfe vorhanden") check(find_button("Einzelspieler") != null and find_button("Beenden") != null, "Menüknöpfe vorhanden")
@@ -213,8 +222,8 @@ func test_booster() -> void:
await shot("04_booster_offen") await shot("04_booster_offen")
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert") check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert")
for res in scr.cards: for res in scr.cards:
if res.upgraded: if res.new or res.upgraded:
check(main.profile.level(res.card.id) == Cards.RARITIES.find(res.rarity), "aufgewertete Karte trägt ihre Stufe") check(main.profile.owns(res.card.id) and main.profile.level(res.card.id) == Cards.RARITIES.find(res.rarity), "neue oder aufgewertete Karte trägt ihre Stufe")
else: else:
check(res.dust > 0, "sonst gibt es Staub") check(res.dust > 0, "sonst gibt es Staub")
check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand") check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand")
@@ -255,6 +264,11 @@ func drag_fuse(scr, from: Control, to: Control) -> void:
m.button_mask = MOUSE_BUTTON_MASK_LEFT m.button_mask = MOUSE_BUTTON_MASK_LEFT
get_viewport().push_input(m) get_viewport().push_input(m)
await frames(1) await frames(1)
if scr.drag_ghost == null or scr.drag_target == null:
var fo = from.card if from is CardView else from.creature
var to_o = to.card if to is CardView else to.creature
print("DEBUG drag: ghost=", scr.drag_ghost, " target=", scr.drag_target, " press=", scr.press_target, " dran=", scr.my_turn(),
" overlay=", scr.any_overlay(), " mana=", scr.game.human.energy, " kann=", scr.game.can_fuse(scr.game.human, fo, to_o), " from=", from.get_global_rect(), " to=", to.get_global_rect())
check(scr.drag_ghost != null and scr.drag_target != null, "Karte schwebt über einem Verschmelzungsziel") check(scr.drag_ghost != null and scr.drag_target != null, "Karte schwebt über einem Verschmelzungsziel")
var waited := 0 var waited := 0
while scr.drag_ghost != null and waited < 240: while scr.drag_ghost != null and waited < 240:
@@ -297,7 +311,7 @@ func human_turn(scr) -> void:
var mana: int = g.human.energy var mana: int = g.human.energy
var hand: int = g.human.hand.size() var hand: int = g.human.hand.size()
var cost: int = g.draw_cost(g.human) var cost: int = g.draw_cost(g.human)
await click(scr.offer_views[0]) await click(scr.deck)
check(g.human.energy == mana - cost and g.human.hand.size() == hand + 1, "gezogen: eine Karte") check(g.human.energy == mana - cost and g.human.hand.size() == hand + 1, "gezogen: eine Karte")
var tried := [] # Wesen ohne lohnendes Ziel (0 Angriff ohne Vorteil) nicht wieder versuchen var tried := [] # Wesen ohne lohnendes Ziel (0 Angriff ohne Vorteil) nicht wieder versuchen
for i in 12: for i in 12:
@@ -329,7 +343,7 @@ func human_turn(scr) -> void:
await click(target_view(scr, targets)) await click(target_view(scr, targets))
check(scr.selected == null and cr.attacks_done > 0, "Angriff ausgeführt") check(scr.selected == null and cr.attacks_done > 0, "Angriff ausgeführt")
if g.winner == "": if g.winner == "":
await click(scr.b_end) await key(KEY_SPACE)
func start_battle() -> void: func start_battle() -> void:
@@ -385,11 +399,14 @@ func leave_battle() -> void:
func give_up() -> void: func give_up() -> void:
var scr = screen() var scr = screen()
await click(find_button("Aufgeben")) await key(KEY_ESCAPE)
check(scr.confirm.visible, "Aufgeben fragt nach") check(scr.options.visible, "Esc öffnet die Optionen")
await click(find_button("Aufgeben", scr.options))
check(scr.confirm.visible and not scr.options.visible, "Aufgeben fragt nach")
await click(find_button("Weiterspielen")) await click(find_button("Weiterspielen"))
check(not scr.confirm.visible and screen() == scr, "Weiterspielen bleibt im Kampf") check(not scr.confirm.visible and screen() == scr, "Weiterspielen bleibt im Kampf")
await click(find_button("Aufgeben")) await key(KEY_ESCAPE)
await click(find_button("Aufgeben", scr.options))
await click(find_button("Ja, aufgeben")) await click(find_button("Ja, aufgeben"))
check(screen() is MenuScreen, "Aufgeben führt ins Menü") check(screen() is MenuScreen, "Aufgeben führt ins Menü")
@@ -419,33 +436,75 @@ func test_einstellungen() -> void:
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
## Deck: eigene Karten rein und raus, Auffüllen nach Filter, Speichern. Beginnt mit dem frischen Startdeck.
func test_deck() -> void:
main.profile.choose_start("Feuer")
main.save()
await click(find_button("Deck"))
var scr = screen()
check(scr is DeckScreen, "Deck geöffnet")
var p: Profile = main.profile
check(scr.grid.views().size() == p.collection.size() and scr.count_label.text == "Stapel: %d Karten (%d bis %d)" % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX], "alle eigenen Karten, Zähler stimmt")
var views: Array = scr.grid.views()
check(views.all(func(v): return not v.dim) and p.deck.size() == Game.DECK_MIN, "nach dem Start sind alle eigenen Karten im Stapel")
var first_id: String = views[0].card.id
await click(views[0])
check(not (first_id in p.deck) and p.deck.size() == Game.DECK_MIN - 1 and scr.grid.views().any(func(v): return v.card.id == first_id and v.dim), "Klick nimmt die Karte aus dem Stapel, sie wird dunkel")
var back_view = scr.grid.views().filter(func(v): return v.card.id == first_id)[0]
scr.grid.ensure_control_visible(back_view) # sie liegt jetzt hinter den Stapelkarten, außerhalb des Sichtbereichs
await frames(2)
await click(back_view)
check(first_id in p.deck and p.deck.size() == Game.DECK_MIN, "Klick nimmt sie wieder hinein")
await shot("19_deck")
await click(find_button("Leeren"))
check(p.deck.is_empty(), "geleert")
await click(find_button("Wasser"))
await click(find_button("Auffüllen"))
check(p.deck.size() == 5 and p.deck.all(func(id): return Cards.by_id(id).elements[0] == "Wasser"), "Auffüllen hält sich an den Filter: nur die fünf Wasserkarten")
await click(find_button("Alle"))
await click(find_button("Auffüllen"))
check(p.deck.size() == Game.DECK_MIN and Profile.load_or_new(SAVE).deck == p.deck, "ohne Filter alles, gespeichert")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_sammlung() -> void: func test_sammlung() -> void:
await click(find_button("Sammlung")) await click(find_button("Sammlung"))
var scr = screen() var scr = screen()
check(scr is CollectionScreen, "Sammlung geöffnet") check(scr is CollectionScreen, "Sammlung geöffnet")
var p: Profile = main.profile var p: Profile = main.profile
check(scr.grid.views().size() == Cards.all().size(), "alle Karten gehören dir") check(scr.grid.views().size() == Cards.all().size(), "alle Karten des Spiels sind zu sehen")
check(scr.grid.views().slice(0, p.collection.size()).all(func(v): return not v.dim) and scr.grid.views().back().dim, "eigene Karten vorn, fehlende abgedunkelt")
p.dust = 5000 p.dust = 5000
scr.search_box.text = "Phönix" scr.search_box.text = "Lodernder Funke"
scr.search_box.text_changed.emit("Phönix") scr.search_box.text_changed.emit("Lodernder Funke")
await frames(1) await frames(1)
var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0] var funke = scr.grid.views().filter(func(v): return v.card.id == "f_2_1")[0]
await click(phoenix) await click(funke)
check(scr.selected.id == "ff_phoenix" and p.rarity("ff_phoenix") == "Common", "Karte gewählt, noch Common") check(scr.selected.id == "f_2_1" and p.rarity("f_2_1") == "Common" and scr.b_upgrade.visible, "eigene Karte gewählt, noch Common")
await click(scr.b_upgrade) await click(scr.b_upgrade)
check(p.level("ff_phoenix") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["Rare"], "auf Rare aufgewertet") check(p.level("f_2_1") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["Rare"], "auf Rare aufgewertet")
scr.search_box.text = "" scr.search_box.text = ""
scr.search_box.text_changed.emit("") scr.search_box.text_changed.emit("")
await frames(1) await frames(1)
var front: Array = scr.grid.views().slice(0, p.upgraded_count()) var front = scr.grid.views().filter(func(v): return v.card.id == "f_2_1")[0]
check(front.all(func(v): return v.rarity != "Common") and front.any(func(v): return v.card.id == "ff_phoenix" and v.rarity == "Rare"), "aufgewertete Karten stehen vorn in der Sammlung, der Phönix als Rare dabei") check(front.rarity == "Rare" and front.card.attack == 3 and front.card.health == 2 and scr.grid.views()[0].rarity != "Common", "die Rare zeigt +1/+1, aufgewertete stehen vorn: %s %d/%d" % [front.rarity, front.card.attack, front.card.health])
scr.search_box.text = "Phönix" check(Profile.load_or_new(SAVE).level("f_2_1") == 1, "Sammlung gespeichert")
scr.search_box.text_changed.emit("Phönix") scr.search_box.text = "Lodernder Funke"
scr.search_box.text_changed.emit("Lodernder Funke")
await frames(1) await frames(1)
check(Profile.load_or_new(SAVE).level("ff_phoenix") == 1, "Sammlung gespeichert")
var dust: int = p.dust var dust: int = p.dust
await click(scr.b_dismantle) await click(scr.b_dismantle)
check(p.level("ff_phoenix") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["Rare"], "wieder zerlegt") check(p.level("f_2_1") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["Rare"], "wieder zerlegt")
# Herstellen einer fehlenden Karte
scr.search_box.text = "Infernalischer Drache"
scr.search_box.text_changed.emit("Infernalischer Drache")
await frames(1)
await click(scr.grid.views().filter(func(v): return v.card.id == "f_5_5")[0])
check(scr.b_craft.visible and not scr.b_upgrade.visible, "fehlende Karte: nur Herstellen")
dust = p.dust
await click(scr.b_craft)
check(p.owns("f_5_5") and p.dust == dust - Profile.CRAFT_COST, "hergestellt")
await shot("20_sammlung") await shot("20_sammlung")
await click(find_button("Zurück")) await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
@@ -489,7 +548,9 @@ func test_profil_zuruecksetzen() -> void:
check(not scr.confirm.visible, "abgebrochen") check(not scr.confirm.visible, "abgebrochen")
await click(find_button("Profil zurücksetzen")) await click(find_button("Profil zurücksetzen"))
await click(find_button("Ja, zurücksetzen")) await click(find_button("Ja, zurücksetzen"))
check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and main.profile.collection.is_empty(), "Profil zurückgesetzt") check(screen() is StartDeckScreen and main.profile.coins == Profile.START_COINS and not main.profile.started(), "Profil zurückgesetzt: Startdeck neu wählen")
await click(find_button("Luft wählen"))
check(screen() is MenuScreen and main.profile.deck.filter(func(id): return Cards.by_id(id).elements[0] == "Luft").size() == 25, "Luft gewählt")
check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert") check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert")
@@ -526,26 +587,26 @@ func test_kampf_steuerung() -> void:
await wait_for_human(scr) await wait_for_human(scr)
var g: Game = scr.game var g: Game = scr.game
g.human.energy = 20 # die Starthand ist zufällig, hier soll nur die Steuerung geprüft werden g.human.energy = 20 # die Starthand ist zufällig, hier soll nur die Steuerung geprüft werden
g.human.hand = [Cards.by_id("f_wolf").copy()] g.human.hand = [Cards.by_id("f_3_2").copy()]
scr.refresh() scr.refresh()
check(scr.hand_views.size() == 1 and scr.draw_enabled and scr.offer_views.size() == Game.OFFER, "eine Handkarte, drei offene auf dem Stapel") check(scr.hand_views.size() == 1 and scr.deck.enabled, "eine Handkarte, Stapel bereit")
# Ziehen: die gewählte offene Karte landet auf der Hand, das erste Ziehen ist frei # Ziehen: eine Karte vom eigenen Stapel, das erste Ziehen ist frei
var mana: int = g.human.energy var mana: int = g.human.energy
var wanted: Cards.Card = g.human.offer[1] var deck_before: int = g.human.deck.size()
await click(scr.offer_views[1]) await click(scr.deck)
check(g.human.energy == mana and g.human.hand.size() == 2 and scr.hand_views.size() == 2 and g.human.hand[1].id == wanted.id, "gezogen: die gewählte Karte, frei") check(g.human.energy == mana and g.human.hand.size() == 2 and scr.hand_views.size() == 2 and g.human.deck.size() == deck_before - 1, "gezogen: eine Karte, frei")
check(g.human.offer.size() == Game.OFFER and g.human.offer.all(func(c): return c.cost <= g.human.max_energy) and g.draw_cost(g.human) == Game.DRAW_COST, "eine bezahlbare neue liegt nach, das nächste Ziehen kostet 2") check(g.draw_cost(g.human) == 1 and scr.deck.cost == 1, "das nächste Ziehen kostet 1")
# Ausspielen kostet die Kartenkosten; das Wesen greift noch nicht an # Ausspielen kostet die Kartenkosten; das Wesen greift noch nicht an
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 - Cards.by_id("f_wolf").cost, "Wesen per Taste gespielt, Kosten bezahlt") check(g.human.board.size() == board_before + 1 and g.human.energy == mana - Cards.by_id("f_3_2").cost, "Wesen per Taste gespielt, Kosten bezahlt")
check(not g.human.board.back().can_attack(), "frisch gelegt greift es noch nicht an") check(not g.human.board.back().can_attack(), "frisch gelegt greift es noch nicht an")
# Rückgängig: Wesen zurück auf die Hand, Mana zurück # 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 == "f_wolf") and g.human.energy == mana, "Rückgängig stellt Hand und Mana her") check(g.human.board.size() == board_before and g.human.hand.any(func(c): return c.id == "f_3_2") and g.human.energy == mana, "Rückgängig stellt Hand und Mana her")
check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht") 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")
@@ -553,7 +614,7 @@ func test_kampf_steuerung() -> void:
mana = g.human.energy mana = g.human.energy
var hand_before: int = g.human.hand.size() var hand_before: int = g.human.hand.size()
await key(KEY_Z) await key(KEY_Z)
check(g.human.energy == mana - Game.DRAW_COST and g.human.hand.size() == hand_before + 1, "Z zieht die erste offene Karte für 2") check(g.human.energy == mana - 1 and g.human.hand.size() == hand_before + 1, "Z zieht, das zweite Ziehen für 1")
await shot("22_kampf_steuerung") await shot("22_kampf_steuerung")
# Gamepad: A auf dem Stapel zieht # Gamepad: A auf dem Stapel zieht
await pad(JOY_BUTTON_DPAD_LEFT) await pad(JOY_BUTTON_DPAD_LEFT)
@@ -565,7 +626,7 @@ func test_kampf_steuerung() -> void:
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")
await frames(2) await frames(2)
check(scr.b_skip.visible and not scr.b_end.visible, "Überspringen-Knopf während des Gegnerzugs") check(scr.b_skip.visible, "Überspringen-Knopf während des Gegnerzugs")
await click(scr.b_skip) await click(scr.b_skip)
var w := 0 var w := 0
while g.current() != g.human and g.winner == "" and w < 120: while g.current() != g.human and g.winner == "" and w < 120:
@@ -574,9 +635,15 @@ func test_kampf_steuerung() -> void:
check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w) check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w)
scr.ai_delay = 0.0 scr.ai_delay = 0.0
await key(KEY_ESCAPE) await key(KEY_ESCAPE)
check(scr.confirm.visible, "Esc öffnet Aufgeben") check(scr.options.visible and find_button("Hauptmenü", scr.options) != null and find_button("Beenden", scr.options) != null, "Esc öffnet die Optionen")
await key(KEY_ESCAPE) await key(KEY_ESCAPE)
check(not scr.confirm.visible, "Esc schließt den Dialog") check(not scr.options.visible, "Esc schließt die Optionen")
# Einstellungen aus dem Kampf und zurück
await key(KEY_ESCAPE)
await click(find_button("Einstellungen", scr.options))
check(screen() is SettingsScreen and is_instance_valid(scr), "Einstellungen geöffnet, der Kampf bleibt erhalten")
await click(find_button("Zurück"))
check(screen() == scr and scr.game == g and not scr.options.visible, "zurück im selben Kampf")
await give_up() await give_up()
@@ -594,21 +661,21 @@ func test_kampf_grenzen() -> void:
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
await click(scr.offer_views[0]) await click(scr.deck)
check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND) check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND)
check(not scr.draw_enabled, "bei voller Hand lässt sich nichts ziehen") check(not scr.deck.enabled, "bei voller Hand lässt sich nichts ziehen")
await click(scr.offer_views[0]) await click(scr.deck)
check(scr.msg_label.text == tr("Deine Hand ist voll"), "Meldung bei voller Hand: " + scr.msg_label.text) check(scr.msg_label.text == tr("Deine Hand ist 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(): if g.human.hand.is_empty():
await click(scr.offer_views[0]) await click(scr.deck)
await key(KEY_1) await key(KEY_1)
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(): if g.human.hand.is_empty():
await click(scr.offer_views[0]) await click(scr.deck)
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")
@@ -619,10 +686,10 @@ func test_kampf_grenzen() -> void:
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 Ziehen # Ohne Mana kein Ziehen
g.human.energy = 0 g.human.energy = 0
g.human.drew = true g.human.draws = 1
scr.refresh() scr.refresh()
await frames(1) await frames(1)
check(not scr.draw_enabled, "ohne Mana kein zweites Ziehen") check(not scr.deck.enabled, "ohne Mana kein zweites Ziehen")
await give_up() await give_up()
@@ -646,27 +713,27 @@ func test_kampf_elemente_und_verschmelzen() -> void:
cr.hidden = hidden cr.hidden = hidden
p.board.append(cr) p.board.append(cr)
return cr return cr
var wolf_cr: Game.Creature = put.call(g.human, "f_wolf") var wolf_cr: Game.Creature = put.call(g.human, "f_3_2")
wolf_cr.health = 4 # übersteht die Konter von Falke und Kobold, damit der Test ihn weiter nutzen kann wolf_cr.health = 4 # übersteht die Konter von Falke und Funke, damit der Test ihn weiter nutzen kann
wolf_cr.max_health = 4 wolf_cr.max_health = 4
put.call(g.human, "f_asche") put.call(g.human, "f_4_3")
put.call(g.human, "w_reiter") put.call(g.human, "w_3_3")
var falke: Game.Creature = put.call(scr.opponent(), "l_falke") var falke: Game.Creature = put.call(scr.opponent(), "l_3_1")
var secret: Game.Creature = put.call(scr.opponent(), "f_kobold", true) var secret: Game.Creature = put.call(scr.opponent(), "f_2_1", true)
g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()] g.human.hand = [Cards.by_id("f_2_1").copy(), Cards.by_id("l_1_1").copy()]
g.human.energy = 20 g.human.energy = 20
g.human.looks = {"w_reiter": 3} g.human.looks = {"w_3_3": 3}
g.apply_auras() g.apply_auras()
scr.refresh() scr.refresh()
await frames(2) await frames(2)
check(scr.element_view.text() == "Feuer 2/0 (Doppelschlag) · Wasser 1/0 · Erde 0/0 · Luft 0/0", "Zähler: offene eigene Wesen und ausgespielte Karten, Feuerpaar: " + scr.element_view.text()) check(scr.element_view.text() == "Feuer 2/0 (Doppelschlag) · Wasser 1/0", "Zähler: nur Elemente mit Wesen oder gespielten Karten, Feuerpaar: " + scr.element_view.text())
check(scr.opp_element_view.text() == "Feuer 0/0 · Wasser 0/0 · Erde 0/0 · Luft 1/0", "die Leiste des Gegners zählt nur seine offenen Wesen: " + scr.opp_element_view.text()) check(scr.opp_element_view.text() == "Luft 1/0", "die Liste des Gegners zählt nur seine offenen Wesen: " + scr.opp_element_view.text())
check(g.human.board[0].keywords() == ["Doppelschlag"] and scr.tiles[secret.uid].secret and scr.hand_views[0].bonus == 0, "Feuerpaar gibt Doppelschlag; fremdes Verdecktes zeigt den Rücken; noch kein Bonus in Sicht") check(g.human.board[0].keywords() == ["Doppelschlag"] and scr.tiles[secret.uid].secret and scr.hand_views[0].bonus == 0, "Feuerpaar gibt Doppelschlag; fremdes Verdecktes zeigt den Rücken; noch kein Bonus in Sicht")
# Rechtsklick auf die Handkarte: Menü mit Verdeckt legen, ohne Verschmelzen # Rechtsklick auf die Handkarte: Menü mit Verdeckt legen, ohne Verschmelzen
await hover(scr.hand_views[0]) await hover(scr.hand_views[0])
await right_click_at(scr.hand_views[0].get_global_rect().get_center()) await right_click_at(scr.hand_views[0].get_global_rect().get_center())
check(scr.menu_layer.visible and find_button("Verdeckt legen ◆ %d" % g.human.hand[0].cost, scr.menu_layer) != null and find_button("Abwerfen +1 ◆", scr.menu_layer) != null, "Rechtsklick öffnet das Kartenmenü mit Verdeckt legen und Abwerfen") check(scr.menu_layer.visible and find_button("Verdeckt legen ◆ %d" % g.human.hand[0].cost, scr.menu_layer) != null and find_button("Abwerfen +1 ◆", scr.menu_layer) != null, "Rechtsklick öffnet das Kartenmenü mit Verdeckt legen und Abwerfen")
check(find_button("Verschmelzen mit … ◆ 1", scr.menu_layer) == null, "kein Verschmelzen im Rechtsklick-Menü") check(find_button("Verschmelzen mit …", scr.menu_layer) == null, "kein Verschmelzen im Rechtsklick-Menü")
await shot("23_kartenmenue") await shot("23_kartenmenue")
await right_click() await right_click()
check(not scr.menu_layer.visible, "Rechtsklick schließt das Menü") check(not scr.menu_layer.visible, "Rechtsklick schließt das Menü")
@@ -685,16 +752,16 @@ func test_kampf_elemente_und_verschmelzen() -> void:
while scr.own_views[3].busy: while scr.own_views[3].busy:
await frames(1) await frames(1)
await click(scr.own_views[3]) await click(scr.own_views[3])
check(mine_hidden.hidden and scr.msg_label.text == "Feuerkobold kann erst im nächsten Zug aufgedeckt werden", "im Legezug nicht aufzudecken: " + scr.msg_label.text) check(mine_hidden.hidden and scr.msg_label.text == "Lodernder Funke kann erst im nächsten Zug aufgedeckt werden", "im Legezug nicht aufzudecken: " + scr.msg_label.text)
mine_hidden.ready = true mine_hidden.ready = true
await click(scr.own_views[3]) await click(scr.own_views[3])
check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3/1 (Doppelschlag)"), "aufgedeckt: drei offene Feuer, eine ausgespielt: " + scr.element_view.text()) check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3/1 (Doppelschlag)"), "aufgedeckt: drei offene Feuer, eine ausgespielt: " + scr.element_view.text())
check(wolf.power() == 3 and falke.power() == 3 and mine_hidden.can_attack(), "unter 10 Mana kein Bonus, aufgedeckt greift sofort an") check(wolf.power() == 3 and falke.power() == 3 and mine_hidden.can_attack(), "unter fünf Karten kein Bonus, aufgedeckt greift sofort an")
g.human.played["Feuer"] = 10 g.human.played["Feuer"] = 5
g.apply_auras() g.apply_auras()
scr.refresh() scr.refresh()
await frames(1) await frames(1)
check(scr.element_view.text().begins_with("Feuer 3/10 (Doppelschlag) (+1)") and wolf.power() == 4 and falke.power() == 3, "10 Mana Feuer: +1 Angriff für die eigenen Feuerwesen: " + scr.element_view.text()) check(scr.element_view.text().begins_with("Feuer 3/5 (Doppelschlag) (+1/+1)") and wolf.power() == 4 and falke.power() == 3, "fünf Feuerkarten: +1/+1 für die eigenen Feuerwesen: " + scr.element_view.text())
check(scr.hand_views[0].bonus == 0, "die Luftkarte auf der Hand zeigt keinen Bonus") check(scr.hand_views[0].bonus == 0, "die Luftkarte auf der Hand zeigt keinen Bonus")
await shot("23b_vier_feuer") await shot("23b_vier_feuer")
# Angriffsziel: Elementvorteil, verdeckte ohne Anzeige, Held gesperrt # Angriffsziel: Elementvorteil, verdeckte ohne Anzeige, Held gesperrt
@@ -714,220 +781,26 @@ func test_kampf_elemente_und_verschmelzen() -> void:
mana = g.human.energy mana = g.human.energy
var before: int = g.human.board.size() var before: int = g.human.board.size()
await drag_fuse(scr, scr.own_views[1], scr.own_views[2]) await drag_fuse(scr, scr.own_views[1], scr.own_views[2])
check(g.human.board.size() == before - 1 and g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 1, "zu Dampf verschmolzen, 1 Mana bezahlt") check(g.human.board.size() == before - 1 and g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 3, "zu Dampf verschmolzen, 3 Mana bezahlt (halbe Manasumme)")
var dampf: Game.Creature = g.human.board.filter(func(c): return c.name == "Dampf")[0] var dampf: Game.Creature = g.human.board.filter(func(c): return c.name == "Dampf")[0]
check(scr.tiles[dampf.uid].rarity() == "Ultra Rare" and dampf.card.look == 3, "die Fusion trägt die höhere Stufe (Wellenreiter war Ultra Rare)") check(scr.tiles[dampf.uid].rarity() == "Ultra Rare" and dampf.card.look == 3, "die Fusion trägt die höhere Stufe (der Reiter war Ultra Rare)")
await shot("24_verschmelzen") await shot("24_verschmelzen")
# Verschmelzen auf der Hand per Ziehen # Verschmelzen auf der Hand per Ziehen
g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()] g.human.hand = [Cards.by_id("f_2_1").copy(), Cards.by_id("f_2_1").copy(), Cards.by_id("l_1_1").copy()]
scr.refresh() scr.refresh()
await frames(1) await frames(1)
await drag_fuse(scr, scr.hand_views[0], scr.hand_views[1]) await drag_fuse(scr, scr.hand_views[0], scr.hand_views[1])
check(g.human.hand.size() == 2 and g.human.hand[0].name == "Inferno" and g.human.hand[0].attack == 3 and g.human.hand[0].health == 2, "zwei Kobolde auf der Hand zu Inferno 3/2") check(g.human.hand.size() == 2 and g.human.hand[0].name == "Inferno" and g.human.hand[0].attack == 3 and g.human.hand[0].health == 2, "zwei Funken auf der Hand zu Inferno 3/2")
# Gamepad-Menü (Y) bietet Verdeckt legen und Verschmelzen mit … # Gamepad-Menü (Y) bietet Verdeckt legen und Verschmelzen mit …
scr.open_menu(g.human.hand[0], Rect2(scr.hand_views[0].position, scr.hand_views[0].size), true) scr.open_menu(g.human.hand[0], Rect2(scr.hand_views[0].position, scr.hand_views[0].size), true)
check(find_button("Verdeckt legen ◆ %d" % g.human.hand[0].cost, scr.menu_layer) != null and g.human.hand[0].cost == 2, "Gamepad-Menü: Verdeckt legen, Inferno kostet 2") check(find_button("Verdeckt legen ◆ %d" % g.human.hand[0].cost, scr.menu_layer) != null and g.human.hand[0].cost == 2, "Gamepad-Menü: Verdeckt legen, Inferno kostet 2")
await click(find_button("Verschmelzen mit … ◆ 2", scr.menu_layer)) await click(find_button("Verschmelzen mit …", scr.menu_layer))
check(scr.fuse_first == g.human.hand[0], "Partner wählen") check(scr.fuse_first == g.human.hand[0], "Partner wählen")
await click(scr.hand_views[1]) await click(scr.hand_views[1])
check(g.human.hand.size() == 1 and g.human.hand[0].name == "Blitz", "Inferno und Windgeist zu Blitz") check(g.human.hand.size() == 1 and g.human.hand[0].name == "Blitz", "Inferno und Windgeist zu Blitz")
await give_up() await give_up()
func test_netzwerk() -> void:
await click(find_button("Netzwerkspiel"))
var scr = screen()
check(scr is LobbyScreen, "Lobby geöffnet")
await click(find_button("Spiel eröffnen"))
check(main.net.is_host and main.net.connected, "Spiel eröffnet")
if FileAccess.file_exists("user://bot_quit"):
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://bot_quit"))
var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"]
var pid := OS.create_process(OS.get_executable_path(), args)
check(pid > 0, "Bot-Prozess gestartet")
var wait := 0
while main.net.names.size() < 2 and wait < 600:
await frames(1)
wait += 1
if main.net.names.size() < 2:
print("HINWEIS: Bot kam nicht, zweiter Versuch")
OS.kill(pid)
pid = OS.create_process(OS.get_executable_path(), args)
while main.net.names.size() < 2 and wait < 1500:
await frames(1)
wait += 1
check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path())
await shot("23_lobby")
await click(find_button("Start"))
var b = screen()
check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host")
b.ai_delay = 0.0
var rounds := 0
while b.game != null and b.game.winner == "" and rounds < 2:
if b.game.current() == b.perspective:
if b.game.current() == b.perspective:
await click(b.b_end)
var w2 := 0
while b.game.current() != b.perspective and b.game.winner == "" and w2 < 900:
await frames(1)
w2 += 1
rounds += 1
if b.game.winner != "":
print("DEBUG Netz: Sieger=", b.game.winner, " Verlauf=", b.game.log.slice(maxi(0, b.game.log.size() - 12)))
check(b.game.turn >= 2 and b.game.winner == "", "Runden gespielt, Partie läuft (Zug %d)" % b.game.turn)
check(b.game.log.any(func(l): return l.begins_with("Bot kauft") or l.begins_with("Bot spielt")), "Bot hat gehandelt")
await shot("24_netzwerk_kampf")
# Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen
var quit_file := "user://bot_quit"
var f := FileAccess.open(quit_file, FileAccess.WRITE)
f.store_string("x")
f.close()
var w3 := 0
while main.net.waiting.is_empty() and w3 < 600:
await frames(1)
w3 += 1
check(main.net.waiting.has("Bot") and b.game.log.any(func(l): return "Verbindung verloren" in l), "Platz wird freigehalten")
var turn_before: int = b.game.turn
for i in 2:
if b.game.current() == b.perspective and b.game.winner == "":
await click(b.b_end)
var w4 := 0
while b.game.current() != b.perspective and b.game.winner == "" and w4 < 300:
await frames(1)
w4 += 1
check(b.game.turn >= turn_before + 1 and b.game.log.any(func(l): return "übersprungen" in l), "Züge des fehlenden Spielers werden übersprungen")
DirAccess.remove_absolute(ProjectSettings.globalize_path(quit_file))
await frames(5)
var pid2 := OS.create_process(OS.get_executable_path(), args)
var w5 := 0
while not main.net.waiting.is_empty() and w5 < 900:
await frames(1)
w5 += 1
check(main.net.waiting.is_empty() and main.net.seats.size() == 2 and b.game.log.any(func(l): return l == "Bot ist zurück."), "Bot wieder verbunden, Platz zurück")
var turn2: int = b.game.turn
for i in 2:
if b.game.current() == b.perspective and b.game.winner == "":
await click(b.b_end)
var w6 := 0
while b.game.current() != b.perspective and b.game.winner == "" and w6 < 900:
await frames(1)
w6 += 1
check(b.game.turn >= turn2 + 1, "Partie läuft nach der Rückkehr weiter")
OS.kill(pid2)
await leave_battle()
check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü")
func _bot_args(extra: Array) -> Array:
return ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--"] + extra
func _bot_quit(on: bool) -> void:
var path := "user://bot_quit"
if on:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("x")
f.close()
elif FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## Verbindet sich mit dem Bot-Host (mehrere Versuche, bis er lauscht).
func _join_bot_host(watch: bool) -> void:
await click(find_button("Netzwerkspiel"))
var lobby = screen()
lobby.ip_box.text = "127.0.0.1"
for attempt in 8:
await click(find_button("Zuschauen" if watch else "Beitreten"))
var w := 0
while not main.net.connected and w < 180:
await frames(1)
w += 1
if main.net.connected:
return
main.net.leave()
await frames(30)
## Der Host (ein Bot) verschwindet: der einzige Mitspieler übernimmt und spielt zu Ende.
func test_host_wechsel() -> void:
_bot_quit(false)
var pid := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
check(pid > 0, "Bot-Host gestartet")
await frames(30)
await _join_bot_host(false)
var w := 0
while not (screen() is BattleScreen) and w < 900:
await frames(1)
w += 1
var b = screen()
check(b is BattleScreen and b.is_client(), "als Mitspieler beim Bot-Host im Kampf")
w = 0
while (b.game == null or main.net.backup.is_empty()) and w < 600:
await frames(1)
w += 1
check(b.game != null and main.net.i_am_successor and not main.net.backup.is_empty(), "Nachfolger kennt den vollen Spielstand")
main.net.rejoin_seconds = 3.0
_bot_quit(true)
w = 0
while not main.net.is_host and w < 900:
await frames(1)
w += 1
check(main.net.is_host and b.game == main.net.game and screen() == b and b.game.log.any(func(l): return "übernimmt als Host" in l),
"Host-Wechsel: Mitspieler führt das Spiel weiter")
check(main.net.waiting.has("BotHost"), "Platz des alten Hosts bleibt zunächst frei")
await shot("34_host_wechsel")
# Der alte Host kehrt nicht zurück: ein Zug wird übersprungen, nach der Frist scheidet er aus
b.ai_delay = 0.0
if b.game.current() == b.perspective:
await click(b.b_end)
w = 0
while b.game.winner == "" and w < 900:
await frames(1)
w += 1
check(b.game.log.any(func(l): return "übersprungen" in l), "Zug des fehlenden Hosts übersprungen")
check(b.game.winner != "" and b.game.winner_player == b.perspective and b.game.log.any(func(l): return "kehrt nicht zurück" in l),
"nach der Frist scheidet der alte Host aus, Partie gewonnen (Sieger '%s', Zug %d)" % [b.game.winner, b.game.turn])
await leave_battle()
_bot_quit(false)
check(screen() is MenuScreen and not main.net.connected, "nach dem Host-Wechsel zurück im Menü")
## Zuschauer: sieht die Partie zweier Bots ohne Hände und ohne eigene Züge.
func test_zuschauer() -> void:
_bot_quit(false)
var pid_h := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
await frames(30)
var pid_c := OS.create_process(OS.get_executable_path(), _bot_args(["--ip=127.0.0.1", "--name=Bot"]))
check(pid_h > 0 and pid_c > 0, "zwei Bots gestartet")
await _join_bot_host(true)
var w := 0
while not (screen() is BattleScreen) and w < 1200:
await frames(1)
w += 1
var b = screen()
check(b is BattleScreen and b.spectating(), "als Zuschauer im Kampf")
w = 0
while (b.game == null or b.game.turn < 2) and w < 900:
await frames(1)
w += 1
check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.b_end.visible,
"Zuschauer sieht die Partie ohne Hände und ohne Züge (Zug %d)" % (b.game.turn if b.game != null else 0))
await shot("35_zuschauer")
check(not b.b_end.visible and not b.b_undo.visible, "Zuschauer hat keine Zugknöpfe")
if screen() is BattleScreen: # die Bots spielen schnell, die Partie kann schon vorbei sein
await leave_battle()
else:
var w2 := 0
while not (screen() is MenuScreen) and w2 < 300:
await frames(1)
w2 += 1
check(screen() is MenuScreen and not main.net.connected, "Zuschauer zurück im Menü")
_bot_quit(true)
await frames(60)
_bot_quit(false)
func test_kampagne() -> void: func test_kampagne() -> void:
await click(find_button("Kampagne")) await click(find_button("Kampagne"))
var scr = screen() var scr = screen()
@@ -978,7 +851,7 @@ func test_kampf(shot_turn: int) -> void:
scr.smart_ai = false scr.smart_ai = false
g.opponent.life = 1 g.opponent.life = 1
for i in 3: for i in 3:
var drache := Game.Creature.new(Cards.by_id("f_drache").copy(), g.human, 9100 + i) var drache := Game.Creature.new(Cards.by_id("f_5_5").copy(), g.human, 9100 + i)
drache.ready = true drache.ready = true
g.human.board.append(drache) g.human.board.append(drache)
scr.refresh() scr.refresh()
@@ -1015,13 +888,13 @@ func test_kampf_abbrechen() -> void:
check(g.opponent != null and g.human.max_energy == Game.START_MANA, "Duell läuft") 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_3_2"), g.human, 9000)
wolf.ready = true wolf.ready = true
g.human.board.append(wolf) g.human.board.append(wolf)
g.human.hand.append(Cards.by_id("w_reiter")) g.human.hand.append(Cards.by_id("w_3_3"))
g.human.looks["w_reiter"] = Cards.RARITIES.find("Super Rare") g.human.looks["w_3_3"] = Cards.RARITIES.find("Super Rare")
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_3_3")[0]
check(reiter_view.rarity == "Super Rare", "Handkarte trägt die eigene Stufe") check(reiter_view.rarity == "Super Rare", "Handkarte trägt die eigene Stufe")
await hover(scr.element_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird await hover(scr.element_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird
await hover(reiter_view) await hover(reiter_view)
@@ -1037,13 +910,14 @@ func test_kampf_abbrechen() -> void:
print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(), print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(),
" dran=", g.current().name, " Sieger=", g.winner, " hovered=", get_viewport().gui_get_hovered_control()) " dran=", g.current().name, " Sieger=", g.winner, " hovered=", get_viewport().gui_get_hovered_control())
check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover") check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover")
await click(scr.b_log) await key(KEY_ESCAPE)
await click(find_button("Verlauf", scr.options))
check(scr.log_overlay.visible and scr.any_overlay(), "Verlauf öffnet als Überlagerung") check(scr.log_overlay.visible and scr.any_overlay(), "Verlauf öffnet als Überlagerung")
await key(KEY_ESCAPE) await key(KEY_ESCAPE)
check(not scr.log_overlay.visible, "Esc schließt den Verlauf") check(not scr.log_overlay.visible, "Esc schließt den Verlauf")
# Runden laufen lassen, damit die Gegner ihre Züge machen # Runden laufen lassen, damit die Gegner ihre Züge machen
for round in 4: for round in 4:
await click(scr.b_end) await key(KEY_SPACE)
var wait := 0 var wait := 0
while g.current() != g.human and g.winner == "" and wait < 600: while g.current() != g.human and g.winner == "" and wait < 600:
await frames(1) await frames(1)

View File

@@ -3,7 +3,7 @@ Dinger,Dinger
Ein Kartenspiel: Duell um das Feld,A card game: duel for the field Ein Kartenspiel: Duell um das Feld,A card game: duel for the field
Einzelspieler,Single player Einzelspieler,Single player
Kampagne,Campaign Kampagne,Campaign
Netzwerkspiel,Network game Deck,Deck
Booster öffnen,Open boosters Booster öffnen,Open boosters
Einstellungen,Settings Einstellungen,Settings
Beenden,Quit Beenden,Quit
@@ -81,7 +81,7 @@ Starten,Start
Sammlung,Collection Sammlung,Collection
Verschmelzen,Fuse Verschmelzen,Fuse
Nur eigene Wesen,Only your own creatures Nur eigene Wesen,Only your own creatures
Aufgewertet: %d von %d Karten · Kampagne: %d/%d,Upgraded: %d of %d cards · Campaign: %d/%d Sammlung: %d von %d Karten · Kampagne: %d/%d,Collection: %d of %d cards · Campaign: %d/%d
%s ist jetzt %s,%s is now %s %s ist jetzt %s,%s is now %s
Alle Kapitel abgeschlossen,All chapters completed Alle Kapitel abgeschlossen,All chapters completed
Elemente,Elements Elemente,Elements
@@ -141,7 +141,7 @@ Muss zuerst angegriffen werden,Must be attacked first
Greift sofort an,Attacks right away Greift sofort an,Attacks right away
Heilt sich am Zugende ganz,Heals fully at the end of the turn Heilt sich am Zugende ganz,Heals fully at the end of the turn
Wesen greifen sofort an,Creatures attack right away Wesen greifen sofort an,Creatures attack right away
Verschmelzen mit … ◆ %d,Fuse with … ◆ %d Verschmelzen mit …,Fuse with …
Ziel wählen,Pick the partner Ziel wählen,Pick the partner
Erst die Wesen mit Spott,Taunting creatures first Erst die Wesen mit Spott,Taunting creatures first
%s kann erst im nächsten Zug aufgedeckt werden,%s can be revealed next turn %s kann erst im nächsten Zug aufgedeckt werden,%s can be revealed next turn
@@ -153,3 +153,17 @@ stirbt,dies
Abwerfen +%d ◆,Discard +%d ◆ Abwerfen +%d ◆,Discard +%d ◆
Überspringen,Skip Überspringen,Skip
Zug %d,Turn %d Zug %d,Turn %d
Stapel: %d Karten (%d bis %d),Deck: %d cards (%d to %d)
Herstellen (%d Staub),Craft (%d dust)
Nicht in der Sammlung,Not in the collection
%s hergestellt,%s crafted
Neu!,New!
Auffüllen,Fill up
Leeren,Clear
Dein Stapel ist leer,Your deck is empty
Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die teuersten Karten des Filters.,Click adds a card to the deck or removes it. Fill up takes the most expensive cards of the filter.
Wähle dein Startdeck,Choose your starting deck
"25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest.","25 cards of the element plus five of each other one. Boosters and the workshop bring the rest later."
%s wählen,Choose %s
Optionen,Options
Hauptmenü,Main menu
1 keys en
3 Ein Kartenspiel: Duell um das Feld A card game: duel for the field
4 Einzelspieler Single player
5 Kampagne Campaign
6 Netzwerkspiel Deck Network game Deck
7 Booster öffnen Open boosters
8 Einstellungen Settings
9 Beenden Quit
81 Sammlung Collection
82 Verschmelzen Fuse
83 Nur eigene Wesen Only your own creatures
84 Aufgewertet: %d von %d Karten · Kampagne: %d/%d Sammlung: %d von %d Karten · Kampagne: %d/%d Upgraded: %d of %d cards · Campaign: %d/%d Collection: %d of %d cards · Campaign: %d/%d
85 %s ist jetzt %s %s is now %s
86 Alle Kapitel abgeschlossen All chapters completed
87 Elemente Elements
141 Greift sofort an Attacks right away
142 Heilt sich am Zugende ganz Heals fully at the end of the turn
143 Wesen greifen sofort an Creatures attack right away
144 Verschmelzen mit … ◆ %d Verschmelzen mit … Fuse with … ◆ %d Fuse with …
145 Ziel wählen Pick the partner
146 Erst die Wesen mit Spott Taunting creatures first
147 %s kann erst im nächsten Zug aufgedeckt werden %s can be revealed next turn
153 Abwerfen +%d ◆ Discard +%d ◆
154 Überspringen Skip
155 Zug %d Turn %d
156 Stapel: %d Karten (%d bis %d) Deck: %d cards (%d to %d)
157 Herstellen (%d Staub) Craft (%d dust)
158 Nicht in der Sammlung Not in the collection
159 %s hergestellt %s crafted
160 Neu! New!
161 Auffüllen Fill up
162 Leeren Clear
163 Dein Stapel ist leer Your deck is empty
164 Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die teuersten Karten des Filters. Click adds a card to the deck or removes it. Fill up takes the most expensive cards of the filter.
165 Wähle dein Startdeck Choose your starting deck
166 25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest. 25 cards of the element plus five of each other one. Boosters and the workshop bring the rest later.
167 %s wählen Choose %s
168 Optionen Options
169 Hauptmenü Main menu

Binary file not shown.