update
Tests / headless (push) Waiting to run
Tests / fenster-schnell (push) Waiting to run
Tests / fenster-voll (push) Skipped
Tests / balance (push) Skipped

This commit is contained in:
team3 committed 2026-10-14 17:35:09 +02:00
1 parent f2c609a08b
commit 1d563b9eec
34 files changed
+5486 -643

No files matched your search

+14 -11
View File
@@ -20,22 +20,25 @@ Der Spielstand liegt unter `user://spielstand.json`
## Spielsysteme
- 150 Karten in `data/karten.json`: je Element und neutral alle Werte von 0/1 bis 5/5, die ID verrät sie (`f_3_2` = Feuer 3/2, `n_…` neutral), Namen aus Eigenschaft und Wesen; Kombos sind die Namen verschmolzener Wesen. Neutrale Karten sind nie im Vorteil oder Nachteil, bilden kein Paar, haben aber ihren eigenen Zähler (+1/+1 je drei); verschmolzen mit einem Element (Feuerritter …) bleibt dessen Vorteil, die Schwäche fällt weg
- 550 Karten in `data/karten.json`: je Element und neutral alle Werte von 0/1 bis 10/10, die ID verrät sie (`f_3_2` = Feuer 3/2, `n_…` neutral), Namen aus Eigenschaft und Wesen; Kombos sind die Namen verschmolzener Wesen. Neutrale Karten sind nie im Vorteil oder Nachteil, bilden kein Paar, haben aber ihren eigenen Zähler (+1/+1 je drei); verschmolzen mit einem Element (Feuerritter …) bleibt dessen Vorteil, die Schwäche fällt weg
- 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 und Legen
- Jeder spielt mit seinem eigenen Stapel aus 40 bis 60 Karten (Menü „Deck“: eigene Karten rein oder raus, „Auffüllen“ nimmt die wertvollsten des gewählten Filters, bei gleichem Wert die teuersten; „Stärke“ ist der mittlere Kartenwert: ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100, also 100 für Grundkarten bis 500 für Ultimate Rare). Zu Beginn die obersten fünf Karten des gemischten Stapels. 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)
- Jeder spielt mit seinem eigenen Stapel aus 30 bis 60 Karten (Menü „Deck“: eigene Karten rein oder raus, mit Element- und Manafilter, Manakurve und Elementanteilen; „Auffüllen“ nimmt die wertvollsten des gewählten Filters, bei gleichem Wert die teuersten; „Stärke“ ist der mittlere Kartenwert: ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100, also 100 für Grundkarten bis 500 für Ultimate Rare). Zu Beginn die obersten fünf Karten des gemischten Stapels. Ziehen bringt eine Karte, die erste im Zug ist frei, dann 1, 2, 3 … Mana; ist der Stapel leer, kostet jeder Zug 1, 2, 3 … Leben (Erschöpfung); eine Handkarte abwerfen bringt 1 Mana (Rechtsklick)
- Eigenschaften gibt es nur über Elementpaare (siehe Elementboni): Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug). Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink; verletzte Wesen heilen nicht von selbst
- Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar und bekommt den Elementbonus erst offen; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf
- Beim ersten Start wählt man eines von vier Startdecks (40 Karten: 25 eines Elements bis Angriff 4, je fünf billige der anderen), der Rest kommt aus Boostern oder der Werkstatt. Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare) und ist je Stufe +1/+1 stärker, bei gleichen Kosten; die Stufe zeigt sich auch in der Optik (Glanz, Holo, Relief, Funkeln). Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern
- Booster (100 Münzen): fünf Karten mit gewürfelter Stufe (45/25/15/10/5 %, mindestens eine Rare); neue kommen in Sammlung und Stapel, eine höhere Stufe ersetzt die alte Version (die wird zu Staub), der Rest wird zu Staub (50/100/200/500/1000). Werkstatt in der Sammlung: Aufwerten kostet 100/200/500/1000 Staub, Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Elementboni, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); je drei 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, schlägt durch den Stapelbonus des Ziels (so viel Schaden mehr, wie das Ziel an +x/+x trägt); wer im Nachteil ist, schlägt ohne seinen eigenen Stapelbonus zu; beim Angriff wie beim Konter, gegen Helden nichts; ohne Stapelbonus bewirkt das Element nichts; 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“
- Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf
- Man startet mit allen 110 neutralen Karten in der Sammlung, der Stapel sind 30 davon nach Manakurve (2 zu 0 Mana, 5 zu 1, 6 zu 2, 6 zu 3, 5 zu 4, 3 zu 5, 2 zu 6, 1 zu 7); die Elemente schaltet die Kampagne frei: mit jedem offenen Kapitel gibt es den Booster seines Elements, die Werkstatt bringt den Rest; Sammlung, Deckfilter und Booster zeigen nur Elemente offener Kapitel. 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: neutral 100 Münzen, je Element 200 (ab seinem Kapitel), einzeln oder fünf und zehn auf einmal; fünf Karten des Elements 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
- Elementpaare, 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); verdeckte zählen nicht. Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte. Die Liste links neben dem jeweiligen Brett zeigt nur Elemente mit offenen Wesen: zwei Symbole, gefüllt je offenem eigenen Wesen, und die Zahl der Wesen
- Elementvorteil beim Kampf (Wasser > Feuer > Luft > Erde > Wasser): wer im Vorteil ist, macht doppelten Schaden; wer im Nachteil ist, halben (aufgerundet); beim Angriff wie beim Konter, gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht, mit Neutral verschmolzen ist es nie im Nachteil; das Boss-Merkmal „kein Elementnachteil“ hebt den Nachteil auf. Ist ein Angreifer gewählt, zeigen die Gegnerkarten ihren Konter gegen ihn; ü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. Den Zug beendet die Leertaste oder der Rechtsklick ins Leere; Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden)
- Verschmelzen: eine Handkarte oder ein eigenes Wesen auf eine andere ziehen und loslassen, auch gemischt; das Ergebnis erscheint über dem Ziel, darunter „Verschmelzen“ oder „Abbrechen“ (Gamepad: Y, „Verschmelzen mit …“). Es kostet nichts; Wesen verlassen das Brett, die neue Karte kommt unverletzt auf die Hand und wird wie jede andere ausgespielt. Je Wert die aufgerundete Hälfte der Grundwertsumme plus eins, Kosten daraus wie bei jeder Karte, dann die höhere Stufe der Eltern mit ihrem +1/+1; Elemente und Eigenschaften beider; drei oder mehr Elemente ergeben eine Chimäre
- Duell mit ausgeloster Zugreihenfolge, beide beginnen mit 1 Mana; die Hand fasst 7 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
- Duell mit ausgeloster Zugreihenfolge, beide beginnen mit 1 Mana; keine Grenze für Handkarten, das Feld fasst 5 Wesen
- Münzen nach jedem Kampf: 5 je eigenem Zug, 5 je ausgespielter Karte, 5 je Zug unter 15 Sekunden; ein Sieg verdoppelt alles; das Ergebnisbild schlüsselt es auf. In der Kampagne ohne Schwierigkeitsfaktor, der erste Sieg einer Stufe bringt ihre Belohnung obendrauf
- Erfahrung und Skilltree (Menü „Skilltree“): jede verdiente Münze zählt als Erfahrung; Level n braucht 50·n·(n+1) EP (100, 300, 600 …), jedes Level bringt einen Skillpunkt, Münzen kaufen keine Punkte. Drei Äste: Sammeln (Packglück: Common −8, die höheren Stufen +3/+2/+2/+1 Prozentpunkte je Stufe, bis 3; Staubsammler: +25 % Staub je Stufe, bis 3), Wirtschaft (Münzsegen: +10 % Münzen je Stufe, bis 3; Händler: Booster 10 % billiger je Stufe, bis 2), Kampf (Freies Ziehen: auch das zweite Ziehen je Zug gratis; Starker Start: 3 statt 1 Mana im ersten Zug; beide wirken wie die gleichnamigen Merkmale). Jede Skillkarte zeigt die Wirkung jetzt und auf der nächsten Stufe in Zahlen
- Cheats (kleiner Knopf unten rechts im Hauptmenü): Münzen, Staub, Level, Skills, Kapitel und Sammlung direkt setzen, zum Ausprobieren
- Schwierigkeit Leicht/Normal/Schwer (Einzelspieler): der Gegner spielt 30 Karten eines Elements aus den offenen Kapiteln nach Manakurve, seine Karten eine Stufe unter deiner Deckstärke, auf ihr oder eine darüber (Stärke 100 → Common … 500 → Ultimate Rare); Leicht mit einfacher KI, Schwer mit einem zufälligen Merkmal; 30 Leben wie du; Münzen 1/1,5/2-fach
- KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread
- Kampagne: drei Kapitel mit je zehn Magiern, Bosse mit ein bis drei Merkmalen 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
- Kampagne: fünf Kapitel mit je zehn Magiern, der zehnte ist der Boss; Kapitel 1 spielt neutrale Karten, Kapitel 2 bis 5 Feuer, Wasser, Luft, Erde; jeder Gegner spielt 30 Karten seines Elements nach Manakurve, der Kostendeckel steigt je Stufe von 3 auf 10 (Boss), in Kapitel 1 und 2 Common, dann je Kapitel eine Stufe höher (Kapitel 5 Ultra Rare); ein Merkmal je Gegner, ab Kapitel 3 zwei, in Kapitel 5 drei; eigenes Merkmal nach Kapitel 1
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
- Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`)
- Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen; Ton und Musik sind standardmäßig aus
@@ -74,6 +77,6 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Stapel, Booster, Werkstatt, Einstellungen
- `scripts/campaign.gd` – Kampagnenstufen
- `scenes/main.tscn` – Startszene
- `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` die Elementpaare, `battle_layout.gd` die Aufteilung des Kampfes
- `translations/ui.csv` – englische Oberflächentexte
- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs
+9 -7
View File
@@ -6,15 +6,17 @@
- Kartenspiel wie zum Beispiel Magic
- PC-Spiel
- Duell: Spieler gegen einen KI-Gegner, beide mit gleichen Regeln
- Einzelspieler-Knopf führt zur Wahl der Schwierigkeit
- Kampagne gegen benannte Magier als Fortschritt
- Einzelspieler-Knopf führt zur Wahl der Schwierigkeit: der Gegner spielt ein Element aus den offenen Kapiteln, Karten eine Stufe unter deiner Deckstärke / auf ihr / eine darüber; Leicht einfache KI, Schwer ein zufälliges Merkmal; Münzen ×1/×1,5/×2
- Münzen nach jedem Kampf: 5 je eigenem Zug, 5 je ausgespielter Karte, 5 je Zug unter 15 s; ein Sieg verdoppelt; Kampagne ohne Faktor, der erste Sieg einer Stufe bringt ihre Belohnung obendrauf
- Erfahrung = verdiente Münzen; Level n braucht 50·n·(n+1) EP, je Level ein Skillpunkt (nicht kaufbar); Skilltree mit drei Ästen: Sammeln (Packglück, Staubsammler), Wirtschaft (Münzsegen, Händler), Kampf (Freies Ziehen, Starker Start als Merkmale)
- Kampagne: fünf Kapitel mit je zehn Magiern, der zehnte ist der Boss; Kapitel 1 neutral, dann Feuer, Wasser, Luft, Erde; Gegner spielen 30 Karten ihres Elements nach Manakurve, Kostendeckel je Stufe 3 bis 10, Kapitel 1 und 2 Common, dann je Kapitel eine Stufe höher (Kapitel 5 Ultra Rare); ein offenes Kapitel schaltet den Booster seines Elements frei (neutral 100 Münzen, Element 200)
## Sammeln
- 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
- Man startet mit allen 110 neutralen Karten in der Sammlung, 30 davon nach Manakurve im Stapel; die Elemente kommen über die Kampagne und ihre Booster; alle Karten sammeln ist das lange Ziel
- Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare); je Stufe +1/+1 bei gleichen Kosten, dazu Optik (Glanz, Holo, Relief, Funkeln); Fusionen tragen die höhere Stufe der Eltern
- Booster öffnen (Chancen 45/25/15/10/5 %, mindestens eine Rare): neue Karten kommen in Sammlung und Stapel, von jeder Karte zählt die seltenste Version, die alte wird zu Staub, der Rest wird zu Staub (50/100/200/500/1000)
- Werkstatt: Staub wertet gezielt auf (100/200/500/1000), Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Jeder hat sein Deck: 40 bis 60 eigene Karten, im Menü „Deck“ zusammengestellt (Auffüllen folgt dem Filter, wertvollste zuerst; Stärke = mittlerer Kartenwert ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100: 100 bis 500); zu Beginn die obersten fünf 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
- Jeder hat sein Deck: 30 bis 60 eigene Karten, im Menü „Deck“ zusammengestellt (Auffüllen folgt dem Filter, wertvollste zuerst; Stärke = mittlerer Kartenwert ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100: 100 bis 500); zu Beginn die obersten fünf 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 und Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet, höchstens 10; 0/2 kostet 0), Abwerfen bringt 1; 1 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; keine Grenze für Handkarten
- Karten haben nur Angriff und Leben; Eigenschaften kommen allein über Elementpaare: Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug)
- Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink (auch im ersten Zug); verletzte Wesen heilen nicht von selbst
@@ -23,10 +25,10 @@
- Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen
## Elemente
- Jede Karte gehört zu genau einem Element oder ist neutral; je Element und neutral gibt es alle Werte von 0/1 bis 5/5 (150 Karten). Neutral: nie im Vorteil oder Nachteil, kein Paar, aber ein eigener Zähler (+1/+1 je drei); verschmolzen nimmt das Wesen im schlechten Matchup die neutrale Seite
- Jede Karte gehört zu genau einem Element oder ist neutral; je Element und neutral gibt es alle Werte von 0/1 bis 10/10 (550 Karten). Neutral: nie im Vorteil oder Nachteil, kein Paar; verschmolzen nimmt das Wesen im schlechten Matchup die neutrale Seite
- Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan)
- Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag)
- Je drei 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, schlägt durch den Stapelbonus des Ziels: so viel Schaden mehr, wie das Ziel an +x/+x trägt; wer im Nachteil ist, schlägt ohne seinen eigenen Stapelbonus zu; beim Angriff wie beim Konter; ohne Stapelbonus bewirkt das Element nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht)
- Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte; verdeckte zählen nicht fürs Paar
- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (wer im Vorteil ist, macht doppelten Schaden, wer im Nachteil ist, halben, aufgerundet; beim Angriff wie beim Konter; gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht; Boss-Merkmal „kein Elementnachteil“)
- Verschmelzen: eine Handkarte oder ein eigenes Wesen auf eine andere ziehen und loslassen, auch gemischt; das Ergebnis erscheint über dem Ziel und wird bestätigt oder abgebrochen (Gamepad: über das Kartenmenü); kostet kein Mana; Wesen verlassen das Brett, die neue Karte kommt unverletzt auf die Hand; je Wert die aufgerundete Hälfte der Grundwertsumme plus eins, Kosten daraus, dann die höhere Stufe der Eltern (+1/+1 je Stufe); Elemente und Eigenschaften beider
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus
+4000
View File
File diff suppressed because it is too large. Load diff
+26 -15
View File
@@ -27,8 +27,7 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw
der Platz erlaubt, unten Angriff (gelb, links) und Leben (blau, rechts) als Kreise. Karten
haben keinen Text: nur Element, Angriff, Leben. Auf dem Feld stehen unter dem Namen links die
Eigenschaften als Symbole (Ausrufezeichen = Spott, Doppelpfeil = Flink, Kreuz = Regeneration,
gekreuzte Klingen = Doppelschlag, in der Farbe des Elements, das sie gibt), rechts der
Elementbonus.
gekreuzte Klingen = Doppelschlag, in der Farbe des Elements, das sie gibt).
- Die Hand sind alle Karten in Feldgröße, ab sechs überlappend; Details kommen aus der schwebenden Vorschau.
- Seltenheit nach Yu-Gi-Oh: Common matt; Rare silbern glänzender Name; Super Rare Holo-Bild
(wandernder Regenbogenschimmer im Bildkreis); Ultra Rare golden glänzender Name plus Holo-Bild;
@@ -55,9 +54,9 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
das Y-Menü („Verschmelzen mit …“, dann A auf dem Partner).
- **Eigenes Brett** mit fünf sichtbaren Plätzen; links daneben die eigene Leiste (Leben, Mana,
Einkommen, Zug), rechts „Rückgängig“, nur wenn es gerade möglich ist.
- **Elementzähler** je Spieler als Liste links neben seinem Brett, nur Elemente mit Wesen oder gespielten Karten.
- **Elementpaare** je Spieler als Liste links neben seinem Brett, nur Elemente mit offenen Wesen.
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 die Zahl der Wesen.
- **Mittlere Maustaste** auf eine Handkarte legt sie verdeckt. **Kartenmenü** per Rechtsklick
oder Y auf Hand- und Brettkarten: verdeckt legen, aufdecken, Spott, Flink, Regeneration und
Doppelschlag kaufen; per Y zusätzlich verschmelzen.
@@ -68,8 +67,8 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
und Feldkombo darunter.
- **Optionen** per Esc als Überlagerung: Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden; der Verlauf
öffnet darüber. Zug beenden per Leertaste oder Rechtsklick ins Leere, oben rechts nur Rückgängig.
- Handkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen
steht der Elementkonter (der ignorierte Stapelbonus) am Ziel, bei Verdeckten nicht.
- Beim Angreifen zeigt das Ziel seinen Konter mit Elementvorteil (doppelt oder halb), bei
Verdeckten nicht; über dem Ziel zeigt der Angreifer seinen Angriff mit Vorteil.
## Bewegung und Ton
@@ -83,17 +82,29 @@ Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
## Menüs
- 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
- Hauptmenü: Titel, Einzelspieler, Kampagne und Deck links, darunter Booster, Sammlung, Skilltree,
in der Zeile darunter Einstellungen und Beenden, unten rechts der kleine Cheats-Knopf; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und
Staub oben rechts.
- Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter.
- Kampagne als Pfad mit zehn Stationen je Kapitel, die nächste pulsiert; darunter die gewählte
Stufe mit Gegner, Leben, Belohnung und Merkmalen. Die Bosse werden über Leben und Merkmale
stärker, nicht über Begleiter.
- Sammlung: sechs Spalten, 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. Rechts die Kartenzahl und die Stärke
(mittlerer Kartenwert, 100 für Grundkarten bis 500 für Ultimate Rare).
- Kampagne als Pfad mit zehn Stationen je Kapitel (fünf Kapitel: Neutral, Feuer, Wasser, Luft,
Erde, das Element steht über dem Pfad), die nächste pulsiert; darunter die gewählte Stufe mit
Gegner, Leben, Belohnung und Merkmalen. Die Gegner werden über Leben, Merkmale und die
Seltenheit ihrer Karten stärker, nicht über Begleiter.
- Booster: je offenem Kapitel eine Pack-Kachel (Symbol, Name, Preis, ×1 ×5 ×10), mittig; gesperrte
gibt es nicht zu sehen. Die Karten fliegen aus dem Pack ins Raster (ein Pack fünf große, mehrere
zehn kleine je Reihe, beste Stufe zuerst) und liegen verdeckt; Aufdecken gestaffelt, seltene
blitzen auf, ab Ultra Rare mit Pause. Danach das Fazit „n neu · n aufgewertet · +n Staub“, das
gekaufte Pack pulsiert.
- Skilltree: oben Level, Erfahrung bis zum nächsten Level und freie Punkte, darunter drei Spalten
(Sammeln, Wirtschaft, Kampf) mit Skillkarten: Name, Wirkung, „Jetzt“ und „Nächste Stufe“ in
Zahlen, Stufe x / y und „Lernen“.
- Cheats (kleiner Knopf unten rechts im Hauptmenü): Stand oben, darunter Knopfreihen für Währung,
Erfahrung, Kampagne und Sammlung; jeder Knopf speichert sofort.
- Sammlung: sechs Spalten, nur Elemente offener Kapitel, eigene Karten zuerst (höchste Stufen vorn), fehlende abgedunkelt, rechts
die große Vorschau mit Aufwerten, Zerlegen oder Herstellen. Deck: gleiches Raster, Filter nach Element (Symbolknöpfe)
und Mana 0 bis 10, Stapelkarten zuerst und höchste Stufen vorn, Stapelkarten mit grünem Haken, die
anderen abgedunkelt; Klick wechselt. Rechts Kartenzahl, Stärke (mittlerer Kartenwert, 100 für Grundkarten bis 500 für
Ultimate Rare), Manakurve, Elementanteile und die Vorschau der gezeigten Karte.
## Grafik
+51 -58
View File
@@ -1,79 +1,72 @@
class_name Campaign
## Kampagne: zehn benannte Magier in drei Kapiteln. Die Bosse haben weniger Leben als du,
## dafür Merkmale; je Kapitel kommt eins dazu.
## Kampagne: fünf Kapitel mit je zehn benannten Magiern, der zehnte ist der Boss. Kapitel 1 spielt
## neutrale Karten, Kapitel 2 bis 5 je ein Element; jeder Gegner spielt 30 Karten seines Elements
## nach Manakurve (DeckGen.curve_deck) mit steigendem Kostendeckel (COST_CAP), in der Stufe seines Kapitels (Kapitel 1 und 2 Common, dann je Kapitel eine höher, Kapitel 5
## Ultra Rare). Mit jedem offenen
## Kapitel gibt es den Booster seines Elements zu kaufen (Profile.booster_unlocked).
const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "life": 22, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "life": 22, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "life": 24, "reward": 150, "perk": "wesen_angriff"},
{"name": "Liska", "title": "Windläuferin", "life": 24, "reward": 150, "perk": "ziehen_gratis"},
{"name": "Vulkar", "title": "Herr der Lava", "life": 24, "reward": 200, "perk": "wesen_leben"},
{"name": "Nyx", "title": "Sturmruferin", "life": 26, "reward": 200, "perk": "extra_mana"},
{"name": "Ignis", "title": "Inferno-Priester", "life": 26, "reward": 250, "perk": "element_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "life": 26, "reward": 250, "perk": "alle_flink"},
{"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 300, "perk": "alle_flink"},
{"name": "Archimagier", "title": "Meister der Elemente", "life": 28, "reward": 500, "perk": "start_energie"},
]
const STAGES_PER_CHAPTER := 10
const CHAPTER_ELEMENTS: Array[String] = [Cards.NEUTRAL, "Feuer", "Wasser", "Luft", "Erde"]
const NAMES: Array[String] = ["Fenn", "Mara", "Borin", "Liska", "Vulkar", "Nyx", "Ignis", "Thalassa", "Zephyra", "Archimagier"]
const TITLES := {
"Neutral": ["Kobold-Zähmer", "Wanderhexe", "Steinfaust", "Pfadfinderin", "Söldnerfürst", "Schattenruferin", "Grauer Priester", "Händlerkönigin", "Stahlkaiserin", "Meister der Lehre"],
"Feuer": ["Flammenzähmer", "Glutweberin", "Schmiedefaust", "Funkenläuferin", "Herr der Lava", "Aschenruferin", "Inferno-Priester", "Glutkönigin", "Flammenkaiserin", "Meister des Feuers"],
"Wasser": ["Gezeitenzähmer", "Gezeitenhexe", "Eisfaust", "Nebelläuferin", "Herr der Brandung", "Regenruferin", "Tiefsee-Priester", "Tiefseekönigin", "Flutkaiserin", "Meister des Wassers"],
"Luft": ["Windzähmer", "Wolkenhexe", "Sturmfaust", "Windläuferin", "Herr der Böen", "Sturmruferin", "Himmelspriester", "Wolkenkönigin", "Blitzkaiserin", "Meister der Lüfte"],
"Erde": ["Wurzelzähmer", "Moorhexe", "Felsenfaust", "Hügelläuferin", "Herr der Schluchten", "Bebenruferin", "Erzpriester", "Kristallkönigin", "Bergkaiserin", "Meister der Erde"],
}
const LIFE := [22, 22, 24, 24, 24, 26, 26, 26, 28, 28]
## Höchste Kartenkosten im Stapel je Stufe eines Kapitels.
const COST_CAP: Array[int] = [3, 4, 4, 5, 5, 6, 7, 8, 9, 10]
## Merkmale je Stufe: das erste nach dieser Liste, weitere je drei Plätze dahinter.
const PERK_ORDER: Array[String] = ["feuer_angriff", "leben_35", "wesen_angriff", "ziehen_gratis", "wesen_leben", "extra_mana", "kein_nachteil", "alle_flink", "alle_flink", "start_energie"]
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Leben zurück.
const STAGES2 := [
{"name": "Fenn", "title": "Flammenzähmer", "life": 22, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
{"name": "Mara", "title": "Herrin der Gezeiten", "life": 22, "reward": 300, "perks": ["leben_35", "extra_mana"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "life": 24, "reward": 350, "perks": ["wesen_angriff", "alle_flink"]},
{"name": "Liska", "title": "Sturmläuferin", "life": 24, "reward": 350, "perks": ["ziehen_gratis", "wesen_angriff"]},
{"name": "Vulkar", "title": "Aschekönig", "life": 24, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "life": 26, "reward": 400, "perks": ["extra_mana", "ziehen_gratis"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "life": 26, "reward": 450, "perks": ["element_doppelt", "wesen_leben"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "life": 26, "reward": 450, "perks": ["alle_flink", "leben_35"]},
{"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 500, "perks": ["alle_flink", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "life": 28, "reward": 800, "perks": ["start_energie", "element_doppelt"]},
]
## Kapitel 3: drei Merkmale und volle Tische.
const STAGES3 := [
{"name": "Fenn", "title": "Aschefürst", "life": 20, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
{"name": "Mara", "title": "Sturmflut", "life": 20, "reward": 600, "perks": ["leben_35", "extra_mana", "alle_flink"]},
{"name": "Borin", "title": "Berg selbst", "life": 22, "reward": 700, "perks": ["wesen_angriff", "alle_flink", "leben_35"]},
{"name": "Liska", "title": "Windbraut", "life": 22, "reward": 700, "perks": ["ziehen_gratis", "wesen_angriff", "extra_mana"]},
{"name": "Vulkar", "title": "Magmakaiser", "life": 22, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "wesen_angriff"]},
{"name": "Nyx", "title": "Orkanmutter", "life": 24, "reward": 800, "perks": ["extra_mana", "ziehen_gratis", "element_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "life": 24, "reward": 900, "perks": ["element_doppelt", "wesen_leben", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "life": 24, "reward": 900, "perks": ["alle_flink", "leben_35", "element_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "life": 26, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]},
{"name": "Archimagier", "title": "Ewiger Meister", "life": 26, "reward": 1200, "perks": ["start_energie", "element_doppelt", "leben_35"]},
]
const CHAPTERS := [STAGES, STAGES2, STAGES3]
static func total() -> int:
return STAGES.size() * CHAPTERS.size()
return STAGES_PER_CHAPTER * CHAPTER_ELEMENTS.size()
static func chapter(stage: int) -> int:
return stage / STAGES.size() + 1
return stage / STAGES_PER_CHAPTER + 1
## Stufe mit einheitlichem Feld "perks".
static func element_of(chapter_no: int) -> String:
return CHAPTER_ELEMENTS[chapter_no - 1]
## Merkmale je Gegner: eins, ab Kapitel 3 zwei, in Kapitel 5 drei.
static func perk_count(chapter_no: int) -> int:
return (chapter_no + 1) / 2
## Stufe i: Name, Titel, Leben, Belohnung (Kapitel 1: 100 bis 300, Boss 500; je Kapitel 250 mehr),
## Merkmale und Element.
static func stage(i: int) -> Dictionary:
if i < STAGES.size():
var st: Dictionary = STAGES[i].duplicate()
st["perks"] = [st.perk]
return st
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
var c := chapter(i)
var k := i % STAGES_PER_CHAPTER
var perks: Array = []
for n in perk_count(c):
perks.append(PERK_ORDER[(k + 3 * n) % STAGES_PER_CHAPTER])
var base := 100 + 250 * (c - 1)
return {"name": NAMES[k], "title": TITLES[element_of(c)][k], "life": LIFE[k],
"reward": base + (400 if k == STAGES_PER_CHAPTER - 1 else 50 * (k / 2)),
"perks": perks, "element": element_of(c), "max_cost": COST_CAP[k]}
## Boss einer Stufe mit dem Leben, den Merkmalen und einem Stapel der Stufe; ab Kapitel 2 sind
## seine Karten eine Seltenheitsstufe höher (+1/+1), ab Kapitel 3 zwei.
## Gegner einer Stufe: Leben und Merkmale der Stufe, 30 Karten seines Elements nach Manakurve bis
## max_cost, in der Stufe des Kapitels (rarity_of).
static func opponent(stage_index: int, r: RandomNumberGenerator = null) -> Game.Player:
var st := stage(stage_index)
var boss := Game.Player.new(st.name, false, int(st.life), st.title)
boss.perks = Array(st.perks)
if r == null:
r = Game.make_rng()
var ch := chapter(stage_index)
boss.deck = DeckGen.opponent_deck(r, ch)
boss.deck = DeckGen.curve_deck(st.element, r, int(st.max_cost))
for id in boss.deck:
boss.looks[id] = ch - 1
boss.looks[id] = rarity_of(chapter(stage_index))
return boss
## Seltenheitsstufe der Gegnerkarten: Kapitel 1 und 2 Common, dann je Kapitel eine höher.
static func rarity_of(chapter_no: int) -> int:
return maxi(0, chapter_no - 2)
+61 -24
View File
@@ -1,7 +1,10 @@
class_name DeckGen
## Stapel: die vier Startdecks, Auffüllen aus der Sammlung, zufällige Stapel für Gegner.
## Stapel: Elementstapel nach Manakurve (Startstapel und Gegner), Auffüllen aus der Sammlung,
## zufällige Stapel für Tests und Simulation.
const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens
## Manakurve eines Stapels mit DECK_MIN Karten: so viele Karten je Kosten 0 bis 10.
const CURVE: Array[int] = [2, 5, 6, 6, 5, 3, 2, 1, 0, 0, 0]
## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang.
@@ -18,41 +21,75 @@ static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array:
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)])
## Alle Karten eines Elements (oder die neutralen) in Datenreihenfolge: die Startsammlung.
static func element_deck(element: String) -> Array:
return Cards.all().filter(func(c): return c.elements[0] == element).map(func(c): return c.id)
## 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 := []
## Stapel eines Elements nach der Manakurve: DECK_MIN verschiedene Karten des Elements bis max_cost,
## je Kosten so viele wie CURVE vorgibt, zufällig gewählt. Was über dem Deckel fehlt, füllen die
## erlaubten Kosten reihum von billig nach teuer auf.
static func curve_deck(element: String, r: RandomNumberGenerator, max_cost: int = 10) -> Array:
var by_cost := {}
for c in Cards.all():
if c.elements[0] == element and c.attack <= 4:
out.append(c.id)
for e in Cards.ELEMENTS:
if e == element:
continue
var cheap := Cards.all().filter(func(c): return c.elements[0] == e and c.cost <= Game.START_MANA)
cheap.sort_custom(func(a, b): return a.cost < b.cost if a.cost != b.cost else a.attack > b.attack)
for c in cheap.slice(0, 5):
out.append(c.id)
if c.elements[0] == element and c.cost <= max_cost:
if not by_cost.has(c.cost):
by_cost[c.cost] = []
by_cost[c.cost].append(c.id)
for cost in by_cost:
Game.shuffle(by_cost[cost], r)
var out := []
for cost in CURVE.size():
for k in CURVE[cost]:
if by_cost.has(cost) and not by_cost[cost].is_empty():
out.append(by_cost[cost].pop_back())
while out.size() < Game.DECK_MIN:
var added := false
for cost in range(0, max_cost + 1):
if out.size() >= Game.DECK_MIN:
break
if by_cost.has(cost) and not by_cost[cost].is_empty():
out.append(by_cost[cost].pop_back())
added = true
if not added:
break
return out
## Füllt deck aus pool (Karten-IDs) bis limit auf, die wertvollsten zuerst (Card.value in der
## Stufe aus levels: ID -> Stufe), bei gleichem Wert die teuersten; nichts doppelt.
## Füllt deck aus pool (Karten-IDs) bis limit auf, die wertvollsten zuerst (Card.value in der Stufe
## aus levels: ID -> Stufe); bei gleichem Wert zuerst, was der Manakurve (CURVE, auf limit
## skaliert) noch fehlt, dann die billigeren, dann nach ID; nichts doppelt.
static func fill(deck: Array, pool: Array, limit: int, levels: Dictionary = {}) -> Array:
var out := deck.duplicate()
var cards := pool.filter(func(id): return not (id in out)).map(func(id): return Cards.by_id(id).leveled(int(levels.get(id, 0))))
cards.sort_custom(func(a, b):
if a.value() != b.value():
return a.value() > b.value()
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 a.cost < b.cost if a.cost != b.cost else a.id < b.id)
var have := {} # Kosten -> Karten im Stapel
for id in out:
var slot := clampi(Cards.by_id(id).cost, 0, CURVE.size() - 1)
have[slot] = int(have.get(slot, 0)) + 1
var i := 0
while i < cards.size() and out.size() < limit:
var j := i
while j < cards.size() and cards[j].value() == cards[i].value():
j += 1
var rest := []
for c in cards.slice(i, j):
if out.size() >= limit:
break
var slot := clampi(c.cost, 0, CURVE.size() - 1)
if int(have.get(slot, 0)) < CURVE[slot] * limit / Game.DECK_MIN:
out.append(c.id)
have[slot] = int(have.get(slot, 0)) + 1
else:
rest.append(c)
for c in rest:
if out.size() >= limit:
break
out.append(c.id)
i = j
return out
+28 -85
View File
@@ -6,11 +6,10 @@ extends RefCounted
const MAX_BOARD := 5
const START_HAND := 5 # Startkarten: die obersten des eigenen Stapels
const START_MANA := 1 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt
const DECK_MIN := 40 # Karten je Stapel: mindestens so viele (Gegner und Startdecks genau so viele)
const DECK_MIN := 30 # Karten je Stapel: mindestens so viele (Gegner genau so viele: alle Karten eines Elements)
const DECK_MAX := 60 # und höchstens so viele
const START_LIFE := 30
const ELEMENT_PAIR := 2 # so viele eigene offene Wesen eines Elements: Sondereffekt des Elements
const ELEMENT_COUNT := 3 # je so viele ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements
const DISCARD_GAIN := 1 # Abwerfen einer Handkarte bringt so viel Mana
# Merkmale der Kampagnen-Bosse (Player.perks).
@@ -23,7 +22,7 @@ const PERKS := {
"leben_35": "Beginnt mit 5 Leben mehr",
"alle_flink": "Wesen greifen sofort an",
"start_energie": "Hat im ersten Zug 2 Mana mehr",
"element_doppelt": "Elementbonus zählt doppelt",
"kein_nachteil": "Wesen haben keinen Elementnachteil",
}
## Für wiederholbare Tests: 0 = echter Zufall, sonst Startwert (zählt je Aufruf hoch).
@@ -50,8 +49,7 @@ class Creature extends RefCounted:
var max_health: int
var attacks_done := 0 # Angriffe in diesem Zug
var ready := false # lag schon zu Beginn des eigenen Zugs: darf angreifen und aufgedeckt werden
var hidden := false # verdeckt gelegt: greift nicht an, zählt nicht für die Elementboni
var aura := 0 # Elementbonus (+1 je Element mit vollem Stapel); das Leben ist schon angepasst
var hidden := false # verdeckt gelegt: greift nicht an, zählt nicht fürs Elementpaar
func _init(c: Cards.Card, o, id: int) -> void:
card = c
@@ -90,9 +88,9 @@ class Creature extends RefCounted:
out.append(k)
return out
## Angriffswert inklusive Elementbonus und Boss-Merkmalen.
## Angriffswert inklusive Boss-Merkmalen.
func power() -> int:
var v := attack + aura
var v := attack
var o = owner()
if o != null:
if "feuer_angriff" in o.perks and "Feuer" in card.elements:
@@ -118,7 +116,6 @@ class Creature extends RefCounted:
c.attacks_done = attacks_done
c.ready = ready
c.hidden = hidden
c.aura = aura
return c
@@ -137,7 +134,7 @@ class Player extends RefCounted:
var perks: Array = [] # Boss-Merkmale (siehe PERKS)
var looks := {} # Karten-ID -> Seltenheitsstufe, reine Optik (siehe Profile)
var turns := 0 # begonnene eigene Züge
var played := {} # Element -> bisher ausgespielte Karten (je ELEMENT_COUNT: +1/+1)
var cards_played := 0 # ausgespielte Karten (Münzen nach dem Kampf)
var draws := 0 # in diesem Zug gezogene Karten; die n-te kostet n-1 Mana
var fatigue := 0 # Züge ohne Karten im Stapel: so viel Leben kostet der nächste
@@ -147,8 +144,6 @@ class Player extends RefCounted:
is_human = h
life = l
max_life = l
for e in Cards.ELEMENTS:
played[e] = 0
func alive() -> bool:
return life > 0
@@ -178,7 +173,7 @@ class Player extends RefCounted:
p.perks = perks.duplicate()
p.looks = looks
p.turns = turns
p.played = played.duplicate()
p.cards_played = cards_played
p.deck = deck.duplicate()
p.draws = draws
p.fatigue = fatigue
@@ -406,14 +401,12 @@ func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String:
cr.health += 1
cr.max_health += 1
p.board.append(cr)
for e in card.elements:
p.played[e] = int(p.played.get(e, 0)) + 1
p.cards_played += 1
if face_down:
say("%s legt ein Wesen verdeckt." % p.name)
else:
say("%s spielt %s." % [p.name, card.name])
emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card, "hidden": face_down})
apply_auras()
cleanup()
check_end()
return ""
@@ -463,12 +456,11 @@ func reveal(p: Player, cr: Creature) -> String:
return ""
## Deckt ein Wesen auf; die Elementboni gelten sofort.
## Deckt ein Wesen auf; das Elementpaar gilt sofort.
func _flip(cr: Creature) -> void:
cr.hidden = false
say("%s wird aufgedeckt." % cr.name)
emit({"type": "reveal", "uid": cr.uid, "card": cr.card})
apply_auras()
# --- Verschmelzen ------------------------------------------------------------
@@ -523,49 +515,6 @@ func fuse(p: Player, a, b) -> String:
return ""
# --- Elementboni ---------------------------------------------------------------
## +1/+1 je volle ELEMENT_COUNT ausgespielte Karten eines Elements des Spielers; mehrere Elemente
## zählen alle, auch Neutral (eigener Zähler, aber kein Paar).
static func aura_of(elements: Array, played: Dictionary) -> int:
var a := 0
for e in elements:
a += int(played.get(e, 0)) / ELEMENT_COUNT
return a
## Bonus eines offenen Wesens aus den ausgespielten Karten seines Besitzers; verdeckte bekommen nichts.
func aura_for(c: Creature) -> int:
var o = c.owner()
if c.hidden or o == null:
return 0
var a := aura_of(c.card.elements, o.played)
if "element_doppelt" in o.perks:
a *= 2
return a
## Rechnet die Elementboni neu. Kommt ein Bonus, steigen Leben und Höchstleben; geht er
## (beim Verdecken), sinkt nur das Höchstleben, das Leben wird gekappt.
func apply_auras() -> void:
for p in players:
for c in p.board:
var a := aura_for(c)
if a != c.aura:
var delta: int = a - c.aura
c.max_health += delta
c.health = mini(c.health + maxi(delta, 0), c.max_health)
c.aura = a
## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme (sie zählt selbst mit).
func forecast(p: Player, card: Cards.Card) -> int:
var played := p.played.duplicate()
for e in card.elements:
played[e] = int(played.get(e, 0)) + 1
return aura_of(card.elements, played) * (2 if "element_doppelt" in p.perks else 1)
# --- Kampf -------------------------------------------------------------------
## Leben lassen sich erst angreifen, wenn der Gegner kein Wesen mehr hat; Wesen mit Spott
@@ -582,7 +531,7 @@ func can_attack(cr: Creature, target) -> Array:
var tgt_owner = target.owner() if target is Creature else target
if not (tgt_owner in enemies_of(cr.owner())):
return [false, "Kein gültiges Ziel"]
if cr.power() + matchup_bonus(cr, target) <= 0: # 0 Angriff reicht nur mit Elementvorteil
if cr.power() + matchup_bonus(cr, target) <= 0: # 0 Angriff macht auch im Vorteil nichts
return [false, "%s würde keinen Schaden machen" % cr.name]
if not (target is Creature) and not tgt_owner.board.is_empty():
return [false, "Erst die Wesen"]
@@ -609,17 +558,18 @@ func attack(cr: Creature, target) -> String:
return ""
## Elementvorteil beim Zuschlagen und beim Konter: im Vorteil zählt der Stapelbonus des Ziels
## nicht (so viel Schaden mehr), im Nachteil der eigene nicht (so viel weniger). Gegen Helden
## gibt es keinen.
## Elementvorteil beim Zuschlagen und beim Konter, als Verschiebung des Schadens: im Vorteil
## doppelt (+ Angriff), im Nachteil halb, aufgerundet (− Angriff / 2). Gegen Helden gibt es keinen;
## mit dem Merkmal kein_nachteil entfällt der Nachteil.
static func matchup_bonus(source: Creature, target) -> int:
if not (target is Creature):
return 0
var m := Cards.matchup(source.card.elements, target.card.elements)
if m > 0:
return target.aura
return source.power()
if m < 0:
return -source.aura
var o = source.owner()
return 0 if o != null and "kein_nachteil" in o.perks else -(source.power() / 2)
return 0
@@ -634,24 +584,17 @@ func damage(target, amount: int) -> void:
emit({"type": "damage", "target": ref_of(target), "amount": amount})
## Räumt Tote ab; danach gelten die Elementboni neu, was weitere Tote nach sich ziehen kann.
## Räumt Tote ab; wer besiegt ist, nimmt seine Wesen mit vom Tisch.
func cleanup() -> void:
for round in 8:
var died := false
for p in players:
for c in p.board.duplicate():
# Wer besiegt ist, nimmt seine Wesen mit vom Tisch.
if c.health <= 0 or not p.alive():
p.board.erase(c)
if c.health > 0:
say("%s verschwindet mit %s." % [c.name, p.name])
else:
say("%s stirbt." % c.name)
emit({"type": "death", "target": ref_of(c)})
died = true
if not died:
return
apply_auras()
for p in players:
for c in p.board.duplicate():
if c.health <= 0 or not p.alive():
p.board.erase(c)
if c.health > 0:
say("%s verschwindet mit %s." % [c.name, p.name])
else:
say("%s stirbt." % c.name)
emit({"type": "death", "target": ref_of(c)})
## Ende: einer von beiden hat kein Leben mehr.
@@ -676,11 +619,11 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
arr[j] = tmp
## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}, "deck": [ids]}
## Duell gegen einen zufälligen Gegner (Tests, Simulation). rules: {"human_perks": [...], "looks": {...}, "deck": [ids]}
static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
if r == null:
r = make_rng()
return new_battle_with(DeckGen.random_opponent(int(rules.get("opponent_life", START_LIFE))), r, false, rules)
return new_battle_with(DeckGen.random_opponent(), r, false, rules)
static func new_battle_with(opp: Player, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game:
+185 -46
View File
@@ -1,24 +1,30 @@
class_name Profile
extends RefCounted
## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen.
## Man startet mit einem von vier Startdecks (je ein Element im Mittelpunkt); Booster und die
## Werkstatt bringen den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe
## Man startet mit allen neutralen Karten (Stapel: 30 nach Manakurve); die Elemente schaltet die Kampagne frei (je Kapitel einen
## Booster), die Werkstatt bringt 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 := 7
const SAVE_VERSION := 8
const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100
const BOOSTER_COST := 100 # neutraler Booster
const ELEMENT_BOOSTER_COST := 200 # Booster eines Elements (ab seinem Kapitel)
const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0}
const START_COINS := 150
const REWARD_WIN := 100
const REWARD_LOSS := 50
# Schwierigkeit: Leben der Gegner, KI und Münzen.
# Münzen nach dem Kampf: je eigener Zug, je ausgespielte Karte und je Zug unter FAST_TURN_SEC
# Sekunden; ein Sieg verdoppelt alles, die Schwierigkeit multipliziert (siehe battle_coins).
const COINS_TURN := 5
const COINS_CARD := 5
const COINS_FAST := 5
const FAST_TURN_SEC := 15.0
# Schwierigkeit im Einzelspieler: Stufe der Gegnerkarten relativ zu deiner Deckstärke (level),
# KI (smart: Vorausschau, sonst gierig), ein zufälliges Merkmal (perk) und der Münzfaktor.
const DIFFICULTIES := {
"leicht": {"name": "Leicht", "life": 25, "reward": 0.75, "smart": false},
"normal": {"name": "Normal", "life": 30, "reward": 1.0, "smart": true},
"schwer": {"name": "Schwer", "life": 35, "reward": 1.5, "smart": true},
"leicht": {"name": "Leicht", "level": -1, "smart": false, "perk": false, "reward": 1.0},
"normal": {"name": "Normal", "level": 0, "smart": true, "perk": false, "reward": 1.5},
"schwer": {"name": "Schwer", "level": 1, "smart": true, "perk": true, "reward": 2.0},
}
# Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub.
const DUST_DISMANTLE := {"Common": 50, "Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
@@ -26,6 +32,8 @@ const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultim
var coins := 0
var dust := 0
var xp := 0 # Erfahrung: alle je verdienten Münzen (Skills.level_for)
var skills := {} # Skill-ID -> gelernte Stufe (Skills.SKILLS)
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
@@ -34,24 +42,20 @@ var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfort
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
## Neues Profil: noch ohne Karten, das Startdeck wählt der Spieler (choose_start).
## Neues Profil: alle neutralen Karten in der Sammlung, 30 davon nach Manakurve im Stapel.
static func starter() -> Profile:
var p := Profile.new()
p.coins = START_COINS
p.give_start()
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:
## Alle neutralen Karten in Common werden die Sammlung, ein Kurvenstapel daraus der Stapel.
func give_start() -> void:
collection = {}
deck = DeckGen.start_deck(element)
for id in deck:
for id in DeckGen.element_deck(Cards.NEUTRAL):
collection[id] = 0
deck = DeckGen.curve_deck(Cards.NEUTRAL, Game.make_rng())
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
@@ -74,6 +78,11 @@ static func from_dict(d: Dictionary) -> Profile:
var p := Profile.new()
p.coins = int(d.get("coins", 0))
p.dust = int(d.get("dust", 0))
p.xp = int(d.get("xp", 0))
var sk = d.get("skills", {})
for id in sk:
if Skills.SKILLS.has(id):
p.skills[id] = clampi(int(sk[id]), 0, int(Skills.SKILLS[id].max))
p.campaign_stage = int(d.get("campaign_stage", 0))
if int(d.get("version", 1)) >= SAVE_VERSION:
var coll = d.get("collection", {})
@@ -85,6 +94,8 @@ static func from_dict(d: Dictionary) -> Profile:
if p.collection.has(cid) and not (cid in ids):
ids.append(cid)
p.deck = ids.slice(0, Game.DECK_MAX)
if p.collection.is_empty():
p.give_start()
var se = d.get("settings", {})
p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)),
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
@@ -99,8 +110,8 @@ static func from_dict(d: Dictionary) -> Profile:
func to_dict() -> Dictionary:
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, "deck": deck,
"campaign_stage": campaign_stage, "settings": settings, "perk": perk}
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "xp": xp, "skills": skills, "collection": collection,
"deck": deck, "campaign_stage": campaign_stage, "settings": settings, "perk": perk}
## Speichert und sichert den vorherigen Stand als .bak.
@@ -182,7 +193,7 @@ func dismantle(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid)
dust += DUST_DISMANTLE[Cards.RARITIES[lvl]]
dust += dust_gain(DUST_DISMANTLE[Cards.RARITIES[lvl]])
if lvl == 0:
collection.erase(cid)
deck.erase(cid)
@@ -204,11 +215,21 @@ func craft(cid: String) -> String:
# --- Booster ----------------------------------------------------------------------
## Fünf Karten mit gewürfelter Seltenheit.
static func roll_booster(r: RandomNumberGenerator) -> Array:
## Preis eines Boosters: neutral BOOSTER_COST, je Element ELEMENT_BOOSTER_COST.
static func booster_cost(element: String) -> int:
return BOOSTER_COST if element == Cards.NEUTRAL else ELEMENT_BOOSTER_COST
## Der Booster eines Elements ist zu haben, sobald sein Kampagnenkapitel offen ist (neutral immer).
func booster_unlocked(element: String) -> bool:
return campaign_stage >= Campaign.STAGES_PER_CHAPTER * Campaign.CHAPTER_ELEMENTS.find(element)
## Fünf Karten des Elements mit gewürfelter Seltenheit; luck (Packglück) verschiebt die Chancen.
static func roll_booster(r: RandomNumberGenerator, element: String, luck: int = 0) -> Array:
var rarities := RARITY_WEIGHTS.keys()
var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
var pool := Cards.all()
var weights := Skills.pack_weights(luck)
var pool := Cards.all().filter(func(c): return c.elements[0] == element)
var out := []
for i in BOOSTER_SIZE:
var rar: String = rarities[r.rand_weighted(weights)]
@@ -218,16 +239,29 @@ static func roll_booster(r: RandomNumberGenerator) -> Array:
return out
## Öffnet einen Booster. Neue Karten kommen in Sammlung und Stapel (solange Platz ist). Von jeder
## Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine gleiche
## oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn die Münzen nicht reichen.
func open_booster(r: RandomNumberGenerator = null) -> Array:
if coins < BOOSTER_COST:
## Öffnet einen Booster des Elements. Neue Karten kommen in Sammlung und Stapel (solange Platz ist).
## Von jeder Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine
## gleiche oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn der Booster
## noch gesperrt ist oder die Münzen nicht reichen.
func open_booster(element: String, r: RandomNumberGenerator = null) -> Array:
return open_boosters(element, 1, r)
## Öffnet n Booster des Elements auf einmal; leer, wenn gesperrt oder die Münzen für alle nicht reichen.
func open_boosters(element: String, n: int, r: RandomNumberGenerator = null) -> Array:
if not booster_unlocked(element) or coins < pack_price(element) * n:
return []
if r == null:
r = Game.make_rng()
coins -= BOOSTER_COST
var out := roll_booster(r)
var out := []
for i in n:
out += _open_one(element, r)
return out
func _open_one(element: String, r: RandomNumberGenerator) -> Array:
coins -= pack_price(element)
var out := roll_booster(r, element, skill("packglueck"))
for res in out:
var lvl: int = Cards.RARITIES.find(res.rarity)
res["new"] = not owns(res.card.id)
@@ -239,33 +273,138 @@ func open_booster(r: RandomNumberGenerator = null) -> Array:
deck.append(res.card.id)
elif lvl > level(res.card.id):
res["upgraded"] = true
res["dust"] = DUST_DISMANTLE[rarity(res.card.id)]
res["dust"] = dust_gain(DUST_DISMANTLE[rarity(res.card.id)])
collection[res.card.id] = lvl
else:
res["dust"] = DUST_DISMANTLE[res.rarity]
res["dust"] = dust_gain(DUST_DISMANTLE[res.rarity])
dust += res.dust
return out
# --- Kampf und Kampagne -----------------------------------------------------------
## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück.
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
func record_battle(won: bool, stage: int = -1, reward_factor: float = 1.0) -> int:
var reward := REWARD_LOSS
## Münzen eines Kampfes: COINS_TURN je eigenem Zug, COINS_CARD je ausgespielter Karte, COINS_FAST
## je schnellem Zug; ein Sieg verdoppelt, factor ist der Münzfaktor der Schwierigkeit.
static func battle_coins(won: bool, turns: int, cards: int, fast: int, factor: float = 1.0) -> int:
var pts := COINS_TURN * turns + COINS_CARD * cards + COINS_FAST * fast
if won:
reward = int(round(REWARD_WIN * reward_factor))
if stage >= 0 and stage == campaign_stage:
campaign_stage += 1
reward = Campaign.stage(stage).reward
notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name)
pts *= 2
return int(round(pts * factor))
## Verbucht ein Kampfergebnis mit den verdienten Münzen (battle_coins, mal Münzsegen) und gibt die
## Gutschrift zurück; sie zählt auch als Erfahrung, ein neues Level meldet sich. stage >= 0:
## Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei und bringt ihre Belohnung obendrauf.
func record_battle(won: bool, earned: int, stage: int = -1) -> int:
var reward := int(round(earned * coin_factor()))
if won and stage >= 0 and stage == campaign_stage:
campaign_stage += 1
reward += Campaign.stage(stage).reward
notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name)
coins += reward
var before := player_level()
xp += reward
if player_level() > before:
var gained := player_level() - before
notices.append(("Level %d erreicht: +%d Skillpunkt" if gained == 1 else "Level %d erreicht: +%d Skillpunkte") % [player_level(), gained])
return reward
# --- Skilltree ----------------------------------------------------------------------
## Spielerlevel aus der Erfahrung (nicht die Kartenstufe level(cid)).
func player_level() -> int:
return Skills.level_for(xp)
## Gelernte Stufe eines Skills.
func skill(id: String) -> int:
return int(skills.get(id, 0))
## Freie Skillpunkte: ein Punkt je Level, abzüglich der gelernten Stufen.
func skill_points() -> int:
var spent := 0
for id in skills:
spent += int(skills[id])
return player_level() - spent
## Lernt die nächste Stufe eines Skills für einen Punkt. Gibt "" zurück oder eine Fehlermeldung.
func learn(id: String) -> String:
if skill(id) >= int(Skills.SKILLS[id].max):
return "Schon auf der höchsten Stufe"
if skill_points() <= 0:
return "Kein Skillpunkt frei"
skills[id] = skill(id) + 1
return ""
## Preis eines Boosters mit Händler-Rabatt.
func pack_price(element: String) -> int:
return int(round(booster_cost(element) * (1.0 - 0.1 * skill("packpreis"))))
func coin_factor() -> float:
return 1.0 + 0.1 * skill("muenzen")
## Staub mit Staubsammler-Bonus.
func dust_gain(base: int) -> int:
return int(round(base * (1.0 + 0.25 * skill("staub"))))
## Merkmale im Kampf: das gewählte (nach Kapitel 1) und die der Kampfskills.
func battle_perks() -> Array:
var out := []
if perk != "" and perk_unlocked():
out.append(perk)
for id in Skills.PERK_OF:
if skill(id) > 0 and not (Skills.PERK_OF[id] in out):
out.append(Skills.PERK_OF[id])
return out
func difficulty() -> Dictionary:
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
## Deine Stufe: die Deckstärke des Kampfstapels (100 Common … 500 Ultimate Rare) als Seltenheitsstufe.
func deck_level() -> int:
return clampi(roundi((DeckGen.strength(deck_cards(), collection) - 100) / 100.0), 0, Cards.RARITIES.size() - 1)
## Stufe der Gegnerkarten im Einzelspieler: deine Stufe plus den Versatz der Schwierigkeit.
func opponent_level() -> int:
return clampi(deck_level() + int(difficulty().level), 0, Cards.RARITIES.size() - 1)
## Elemente der offenen Kampagnenkapitel (Neutral immer).
func unlocked_elements() -> Array:
return Campaign.CHAPTER_ELEMENTS.slice(0, campaign_stage / Campaign.STAGES_PER_CHAPTER + 1)
## Alle Karten der offenen Elemente: was Sammlung, Deck und Booster zeigen.
func unlocked_cards() -> Array:
var open := unlocked_elements()
return Cards.all().filter(func(c): return c.elements[0] in open)
## Einzelspieler-Gegner: ein Element aus den offenen Kapiteln, 30 Karten nach Manakurve in opponent_level;
## auf Schwer ein zufälliges Merkmal. Leben wie deins.
func single_opponent(r: RandomNumberGenerator) -> Game.Player:
var elements := unlocked_elements()
var element: String = elements[r.randi_range(0, elements.size() - 1)]
var opp := Game.Player.new("Gegner", false, Game.START_LIFE, element)
opp.deck = DeckGen.curve_deck(element, r)
var level := opponent_level()
for id in opp.deck:
opp.looks[id] = level
if bool(difficulty().perk):
var keys: Array = Game.PERKS.keys()
opp.perks = [keys[r.randi_range(0, keys.size() - 1)]]
return opp
func perk_unlocked() -> bool:
return campaign_stage >= Campaign.STAGES.size()
return campaign_stage >= Campaign.STAGES_PER_CHAPTER
+62
View File
@@ -0,0 +1,62 @@
class_name Skills
## Skilltree: Erfahrung sind die verdienten Münzen, je Level ein Skillpunkt, Punkte kaufen Stufen
## der Skills in drei Ästen. Münzen kaufen keine Punkte.
const BRANCHES: Array[String] = ["Sammeln", "Wirtschaft", "Kampf"]
## id -> Name, Ast, Höchststufe, Text.
const SKILLS := {
"packglueck": {"name": "Packglück", "branch": "Sammeln", "max": 3, "text": "Seltenere Karten in Boostern"},
"staub": {"name": "Staubsammler", "branch": "Sammeln", "max": 3, "text": "Mehr Staub aus Doppelten und Zerlegen"},
"muenzen": {"name": "Münzsegen", "branch": "Wirtschaft", "max": 3, "text": "Mehr Münzen nach jedem Kampf"},
"packpreis": {"name": "Händler", "branch": "Wirtschaft", "max": 2, "text": "Booster werden billiger"},
"gratisziehen": {"name": "Freies Ziehen", "branch": "Kampf", "max": 1, "text": "Das zweite Ziehen je Zug ist auch gratis"},
"startmana": {"name": "Starker Start", "branch": "Kampf", "max": 1, "text": "Mehr Mana im ersten Zug"},
}
## Kampfskills geben ein Merkmal (Game.PERKS) im Kampf.
const PERK_OF := {"gratisziehen": "ziehen_gratis", "startmana": "start_energie"}
## Packglück je Stufe: Common gibt ab, die höheren Stufen gewinnen.
const LUCK_SHIFT := {"Common": -8.0, "Rare": 3.0, "Super Rare": 2.0, "Ultra Rare": 2.0, "Ultimate Rare": 1.0}
## Erfahrung, die Level n verlangt: 100, 300, 600, 1000, 1500 … (50 · n · (n + 1)).
static func xp_for_level(n: int) -> int:
return 50 * n * (n + 1)
## Level zu einer Erfahrung.
static func level_for(xp: int) -> int:
var n := 0
while xp >= xp_for_level(n + 1):
n += 1
return n
static func in_branch(branch: String) -> Array:
return SKILLS.keys().filter(func(id): return SKILLS[id].branch == branch)
## Boosterchancen je Stufe (Reihenfolge Cards.RARITIES) mit Packglück luck.
static func pack_weights(luck: int) -> PackedFloat32Array:
var out := PackedFloat32Array()
for rar in Cards.RARITIES:
out.append(float(Profile.RARITY_WEIGHTS[rar]) + luck * float(LUCK_SHIFT[rar]))
return out
## Konkrete Wirkung eines Skills auf einer Stufe, als Text.
static func effect(id: String, rank: int) -> String:
match id:
"packglueck":
var w := pack_weights(rank)
return TranslationServer.translate("Chancen %d/%d/%d/%d/%d %%") % [int(w[0]), int(w[1]), int(w[2]), int(w[3]), int(w[4])]
"staub":
return TranslationServer.translate("+%d %% Staub") % (25 * rank)
"muenzen":
return TranslationServer.translate("+%d %% Münzen") % (10 * rank)
"packpreis":
return TranslationServer.translate("Neutral %d, Element %d Münzen") % [roundi(Profile.BOOSTER_COST * (1.0 - 0.1 * rank)), roundi(Profile.ELEMENT_BOOSTER_COST * (1.0 - 0.1 * rank))]
"gratisziehen":
return TranslationServer.translate("zwei Karten je Zug gratis" if rank > 0 else "eine Karte je Zug gratis")
"startmana":
return TranslationServer.translate("erster Zug: %d Mana") % (Game.START_MANA + (2 if rank > 0 else 0))
return ""
+1
View File
@@ -0,0 +1 @@
uid://bbcmk1gtfjoim
+1 -1
View File
@@ -68,6 +68,6 @@ static func preview_rect(_anchor: Rect2) -> Rect2:
return Rect2(Vector2(1070, 278), PREVIEW_SIZE)
## Zusatzzeile (Elementbonus, Eigenschaften) links unter den Elementzählern.
## Zusatzzeile (Merkmale, Eigenschaften) links unter den Elementpaaren.
static func info_rect(_preview: Rect2) -> Rect2:
return INFO
+53 -27
View File
@@ -31,7 +31,10 @@ var ai_timer := 0.0
var ai_task := -1 # WorkerThreadPool-Aufgabe der laufenden KI-Berechnung
var ai_copy: Game # Spielkopie, auf der die KI rechnet
var ai_box := {} # Ergebnis der Berechnung (vom Thread befüllt)
var reward := -1
var reward := -1 # gutgeschriebene Münzen (mit Kampagnenbonus)
var earned := 0 # Münzen aus Zügen, Karten und Tempo (Profile.battle_coins)
var fast_turns := 0 # eigene Züge unter Profile.FAST_TURN_SEC
var turn_start := 0 # Beginn des laufenden eigenen Zugs (ms)
var ended := false
var pad: BattleGamepad
var gp_zone: String:
@@ -72,7 +75,7 @@ var energy_label: Label
var msg_label: Label
var element_view: ElementView # eigene Elementleiste
var opp_element_view: ElementView # die des Gegners darüber
var info_label: Label # Vorschau: Elementbonus, Merkmale
var info_label: Label # Vorschau: Merkmale, Eigenschaften
var menu_layer: Control # Kartenmenü (Rechtsklick)
var menu_target = null # Karte oder Wesen, für das das Menü offen ist
var arrivals := {} # uid -> Rect2: Wesen, das gerade aus der Hand aufs Brett fliegt
@@ -81,6 +84,7 @@ var banner_turn := -1 # Zug, für den die Zugansage schon lief
var overlay: Control
var result_label: Label
var reward_label: Label
var detail_label: Label # Aufschlüsselung der Münzen
var notice_label: Label
var confirm: Control
var cursor_layer: Control
@@ -99,23 +103,20 @@ func _ready() -> void:
pad = BattleGamepad.new(self)
ai_delay = 0.75 / float(s.ai_speed)
_build_static()
if stage >= 0:
var r := Game.make_rng()
game = Game.new_battle_with(Campaign.opponent(stage, r), r, false, own_rules())
else:
game = Game.new_battle(Game.make_rng(), own_rules())
var r := Game.make_rng()
var opp: Game.Player = Campaign.opponent(stage) if stage >= 0 else app.profile.single_opponent(r)
game = Game.new_battle_with(opp, r, false, own_rules())
smart_ai = bool(app.profile.difficulty().smart)
_begin()
## Eigener Stapel und Kartenstufen, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1).
## Eigener Stapel und Kartenstufen und das eigene Merkmal (nach Kapitel 1).
func own_rules() -> Dictionary:
var p: Profile = app.profile
var rules := {"looks": p.looks(), "deck": p.deck_cards()}
if stage < 0:
rules["opponent_life"] = int(p.difficulty().life)
if p.perk != "" and p.perk_unlocked():
rules["human_perks"] = [p.perk]
var perks := p.battle_perks()
if not perks.is_empty():
rules["human_perks"] = perks
return rules
@@ -224,7 +225,8 @@ func _build_static() -> void:
overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
reward_label = Style.label(overlay, "", 26, Style.GOLD, Rect2(0, 335, 1280, 40), "center")
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center")
detail_label = Style.label(overlay, "", 17, Style.MUTED, Rect2(0, 372, 1280, 28), "center")
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 405, 1280, 70), "center")
Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen")
@@ -459,8 +461,11 @@ func _exit_tree() -> void:
func _finish() -> void:
var won := game.winner == "human"
if game.current() == game.human:
_close_turn() # der Zug, der das Spiel entschied
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
reward = app.profile.record_battle(won, stage, factor)
earned = Profile.battle_coins(won, game.human.turns, game.human.cards_played, fast_turns, factor)
reward = app.profile.record_battle(won, earned, stage)
app.save()
app.sfx.play("win" if won else "lose")
refresh()
@@ -777,6 +782,7 @@ func end_turn() -> void:
return
cancel()
clear_undo()
_close_turn()
game.end_turn()
ai_timer = ai_delay
after_action()
@@ -839,11 +845,36 @@ func on_tile_click(cr: Game.Creature) -> void:
on_click("enemy", cr)
## Zugansage, wenn der eigene Zug beginnt.
## Zählt den laufenden eigenen Zug als schnell, wenn er unter Profile.FAST_TURN_SEC blieb.
func _close_turn() -> void:
if Time.get_ticks_msec() - turn_start < Profile.FAST_TURN_SEC * 1000:
fast_turns += 1
## Aufschlüsselung der Münzen fürs Ergebnisbild.
func reward_text() -> String:
var h := game.human
var s := "%s · %s · %s = %d" % [tr("%d Züge") % h.turns, tr("%d Karten") % h.cards_played, tr("%d schnelle Züge") % fast_turns,
Profile.COINS_TURN * h.turns + Profile.COINS_CARD * h.cards_played + Profile.COINS_FAST * fast_turns]
if game.winner == "human":
s += " · " + tr("Sieg ×2")
var d: Dictionary = app.profile.difficulty()
if stage < 0 and float(d.reward) != 1.0:
s += " · %s %s" % [tr(d.name), Style.factor(float(d.reward))]
var boosted := int(round(earned * app.profile.coin_factor()))
if boosted != earned:
s += " · %s %s" % [tr("Münzsegen"), Style.factor(app.profile.coin_factor())]
if reward > boosted:
s += " · " + tr("Kampagne +%d") % (reward - boosted)
return s
## Zugansage, wenn der eigene Zug beginnt; ab hier läuft die Zeit für den schnellen Zug.
func announce_turn() -> void:
if game == null or not my_turn() or banner_turn == game.turn:
return
banner_turn = game.turn
turn_start = Time.get_ticks_msec()
fx.banner(tr("Dein Zug"), Style.GOLD)
fx.sfx("turn")
@@ -871,23 +902,17 @@ func show_preview(card, entered: bool, owner_looks: Dictionary = {}, info: Strin
info_label.size = ir.size
## Eigene Handkarte: zeigt den Elementbonus, den sie offen gelegt bekäme.
## Eigene Handkarte in der Vorschau.
func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void:
if drag_ghost != null:
return
var a := game.forecast(perspective, card)
var info := ""
if a > 0:
info = tr("Elementbonus +%d") % a
show_preview(card, entered, perspective.looks, info, anchor)
show_preview(card, entered, perspective.looks, "", anchor)
## Was ein Wesen gerade hebt, was es kann und ob es schon angreift.
func creature_info(cr: Game.Creature) -> String:
var parts: Array[String] = []
if cr.aura > 0:
parts.append(tr("Elementbonus +%d") % cr.aura)
var perk := cr.power() - cr.attack - cr.aura
var perk := cr.power() - cr.attack
if perk > 0:
parts.append(tr("Merkmal +%d") % perk)
if cr.card.is_neutral():
@@ -902,10 +927,11 @@ func creature_info(cr: Game.Creature) -> String:
return " · ".join(parts)
## Elementvorteil des gewählten Angreifers gegen ein mögliches Ziel; gegen Verdeckte unbekannt.
## Konter eines möglichen Ziels gegen den gewählten Angreifer (Verschiebung durch sein Element);
## gegen Verdeckte unbekannt.
func matchup_for(cr: Game.Creature) -> int:
if selected != null and not cr.hidden and game.can_attack(selected, cr)[0]:
return Game.matchup_bonus(selected, cr)
return Game.matchup_bonus(cr, selected)
return 0
@@ -1139,7 +1165,6 @@ func refresh() -> void:
fx.glide(v, hand_arrivals[card])
hand_arrivals.erase(card)
v.lift = mine
v.bonus = g.forecast(h, card)
if fuse_first != null and mine and g.can_fuse(h, fuse_first, card)[0]:
v.border = Style.PURPLE
elif card == fuse_first:
@@ -1185,6 +1210,7 @@ func refresh() -> void:
result_label.text = "Sieg!" if won else "Niederlage"
result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED)
reward_label.text = "+%d Münzen" % maxi(reward, 0)
detail_label.text = reward_text()
var notes: Array[String] = app.profile.take_notices()
if not notes.is_empty():
notice_label.text = "\n".join(notes)
+165 -36
View File
@@ -1,19 +1,32 @@
class_name BoosterScreen
extends Control
## Booster kaufen und öffnen: unten die Pack-Kacheln der offenen Kapitel (Neutral und die Elemente in
## Kapitelreihenfolge, gesperrte sind unsichtbar) mit ×1, ×5, ×10; die Karten fliegen aus dem Pack in ein Raster
## (ein Pack: fünf große, mehrere: zehn je Reihe, beste Stufe zuerst) und liegen verdeckt. Klick
## oder „Alle aufdecken“ dreht sie gestaffelt um, seltene blitzen auf; danach steht das Fazit.
const CARD_W := 200
const CARD_H := 280
const SMALL_W := 108
const SMALL_H := 151
const AREA := Rect2(40, 100, 1200, 460)
const QUANTITIES := [1, 5, 10]
var app
var cards: Array = [] # {"card", "rarity", "upgraded", "dust"} je Karte
var cards: Array = [] # {"card", "rarity", "new", "upgraded", "dust"} je Karte
var revealed := {}
var coins_label: Label
var msg_label: Label
var b_buy: Button
var summary_label: Label
var b_buy: Button # Neutral ×1 (Tests)
var b_all: Button
var scroll: ScrollContainer
var slots: Control
var fx_layer: Control
var fresh := {} # gerade aufgedeckt: wird umgedreht
var pack_tiles := {} # Element -> PackTile (Kapitelreihenfolge)
var pack_buttons := {} # Element -> der ×1-Knopf
var fresh := {} # gerade aufgedeckt: Index -> Verzögerung fürs Umdrehen
var fly_from := Vector2.ZERO # woher die Karten einfliegen (Mitte der gekauften Kachel)
var fly := false # beim nächsten refresh einfliegen
var last_pack := "" # zuletzt gekauftes Pack (pulsiert)
class CardBack extends Control:
@@ -39,28 +52,56 @@ class CardBack extends Control:
var rect := Rect2(Vector2.ZERO, size)
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect)
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
Style.text(self, "?", 48 if size.x >= 180 else 30, Style.GOLD, size / 2, "center", true)
func slot_rect(i: int) -> Rect2:
var gap := 20
var x0 := (1280 - Profile.BOOSTER_SIZE * CARD_W - (Profile.BOOSTER_SIZE - 1) * gap) / 2
return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H)
## Pack-Kachel eines Elements: Symbol, Name, Preis; die Kaufknöpfe hängt der Bildschirm an.
class PackTile extends Control:
var element: String
var locked := false
var hint := ""
var pulse := false
var buy_buttons: Array = []
func _init(e: String, rect: Rect2) -> void:
element = e
position = rect.position
size = rect.size
pivot_offset = rect.size / 2
func _draw() -> void:
var col: Color = Cards.ELEMENT_COLORS[element]
draw_style_box(Style.box(Style.darker(col, 0.5), 10, Style.GOLD if pulse else Color(col, 0.7), 2), Rect2(Vector2.ZERO, size))
Style.element_icon(self, element, Vector2(32, 40), 34, Style.lighter(col, 0.4))
Style.text(self, tr(element), 16, Style.TEXT, Vector2(60, 30), "midleft", true)
Style.text(self, hint, 13, Style.MUTED, Vector2(60, 52), "midleft")
func _ready() -> void:
Style.header(self, "Booster", func(): app.show_screen(MenuScreen.new()))
coins_label = Style.chip(self, "", Rect2(860, 26, 240, 38))
fx_layer = Control.new()
fx_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(fx_layer)
scroll = ScrollContainer.new()
scroll.position = AREA.position
scroll.size = AREA.size
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
slots = Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(slots)
msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 640, 1280, 22), "center")
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(370, 690, 330, 52), buy, Color("5a4628"))
b_buy.add_theme_font_size_override("font_size", 20)
b_all = Style.button(self, "Alle aufdecken", Rect2(720, 690, 200, 52), reveal_all)
slots.mouse_filter = Control.MOUSE_FILTER_PASS
slots.custom_minimum_size = AREA.size
scroll.add_child(slots)
summary_label = Style.label(self, "", 16, Style.GOLD, Rect2(0, 566, 1280, 24), "center", true)
b_all = Style.button(self, "Alle aufdecken", Rect2(540, 594, 200, 40), reveal_all)
for i in Campaign.CHAPTER_ELEMENTS.size():
var e: String = Campaign.CHAPTER_ELEMENTS[i]
var tile := PackTile.new(e, Rect2(70 + i * 230, 644, 220, 136))
add_child(tile)
for k in QUANTITIES.size():
var b := Style.button(tile, "×%d" % QUANTITIES[k], Rect2(10 + k * 68, 88, 64, 36), buy.bind(e, QUANTITIES[k]))
b.add_theme_font_size_override("font_size", 14)
tile.buy_buttons.append(b)
pack_tiles[e] = tile
pack_buttons[e] = tile.buy_buttons[0]
b_buy = pack_buttons[Cards.NEUTRAL]
refresh()
@@ -69,19 +110,34 @@ func _input(event: InputEvent) -> void:
app.show_screen(MenuScreen.new())
func buy() -> void:
## Kauft n Packs eines Elements; die Karten liegen verdeckt, mehrere Packs nach Stufe sortiert.
func buy(element: String, n: int) -> void:
var p: Profile = app.profile
var got: Array = p.open_booster()
var before: int = p.coins
var got: Array = p.open_boosters(element, n)
if got.is_empty():
msg_label.text = "Nicht genug Münzen"
msg_label.add_theme_color_override("font_color", Style.RED)
return
app.save()
app.sfx.play("play")
if n > 1:
got.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
cards = got
revealed = {}
msg_label.text = ""
fresh = {}
fly = true
var tile: PackTile = pack_tiles[element]
fly_from = tile.position + tile.size / 2
last_pack = element
scroll.scroll_vertical = 0
refresh()
_count_coins(before, p.coins)
## Die Münzen zählen sichtbar herunter.
func _count_coins(from: int, to: int) -> void:
if not Style.animations or from == to:
return
create_tween().tween_method(func(v: float): coins_label.text = "Münzen: %d" % int(round(v)), float(from), float(to), 0.5)
func reveal(i: int) -> void:
@@ -91,13 +147,17 @@ func reveal(i: int) -> void:
refresh()
## Deckt alles auf, von vorn nach hinten gestaffelt; ab Ultra Rare bleibt die Karte kurz allein.
func reveal_all() -> void:
fresh = {}
var k := 0
for i in Profile.BOOSTER_SIZE:
var delay := 0.0
var step := 0.12 if cards.size() <= Profile.BOOSTER_SIZE else 0.06
for i in cards.size():
if not revealed.has(i):
fresh[i] = k * 0.12
k += 1
fresh[i] = delay
delay += step
if Cards.RARITIES.find(cards[i].rarity) >= 3:
delay += 0.35
revealed[i] = true
app.sfx.play("play")
refresh()
@@ -113,7 +173,7 @@ func flip(v: CardView, delay: float, rarity: String) -> void:
tw.tween_property(v, "scale:x", 1.0, 0.18).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
if rarity == "Common":
return
var burst := Style.panel(fx_layer, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12)
var burst := Style.panel(slots, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12)
burst.pivot_offset = burst.size / 2
burst.modulate.a = 0.0
var bt := create_tween()
@@ -124,13 +184,71 @@ func flip(v: CardView, delay: float, rarity: String) -> void:
bt.tween_callback(burst.queue_free)
## Einflug einer verdeckten Karte aus dem Pack auf ihren Platz, reihenweise gestaffelt.
func fly_in(back: Control, target: Vector2, i: int, per_row: int) -> void:
if not Style.animations:
return
back.position = fly_from - AREA.position - back.size / 2
back.scale = Vector2(0.4, 0.4)
var tw := create_tween().set_parallel()
var delay := (i / per_row) * 0.1 + (i % per_row) * 0.03
tw.tween_property(back, "position", target, 0.3).set_delay(delay).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.tween_property(back, "scale", Vector2.ONE, 0.3).set_delay(delay).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
## Platz der i-ten Karte: ein Pack fünf große in einer Reihe, sonst zehn kleine je Reihe.
func slot_rect(i: int) -> Rect2:
var single := cards.size() <= Profile.BOOSTER_SIZE
var w := CARD_W if single else SMALL_W
var h := CARD_H if single else SMALL_H
var per_row := 5 if single else 10
var gap := 20 if single else 12
var x0 := (AREA.size.x - per_row * w - (per_row - 1) * gap) / 2
var row_h := h + (60 if single else 44)
return Rect2(x0 + (i % per_row) * (w + gap), 10 + (i / per_row) * row_h, w, h)
## Fazit nach dem Aufdecken: neue Karten, Aufwertungen, Staub.
func summary() -> String:
if cards.is_empty() or revealed.size() < cards.size():
return ""
var fresh_n := 0
var up := 0
var dust := 0
for res in cards:
if res.get("new", false):
fresh_n += 1
elif res.upgraded:
up += 1
dust += int(res.dust)
return tr("%d neu · %d aufgewertet · +%d Staub") % [fresh_n, up, dust]
func refresh() -> void:
var p: Profile = app.profile
coins_label.text = "Münzen: %d" % p.coins
b_buy.disabled = p.coins < Profile.BOOSTER_COST
b_all.disabled = cards.is_empty() or revealed.size() >= Profile.BOOSTER_SIZE
var open: Array = p.unlocked_elements()
var x0 := (1280 - open.size() * 220 - (open.size() - 1) * 10) / 2
for e in pack_tiles:
var tile: PackTile = pack_tiles[e]
tile.locked = not p.booster_unlocked(e)
tile.visible = not tile.locked
if not tile.locked:
tile.position = Vector2(x0 + open.find(e) * 230, 644)
tile.hint = tr("%d Münzen") % p.pack_price(e)
for k in QUANTITIES.size():
tile.buy_buttons[k].disabled = tile.locked or p.coins < p.pack_price(e) * QUANTITIES[k]
tile.pulse = e == last_pack and not cards.is_empty() and revealed.size() >= cards.size()
tile.queue_redraw()
b_all.disabled = cards.is_empty() or revealed.size() >= cards.size()
summary_label.text = summary()
Style.clear(slots)
for i in Profile.BOOSTER_SIZE:
var single := cards.size() <= Profile.BOOSTER_SIZE
var per_row := 5 if single else 10
var n := maxi(cards.size(), Profile.BOOSTER_SIZE)
var rows := (n + per_row - 1) / per_row
slots.custom_minimum_size = Vector2(AREA.size.x, maxf(AREA.size.y, slot_rect(n - 1).end.y + 60))
for i in n:
var r := slot_rect(i)
if cards.is_empty():
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
@@ -142,16 +260,27 @@ func refresh() -> void:
slots.add_child(v)
if fresh.has(i):
flip(v, fresh[i], res.rarity)
Style.label(slots, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
var fs := 16 if single else 12
Style.label(slots, tr(res.rarity), fs, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x - 10, r.end.y + 6, r.size.x + 20, 20), "center", true)
var y2 := r.end.y + (30 if single else 24)
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)
Style.label(slots, "Neu!", fs, Style.GOLD, Rect2(r.position.x, y2, r.size.x, 20), "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", fs, Style.GREEN, Rect2(r.position.x - 10, y2, r.size.x + 20, 20), "center", true)
else:
Style.label(slots, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
Style.label(slots, "+%d Staub" % res.dust, fs - 2, Style.MUTED, Rect2(r.position.x, y2, r.size.x, 20), "center")
else:
var back := CardBack.new(r.size)
back.position = r.position
back.pivot_offset = r.size / 2
back.clicked.connect(reveal.bind(i))
slots.add_child(back)
if fly:
fly_in(back, r.position, i, per_row)
fly = false
fresh = {}
if rows > 0 and last_pack != "" and pack_tiles[last_pack].pulse and Style.animations:
var tile: PackTile = pack_tiles[last_pack]
var tw := tile.create_tween().set_loops(3)
tw.tween_property(tile, "scale", Vector2(1.04, 1.04), 0.35).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(tile, "scale", Vector2.ONE, 0.35).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+11 -8
View File
@@ -1,6 +1,7 @@
class_name CampaignScreen
extends Control
## Kampagne als Pfad: zehn Stationen je Kapitel, darunter die gewählte Stufe im Detail.
## Kampagne als Pfad: zehn Stationen je Kapitel (fünf Kapitel: Neutral, dann je ein Element),
## darunter die gewählte Stufe im Detail.
var app
var chapter := 1
@@ -11,7 +12,7 @@ var node_buttons: Array = []
func _ready() -> void:
var p: Profile = app.profile
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size())
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTER_ELEMENTS.size())
selected = mini(p.campaign_stage, Campaign.total() - 1)
build()
@@ -21,14 +22,16 @@ func build() -> void:
node_buttons = []
var p: Profile = app.profile
Style.header(self, "Kampagne", func(): app.show_screen(MenuScreen.new()))
var n := Campaign.STAGES.size()
var n := Campaign.STAGES_PER_CHAPTER
var x := 40
for c in Campaign.CHAPTERS.size():
var b := Style.button(self, "Kapitel %d" % (c + 1), Rect2(x, 88, 150, 38), set_chapter.bind(c + 1), Style.PRIMARY if chapter == c + 1 else null)
for c in Campaign.CHAPTER_ELEMENTS.size():
var b := Style.button(self, "Kapitel %d" % (c + 1), Rect2(x, 88, 130, 38), set_chapter.bind(c + 1), Style.PRIMARY if chapter == c + 1 else null)
b.disabled = p.campaign_stage < c * n
x += 160
x += 138
var element := Campaign.element_of(chapter)
Style.label(self, tr(element), 20, Cards.ELEMENT_COLORS[element], Rect2(0, 136, 1280, 30), "center", true)
if p.perk_unlocked():
Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(760, 96, 130, 24), "right")
Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(740, 96, 150, 24), "right")
perk_box = OptionButton.new()
perk_box.position = Vector2(900, 88)
perk_box.size = Vector2(340, 38)
@@ -109,7 +112,7 @@ func build() -> void:
func set_chapter(c: int) -> void:
chapter = c
var p: Profile = app.profile
var first := (c - 1) * Campaign.STAGES.size()
var first := (c - 1) * Campaign.STAGES_PER_CHAPTER
selected = p.campaign_stage if Campaign.chapter(mini(p.campaign_stage, Campaign.total() - 1)) == c else first
build()
+6 -4
View File
@@ -14,10 +14,10 @@ var card: Cards.Card
var border = null # Color oder null (dann Rahmen der Seltenheitsstufe)
var rarity := "Common" # nur Optik
var dim := false
var bonus := 0 # Vorschau im Kampf: Elementbonus, den die Karte offen gelegt bekäme
var lift := false # hebt sich beim Zeigen an (Hand)
var drag_enabled := false # Hand im Kampf: Drücken meldet pressed statt clicked
var drop := false # Ziel der gerade gezogenen Karte
var marked := false # Haken: die Karte ist im Stapel (Deck)
## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
@@ -92,15 +92,17 @@ func _draw() -> void:
var pic_bottom := size.y - r_stat - 4
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
Style.picture(self, card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rarity, seed)
Style.rarity_sparkle(self, rect, rarity, seed) # vor den Werten, damit nichts die Zahlen überlagert
# Unten: Angriff links, Leben rechts
Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat, Color.WHITE, shiny)
Style.stat_circle(self, Vector2(size.x - r_stat + 2, size.y - r_stat + 2), r_stat, str(card.health), Style.LIFE, f_stat, Color.WHITE, shiny)
if bonus > 0:
Style.text_outlined(self, "+%d" % bonus, 13 if big else 11, Style.GREEN, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3)
Style.card_sheen(self, rect, rarity, seed)
Style.border(self, rect, frame, 3 if big else 2, 8)
Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, card.cost, f_stat, shiny)
Style.rarity_sparkle(self, rect, rarity, seed)
if marked:
var mc := Vector2(size.x / 2, size.y - r_stat + 2)
draw_circle(mc, r_stat * 0.6, Style.GREEN)
Style.text(self, "✓", f_stat - 4, Color.WHITE, mc, "center", true)
if dim:
draw_rect(rect, Color(0, 0, 0, 0.47))
if has_focus():
+69
View File
@@ -0,0 +1,69 @@
class_name CheatScreen
extends Control
## Cheats zum Ausprobieren: Münzen, Staub, Level, Skills, Kampagnenkapitel und Sammlung direkt setzen.
## Jeder Knopf speichert sofort; oben steht der aktuelle Stand.
var app
var status_label: Label
var msg_label: Label
func _ready() -> void:
Style.header(self, "Cheats", func(): app.show_screen(MenuScreen.new()))
status_label = Style.label(self, "", 16, Style.GOLD, Rect2(40, 90, 1200, 48), "left", true)
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD
var groups := [
["Währung", [["+1.000 Münzen", func(): app.profile.coins += 1000], ["+10.000 Münzen", func(): app.profile.coins += 10000],
["+1.000 Staub", func(): app.profile.dust += 1000], ["+10.000 Staub", func(): app.profile.dust += 10000]]],
["Erfahrung", [["+1 Level", func(): app.profile.xp = Skills.xp_for_level(app.profile.player_level() + 1)],
["+5 Level", func(): app.profile.xp = Skills.xp_for_level(app.profile.player_level() + 5)],
["Skills zurücksetzen", func(): app.profile.skills = {}], ["Erfahrung auf 0", func():
app.profile.xp = 0
app.profile.skills = {}]]],
["Kampagne", [["Kapitel 2 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 10)],
["Kapitel 3 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 20)],
["Kapitel 4 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 30)],
["Kapitel 5 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 40)],
["Kampagne zurücksetzen", func(): app.profile.campaign_stage = 0]]],
["Sammlung", [["Alle Karten (Common)", func(): _give_all(0)], ["Alle Karten Ultimate Rare", func(): _give_all(4)],
["Eigene Karten +1 Stufe", func():
for id in app.profile.collection:
app.profile.collection[id] = mini(int(app.profile.collection[id]) + 1, Cards.RARITIES.size() - 1)],
["Sammlung zurücksetzen", func(): app.profile.give_start()]]],
]
var y := 160
for g in groups:
Style.label(self, g[0], 18, Style.TEXT, Rect2(40, y, 200, 30), "left", true)
var x := 250
for entry in g[1]:
var b := Style.button(self, entry[0], Rect2(x, y - 4, 190, 40), _apply.bind(entry[1]))
b.add_theme_font_size_override("font_size", 14)
x += 198
y += 70
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(40, y + 10, 1200, 24), "left")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
## Alle Karten der Sammlung geben, in Stufe level; der Stapel bleibt.
func _give_all(level: int) -> void:
for c in Cards.all():
app.profile.collection[c.id] = level
func _apply(action: Callable) -> void:
action.call()
app.save()
app.sfx.play("click")
msg_label.text = tr("Gespeichert")
refresh()
func refresh() -> void:
var p: Profile = app.profile
status_label.text = tr("Münzen %d · Staub %d · EP %d (Level %d, %d Skillpunkte frei) · Kampagne %d/%d · Sammlung %d Karten, Stapel %d") % [
p.coins, p.dust, p.xp, p.player_level(), p.skill_points(), p.campaign_stage, Campaign.total(), p.collection.size(), p.deck.size()]
+1
View File
@@ -0,0 +1 @@
uid://dluwr50n0sd17
+4 -3
View File
@@ -21,7 +21,7 @@ var b_craft: Button
func _ready() -> void:
Style.header(self, "Sammlung", func(): app.show_screen(MenuScreen.new()))
Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
var filters: Array = Cards.ELEMENTS + [Cards.NEUTRAL]
var filters: Array = app.profile.unlocked_elements() # nur Elemente offener Kapitel
for i in filters.size():
var e: String = filters[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))
@@ -60,9 +60,10 @@ func set_filter(e: String) -> void:
refresh()
## Eigene Karten zuerst (höhere Stufen vorn), dann die fehlenden, sonst in Kartenreihenfolge.
## Karten der offenen Elemente: eigene zuerst (höhere Stufen vorn), dann die fehlenden, sonst in
## Kartenreihenfolge.
func cards() -> Array:
var out: Array = Cards.all().duplicate()
var out: Array = app.profile.unlocked_cards()
if filter != "":
out = out.filter(func(c): return filter in c.elements)
if search != "":
+4 -10
View File
@@ -12,7 +12,7 @@ var width := 2
var flash := false # kurz rot: wurde gerade angegriffen
var slot: Vector2 # Zielposition im Raster
var busy := false # wird gerade animiert (kein Neupositionieren)
var matchup := 0 # Elementvorteil des gewählten Angreifers gegen dieses fremde Wesen (Game.matchup_bonus); die Werte zeigen dann, was ihn erwartet
var matchup := 0 # fremdes Wesen: sein Konter gegen den gewählten Angreifer (Game.matchup_bonus); die Werte zeigen, was ihn erwartet
var attack_shift := 0 # eigener Angreifer: Elementvorteil gegen das Ziel unter der Maus
var doomed := false # stirbt bei dem Angriff, der gerade unter der Maus liegt
var secret := false # verdecktes fremdes Wesen: nur der Rücken ist zu sehen
@@ -78,7 +78,7 @@ func _draw() -> void:
var pic_bottom := size.y - r_stat - 4
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
Style.picture(self, creature.card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rar, seed)
# Unter dem Namen links die Eigenschaften (in der Farbe des Elements, das sie gibt), rechts der Elementbonus
# Unter dem Namen links die Eigenschaften (in der Farbe des Elements, das sie gibt)
var icon := 6.0 if small else 7.0
var y := top_h + 6 + icon
for k in creature.keywords():
@@ -88,11 +88,7 @@ func _draw() -> void:
col = Style.lighter(Cards.ELEMENT_COLORS[e], 0.45)
Style.keyword_icon(self, k, Vector2(6 + icon, y), icon, col)
y += icon * 2 + 6
if creature.aura != 0:
var badge := "+%d" % creature.aura if creature.aura > 0 else "−%d" % -creature.aura
var bw := Style.FONT_BOLD.get_string_size(badge, HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(10)).x + 8
draw_rect(Rect2(size.x - bw - 4, top_h + 5, bw, 16), Color(0.05, 0.05, 0.08, 0.75))
Style.text(self, badge, 10, Style.GREEN if creature.aura > 0 else Style.RED, Vector2(size.x - 4 - bw / 2, top_h + 13), "center", true)
Style.rarity_sparkle(self, rect, rar, seed) # vor den Werten, damit nichts die Zahlen überlagert
_draw_stats(15 if small else 17, r_stat, shiny)
Style.card_sheen(self, rect, rar, seed)
Style.border(self, rect, bcol, 4 if flash else width, 6)
@@ -103,7 +99,6 @@ func _draw() -> void:
if doomed:
draw_rect(Rect2(0, size.y * 0.5 - 12, size.x, 24), Color(0.05, 0.02, 0.02, 0.8))
Style.text_outlined(self, tr("stirbt"), 14, Style.RED, Vector2(size.x / 2, size.y * 0.5), "center", 3)
Style.rarity_sparkle(self, rect, rar, seed)
_draw_drop()
@@ -128,8 +123,7 @@ func _draw_back(rect: Rect2) -> void:
## das Leben bleibt echt. Eigener Angreifer über einem Ziel: sein Angriff mit Elementvorteil.
func shown_stats() -> Array:
if matchup != 0:
var atk := maxi(0, creature.power() - matchup)
return [atk, creature.health]
return [maxi(0, creature.power() + matchup), creature.health]
if attack_shift != 0:
return [maxi(0, creature.power() + attack_shift), creature.health]
return [creature.power(), creature.health]
+190 -25
View File
@@ -1,38 +1,139 @@
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 wertvollsten eigenen Karten des gewählten Filters.
## „Stärke“ ist der mittlere Kartenwert (100 Grundkarten … 500 Ultimate Rare).
## Deck zusammenstellen: alle eigenen Karten im Raster (Filter nach Element und Mana, Stapelkarten
## zuerst, dann höchste Stufen vorn), ein Klick nimmt eine Karte in den Stapel oder wieder heraus. Stapelkarten tragen einen Haken, die anderen sind
## abgedunkelt. Rechts Stapelgröße, Stärke (mittlerer Kartenwert, 100 Grundkarten … 500 Ultimate
## Rare), Manakurve, Elementanteile und die Vorschau der gezeigten Karte. Der Stapel hält DECK_MIN
## bis DECK_MAX Karten; „Auffüllen“ nimmt die wertvollsten eigenen Karten des gewählten Filters.
const PREVIEW := Rect2(940, 362, 180, 252)
const MAX_COST := 10
var app
var filter := ""
var filter := "" # Element oder ""
var cost_filter := -1 # Kosten 0 bis 10 oder -1
var grid: CardGrid
var all_button: Button
var element_buttons := {} # Element -> Knopf mit Symbol
var cost_buttons: Array = [] # je Kosten ein Knopf
var count_label: Label
var strength_label: Label
var curve: CurveView
var share: ElementShare
var preview_holder: Control
var shown_card: Cards.Card = null # Karte in der Vorschau
var msg_label: Label
## Filterknopf mit dem Symbol eines Elements.
class ElementButton extends Button:
var element: String
func _init(e: String) -> void:
element = e
tooltip_text = tr(e)
func _draw() -> void:
Style.element_icon(self, element, size / 2, size.y * 0.6, Style.lighter(Cards.ELEMENT_COLORS[element], 0.4))
## Manakurve: ein Balken je Kosten 0 bis 10 mit der Zahl der Stapelkarten.
class CurveView extends Control:
var counts: Array = []
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_counts(c: Array) -> void:
counts = c
queue_redraw()
func _draw() -> void:
var n := 11
var gap := 4.0
var bw := (size.x - gap * (n - 1)) / n
var top := 16.0
var base := size.y - 16.0
var mx := 1
for v in counts:
mx = maxi(mx, int(v))
for i in n:
var v: int = int(counts[i]) if i < counts.size() else 0
var x := i * (bw + gap)
var h := (base - top) * v / mx
draw_rect(Rect2(x, base - 2, bw, 2), Style.PANEL_LIGHT)
if v > 0:
draw_rect(Rect2(x, base - h, bw, h), Style.BLUE)
Style.text(self, str(v), 11, Style.TEXT, Vector2(x + bw / 2, base - h - 8), "center", true)
Style.text(self, str(i), 11, Style.MUTED, Vector2(x + bw / 2, base + 8), "center")
## Elementanteile des Stapels: je Element eine farbige Kachel mit der Kartenzahl.
class ElementShare extends Control:
var counts := {}
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_counts(c: Dictionary) -> void:
counts = c
queue_redraw()
func shown() -> Array:
return ElementView.all().filter(func(e): return int(counts.get(e, 0)) > 0)
func _draw() -> void:
var items := shown()
if items.is_empty():
return
var w := size.x / items.size()
for i in items.size():
var e: String = items[i]
var col: Color = Cards.ELEMENT_COLORS[e]
var r := Rect2(i * w + 2, 0, w - 4, size.y)
draw_style_box(Style.box(Color(col, 0.25), 6, Color(col, 0.7), 1), r)
Style.text(self, "%s %d" % [tr(e), int(counts[e])], 12, Style.TEXT, r.get_center(), "center", true)
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(""))
all_button = Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
var filters: Array = Cards.ELEMENTS + [Cards.NEUTRAL]
for i in filters.size():
var e: String = filters[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)
var fb := ElementButton.new(e)
fb.position = Vector2(108 + i * 40, 86)
fb.size = Vector2(34, 34)
fb.pressed.connect(set_filter.bind(e))
Style.juice(fb)
add_child(fb)
element_buttons[e] = fb
for c in MAX_COST + 1:
var cb := Style.button(self, str(c), Rect2(330 + c * 40, 86, 36, 34), set_cost.bind(c))
cb.add_theme_font_size_override("font_size", 14)
cost_buttons.append(cb)
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)
strength_label = Style.chip(self, "", Rect2(860, 144, 340, 36), Style.GOLD)
var hint := Style.label(self, "", 15, Style.MUTED, Rect2(840, 188, 380, 66), "center")
hint.autowrap_mode = TextServer.AUTOWRAP_WORD # vor dem Text, sonst wächst die Breite auf eine Zeile
hint.text = "Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die wertvollsten Karten des Filters."
Style.button(self, "Auffüllen", Rect2(840, 262, 380, 46), fill_up)
Style.button(self, "Leeren", Rect2(840, 318, 380, 46), clear_deck, Color("5a3c28"))
msg_label = Style.label(self, "", 15, Style.RED, Rect2(840, 382, 380, 48), "center")
Style.label(self, "Manakurve", 13, Style.MUTED, Rect2(850, 190, 360, 20), "center")
curve = CurveView.new(Rect2(850, 212, 360, 100))
add_child(curve)
share = ElementShare.new(Rect2(850, 322, 360, 30))
add_child(share)
preview_holder = Control.new()
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(preview_holder)
Style.button(self, "Auffüllen", Rect2(840, 626, 380, 44), fill_up, Style.PRIMARY)
Style.button(self, "Leeren", Rect2(840, 678, 380, 40), clear_deck)
msg_label = Style.label(self, "", 14, Style.MUTED, Rect2(840, 726, 380, 48), "center")
msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD
refresh()
@@ -46,15 +147,34 @@ func set_filter(e: String) -> void:
refresh()
## Manafilter: derselbe Knopf noch einmal hebt ihn auf.
func set_cost(c: int) -> void:
cost_filter = -1 if cost_filter == c else c
refresh()
## Hebt den aktiven Filterknopf hervor.
func _mark(b: Button, active: bool, base: Color) -> void:
b.add_theme_stylebox_override("normal", Style.box(Style.lighter(base) if active else base, 8, Style.GOLD if active else Style.MUTED, 2 if active else 1))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 8, Style.GOLD if active else Style.MUTED, 2))
b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 8, Style.GOLD if active else Style.MUTED, 2))
func toggle(card: Cards.Card) -> void:
var err: String = app.profile.toggle_deck(card.id)
msg_label.text = err
say(err)
if err == "":
app.sfx.play("click")
app.save()
refresh()
## Fehler rot, sonst der Hinweis.
func say(err: String) -> void:
msg_label.text = tr(err) if err != "" else tr("Klick auf eine Karte: rein oder raus.")
msg_label.add_theme_color_override("font_color", Style.RED if err != "" else Style.MUTED)
## Füllt mit den wertvollsten eigenen Karten des gewählten Filters bis zum Höchstwert auf.
func fill_up() -> void:
var p: Profile = app.profile
@@ -72,30 +192,75 @@ func clear_deck() -> void:
refresh()
## Eigene Karten, die im Stapel zuerst, dann höchste Stufen vorn, dann nach Kosten.
## Sortierschlüssel einer Karte: im Stapel zuerst, dann höchste Stufen vorn, dann Kosten, dann ID.
func sort_key(c: Cards.Card) -> Array:
var p: Profile = app.profile
return [0 if c.id in p.deck else 1, -p.level(c.id), c.cost, c.id]
## Eigene Karten nach Element- und Manafilter, sortiert (sort_key).
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
if p.level(a.id) != p.level(b.id):
return p.level(a.id) > p.level(b.id)
return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
if cost_filter >= 0:
out = out.filter(func(c): return c.cost == cost_filter)
out.sort_custom(func(a, b): return sort_key(a) < sort_key(b))
return out
## Vorschau der gezeigten Karte in ihrer Stufe.
func show_card(card: Cards.Card) -> void:
var p: Profile = app.profile
shown_card = card
Style.clear(preview_holder)
var v := CardView.new(card.leveled(p.level(card.id)), PREVIEW.size, false, p.rarity(card.id))
v.position = PREVIEW.position
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
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)
strength_label.text = tr("Stärke %d") % DeckGen.strength(p.deck, p.collection)
_mark(all_button, filter == "", Style.PANEL_LIGHT)
var open: Array = p.unlocked_elements() # nur Elemente offener Kapitel
for e in element_buttons:
var eb: Button = element_buttons[e]
eb.visible = e in open
if eb.visible:
eb.position = Vector2(108 + open.find(e) * 40, 86)
_mark(eb, filter == e, Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
for c in cost_buttons.size():
_mark(cost_buttons[c], cost_filter == c, Style.PANEL_LIGHT)
var counts := []
counts.resize(11)
counts.fill(0)
var per_element := {}
for id in p.deck:
var c := Cards.by_id(id)
counts[clampi(c.cost, 0, 10)] += 1
per_element[c.elements[0]] = int(per_element.get(c.elements[0], 0)) + 1
curve.set_counts(counts)
share.set_counts(per_element)
if msg_label.text == "":
say("")
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)
var in_deck: bool = shown[i].id in p.deck
views[i].dim = not in_deck
views[i].marked = in_deck
views[i].hover.connect(func(c, entered):
if entered:
show_card(c))
if shown_card != null and p.owns(shown_card.id):
show_card(shown_card)
elif shown.is_empty():
Style.clear(preview_holder)
else:
show_card(shown[0])
+10 -22
View File
@@ -1,14 +1,12 @@
class_name ElementView
extends Control
## Elementzähler einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente da, bei denen
## etwas passiert ist: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je
## offenem Wesen, beide: Effekt aktiv; Neutral hat keinen), die Zahl der ausgespielten Karten
## und ab Game.ELEMENT_COUNT der Bonus +1/+1.
## Elementpaare einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente mit offenen Wesen
## da: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je offenem Wesen, beide:
## Effekt aktiv; Neutral hat keinen) und die Zahl der offenen Wesen.
const ROW := 32.0
var own := {} # offene eigene Wesen je Element
var played := {} # ausgespielte Karten je Element
var own := {} # offene eigene Wesen je Element
func _init(rect: Rect2) -> void:
@@ -17,7 +15,6 @@ func _init(rect: Rect2) -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
for e in all():
own[e] = 0
played[e] = 0
static func all() -> Array:
@@ -27,7 +24,6 @@ static func all() -> Array:
func update_from(_g: Game, me: Game.Player) -> void:
for e in all():
own[e] = me.element_count(e) if me != null else 0
played[e] = int(me.played.get(e, 0)) if me != null else 0
queue_redraw()
@@ -35,24 +31,18 @@ func paired(e: String) -> bool:
return Cards.ELEMENT_KEYWORD.has(e) and int(own.get(e, 0)) >= Game.ELEMENT_PAIR
func bonus(e: String) -> int:
return int(played.get(e, 0)) / Game.ELEMENT_COUNT
## Elemente, bei denen sich etwas getan hat (offene Wesen oder ausgespielte Karten).
## Elemente mit offenen Wesen.
func shown() -> Array:
return all().filter(func(e): return int(own.get(e, 0)) > 0 or int(played.get(e, 0)) > 0)
return all().filter(func(e): return int(own.get(e, 0)) > 0)
## Der Inhalt als Text (Tests, Protokoll): Element offen/gespielt, dazu Effekt und Bonus.
## Der Inhalt als Text (Tests, Protokoll): Element und offene Wesen, dazu der Effekt.
func text() -> String:
var parts: Array[String] = []
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" % [tr(e), int(own.get(e, 0))]
if paired(e):
s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e])
if bonus(e) > 0:
s += " (+%d/+%d)" % [bonus(e), bonus(e)]
parts.append(s)
return " · ".join(parts)
@@ -61,7 +51,7 @@ func _draw() -> void:
var y := ROW / 2
for e in shown():
var col: Color = Cards.ELEMENT_COLORS[e]
var active := paired(e) or bonus(e) > 0
var active := paired(e)
draw_style_box(Style.box(Color(col, 0.18 if active else 0.1), 6, Color(col, 0.6 if active else 0.0), 1 if active else 0), Rect2(0, y - ROW / 2 + 2, size.x, ROW - 4))
var px := 10.0
Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, y), "midleft", active)
@@ -71,7 +61,5 @@ func _draw() -> void:
var filled := k < int(own.get(e, 0))
Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, y), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3))
px += Game.ELEMENT_PAIR * 24 + 8
Style.text(self, str(int(played.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true)
if bonus(e) > 0:
Style.text(self, "+%d/+%d" % [bonus(e), bonus(e)], 12, Style.GREEN, Vector2(size.x - 6, y), "midright", true)
Style.text(self, str(int(own.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true)
y += ROW
+3 -3
View File
@@ -18,7 +18,7 @@ func _ready() -> void:
add_child(sfx)
profile = Profile.load_or_new(save_path)
apply_settings()
show_screen(MenuScreen.new() if profile.started() else StartDeckScreen.new())
show_screen(MenuScreen.new())
func apply_settings() -> void:
@@ -30,12 +30,12 @@ func apply_settings() -> void:
theme = Style.make_theme()
## Neues Profil: Sammlung und Fortschritt werden verworfen, das Startdeck wird neu gewählt.
## Neues Profil: Sammlung und Fortschritt werden verworfen, zurück bleiben die neutralen Karten.
func reset_profile() -> void:
profile = Profile.starter()
save()
apply_settings()
show_screen(StartDeckScreen.new())
show_screen(MenuScreen.new())
func set_language(code: String) -> void:
+12 -4
View File
@@ -1,6 +1,7 @@
class_name MenuScreen
extends Control
## Hauptmenü: links Titel und Spielarten, rechts drei Karten der eigenen Sammlung als Schaufenster.
## Hauptmenü: links Titel und Spielarten (darunter Booster, Sammlung, Skilltree, dann Einstellungen und
## Beenden), rechts drei Karten der eigenen Sammlung als Schaufenster, unten rechts die Cheats.
var app
var coins_label: Label
@@ -25,10 +26,17 @@ func _ready() -> void:
var x := 80
for entry in [["Booster öffnen", func(): app.show_screen(BoosterScreen.new())],
["Sammlung", func(): app.show_screen(CollectionScreen.new())],
["Einstellungen", func(): app.show_screen(SettingsScreen.new())],
["Skilltree", func(): app.show_screen(SkillScreen.new())]]:
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
x += 176
y += 56
x = 80
for entry in [["Einstellungen", func(): app.show_screen(SettingsScreen.new())],
["Beenden", func(): get_tree().quit()]]:
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
x += 176
var cheats := Style.button(self, "Cheats", Rect2(1120, 744, 120, 36), func(): app.show_screen(CheatScreen.new()))
cheats.add_theme_font_size_override("font_size", 14)
notice_label = Style.label(self, "", 16, Style.GREEN, Rect2(80, y + 70, 760, 60), "left")
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
_showcase(p)
@@ -55,8 +63,8 @@ func _showcase(p: Profile) -> void:
func refresh() -> void:
var p: Profile = app.profile
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d") % [
p.collection.size(), Cards.all().size(), p.campaign_stage, Campaign.total()]
coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d · Level %d") % [
p.collection.size(), p.unlocked_cards().size(), p.campaign_stage, Campaign.total(), p.player_level()]
var notes := p.take_notices()
notice_label.text = " · ".join(notes)
if not notes.is_empty():
+7 -3
View File
@@ -1,7 +1,7 @@
class_name OpponentSelectScreen
extends Control
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf
## vom gemeinsamen Stapel (siehe Game.start).
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Der Gegner spielt ein Element aus den
## offenen Kapiteln in einer Stufe relativ zu deiner Deckstärke (Profile.single_opponent).
var app
var diff_buttons := {}
@@ -46,7 +46,11 @@ func refresh() -> void:
for key in diff_buttons:
_mark(diff_buttons[key], key == p.settings.difficulty)
var d := p.difficulty()
info_label.text = tr("Gegner mit %d Leben · %d Münzen bei Sieg") % [int(d.life), int(round(Profile.REWARD_WIN * float(d.reward)))]
var parts: Array[String] = [tr("Gegnerkarten: %s") % tr(Cards.RARITIES[p.opponent_level()]), tr("Vorausschau-KI") if bool(d.smart) else tr("einfache KI")]
if bool(d.perk):
parts.append(tr("ein Merkmal"))
parts.append(tr("Münzen %s") % Style.factor(float(d.reward)))
info_label.text = " · ".join(parts)
func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void:
+70
View File
@@ -0,0 +1,70 @@
class_name SkillScreen
extends Control
## Skilltree: oben Level, Erfahrung bis zum nächsten Level und freie Skillpunkte; darunter drei
## Äste (Skills.BRANCHES) mit ihren Skills als Karten: Name, Wirkung, aktuelle und nächste Stufe
## in Zahlen (Skills.effect), Stufe und „Lernen“.
var app
var level_label: Label
var xp_label: Label
var points_label: Label
var msg_label: Label
var learn_buttons := {} # Skill-ID -> Knopf
var rank_labels := {} # Skill-ID -> Label
var now_labels := {} # Skill-ID -> Wirkung jetzt
var next_labels := {} # Skill-ID -> Wirkung der nächsten Stufe
func _ready() -> void:
Style.header(self, "Skilltree", func(): app.show_screen(MenuScreen.new()))
level_label = Style.chip(self, "", Rect2(40, 86, 220, 38))
xp_label = Style.chip(self, "", Rect2(276, 86, 400, 38), Style.BLUE)
points_label = Style.chip(self, "", Rect2(692, 86, 300, 38), Style.GREEN)
Style.label(self, "Erfahrung sind deine verdienten Münzen. Jedes Level bringt einen Skillpunkt.", 14, Style.MUTED, Rect2(40, 134, 1200, 22), "left")
for b in Skills.BRANCHES.size():
var branch: String = Skills.BRANCHES[b]
var x := 40 + b * 410
Style.panel(self, Rect2(x, 170, 390, 580))
Style.label(self, branch, 22, Style.GOLD, Rect2(x, 184, 390, 32), "center", true)
var y := 236
for id in Skills.in_branch(branch):
var sk: Dictionary = Skills.SKILLS[id]
Style.panel(self, Rect2(x + 16, y, 358, 176), Style.PANEL_LIGHT, 8)
Style.label(self, sk.name, 18, Style.TEXT, Rect2(x + 32, y + 10, 326, 26), "left", true)
Style.label(self, sk.text, 14, Style.MUTED, Rect2(x + 32, y + 38, 326, 22), "left")
now_labels[id] = Style.label(self, "", 14, Style.TEXT, Rect2(x + 32, y + 64, 326, 22), "left")
next_labels[id] = Style.label(self, "", 14, Style.GREEN, Rect2(x + 32, y + 86, 326, 22), "left")
rank_labels[id] = Style.label(self, "", 15, Style.GOLD, Rect2(x + 32, y + 126, 150, 30), "left", true)
learn_buttons[id] = Style.button(self, "Lernen", Rect2(x + 214, y + 122, 144, 40), learn.bind(id), Style.PRIMARY)
y += 192
msg_label = Style.label(self, "", 15, Style.RED, Rect2(0, 760, 1280, 24), "center")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
func learn(id: String) -> void:
var err: String = app.profile.learn(id)
msg_label.text = tr(err) if err != "" else ""
if err == "":
app.sfx.play("combo")
app.save()
refresh()
func refresh() -> void:
var p: Profile = app.profile
var lvl := p.player_level()
var base := Skills.xp_for_level(lvl)
level_label.text = tr("Level %d") % lvl
xp_label.text = tr("%d / %d EP bis Level %d") % [p.xp - base, Skills.xp_for_level(lvl + 1) - base, lvl + 1]
points_label.text = tr("Skillpunkte: %d") % p.skill_points()
for id in Skills.SKILLS:
var sk: Dictionary = Skills.SKILLS[id]
rank_labels[id].text = tr("Stufe %d / %d") % [p.skill(id), int(sk.max)]
now_labels[id].text = tr("Jetzt: %s") % Skills.effect(id, p.skill(id))
next_labels[id].text = tr("Nächste Stufe: %s") % Skills.effect(id, p.skill(id) + 1) if p.skill(id) < int(sk.max) else tr("Höchste Stufe erreicht")
learn_buttons[id].disabled = p.skill_points() <= 0 or p.skill(id) >= int(sk.max)
+1
View File
@@ -0,0 +1 @@
uid://b0dgryn2kpic5
-37
View File
@@ -1,37 +0,0 @@
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(120, 152), 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())
-1
View File
@@ -1 +0,0 @@
uid://bxl6isdrgqb2b
+10
View File
@@ -26,6 +26,11 @@ const DANGER := Color("5a2828")
## Schriftgröße mit der Einstellung „Große Schrift“.
## Faktor als Text: ×2, ×1,5 (Dezimaltrenner der Sprache).
static func factor(f: float) -> String:
return "×" + String.num(f, 1).trim_suffix(".0").replace(".", TranslationServer.translate(","))
static func fs(size: int) -> int:
return int(round(size * font_scale))
@@ -630,6 +635,11 @@ static func _glyph_plain(ci: CanvasItem, card, center: Vector2, size: float, tin
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
## Elementsymbol in einer Farbe (Filterknöpfe, Listen), Grundvariante.
static func element_icon(ci: CanvasItem, element: String, c: Vector2, size: float, col: Color) -> void:
_element_glyph(ci, element, c, size, 0, col)
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
var h := size / 2
match element:
+2 -2
View File
@@ -16,7 +16,7 @@ func _initialize() -> void:
print("Beide mit Vorausschau:")
_run(games / 2, r, false, false)
print("Kampagne (du mit Vorausschau), je Stufe:")
for stage in [0, 4, 9, 14, 19, 24, 29]:
for stage in [0, 4, 9, 19, 29, 39, 49]:
_campaign(stage, games / 4, r)
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
quit()
@@ -25,7 +25,7 @@ func _initialize() -> void:
func _campaign(stage: int, games: int, r: RandomNumberGenerator) -> void:
var wins := 0
for i in games:
var g := Game.new_battle_with(Campaign.opponent(stage), r)
var g := Game.new_battle_with(Campaign.opponent(stage), r, false, {"deck": DeckGen.element_deck(Cards.NEUTRAL)}) # du mit der Startsammlung
while g.winner == "":
AI.take_turn(g, g.current(), false)
assert(g.turn < 300, "Spiel endet nicht")
+198 -134
View File
@@ -89,6 +89,8 @@ func _initialize() -> void:
test_merkmale()
test_ki_zwei_schritte()
test_kapitel_zwei_und_merkmalwahl()
test_einzelspieler_gegner()
test_skilltree()
test_ki_zeitbudget()
test_englische_karten()
test_uebersetzung_vollstaendig()
@@ -96,20 +98,21 @@ func _initialize() -> void:
quit(1 if failed > 0 else 0)
## 150 Karten: je Element und für Neutral alle Werte von 0/1 bis 5/5, die ID verrät die Werte.
## 550 Karten: je Element und für Neutral alle Werte von 0/1 bis 10/10, die ID verrät die Werte.
func test_kartenpool() -> void:
check(Cards.all().size() == 150, "150 Karten")
check(Cards.all().size() == 550, "550 Karten")
check(Cards.all().all(func(c): return c.elements.size() == 1 and (c.elements[0] in Cards.ELEMENTS or c.elements[0] == Cards.NEUTRAL)), "jede Karte hat genau ein Element oder ist neutral")
for e in Cards.ELEMENTS + [Cards.NEUTRAL]:
check(Cards.all().filter(func(c): return c.elements == [e]).size() == 30, "30 Karten je Element: " + e)
check(Cards.all().filter(func(c): return c.elements == [e]).size() == 110, "110 Karten je Element: " + e)
var names := {}
for c in Cards.all():
var parts: PackedStringArray = c.id.split("_")
check(c.attack == int(parts[1]) and c.health == int(parts[2]) and c.attack <= 5 and c.health >= 1 and c.health <= 5, "ID passt zu den Werten: " + c.id)
check(c.attack == int(parts[1]) and c.health == int(parts[2]) and c.attack <= 10 and c.health >= 1 and c.health <= 10, "ID passt zu den Werten: " + c.id)
check(c.cost == floori((2 * c.attack + c.health) / 3.0), "Kosten = (2 · Angriff + Leben) / 3, abgerundet: " + c.id)
check(c.keywords.is_empty() and c.text() == ("Neutral." if c.is_neutral() else "") and c.name_en != "", "Karten kommen ohne Eigenschaften (außer Neutral), mit englischem Namen: " + c.id)
names[c.name] = true
check(names.size() == 150, "alle Namen verschieden")
check(names.size() == 550, "alle Namen verschieden")
check(C("f_10_10").cost == 10 and C("n_6_1").name == "Eiserner Knappe" and C("w_0_10").name == "Stiller Urleviathan", "10/10 kostet 10; neue Eigenschaften und Wesen ab 6")
check(C("n_3_2").is_neutral() and C("n_3_2").text() == "Neutral." and not C("f_3_2").is_neutral(), "neutrale Karten tragen die Eigenschaft Neutral")
check(C("f_0_2").cost == 0 and C("f_2_1").cost == 1 and C("f_3_2").cost == 2 and C("f_5_5").cost == 5 and C("e_0_4").cost == 1, "Kostenbeispiele, 0/2 kostet nichts")
check(Cards.cost_of(20, 20) == 10, "Kosten enden bei 10")
@@ -146,12 +149,13 @@ func test_mana_und_kosten() -> void:
check(h.energy == Game.START_MANA + 21, "keine Obergrenze")
h.energy = 5
check(g.play_card(h, h.hand[0]) == "" and h.energy == 5 - C("f_3_2").cost, "Ausspielen kostet die Kartenkosten")
check(h.cards_played == 1 and g.clone().human.cards_played == 1, "ausgespielte Karte gezählt, auch in der Kopie")
check(g.can_play(h, h.hand[0])[1] == "Nicht genug Mana", "Drache (5) zu teuer bei %d Mana" % h.energy)
check(Cards.fusion_value(4, 2) == 4 and Cards.fusion_value(8, 1) == 6 and Cards.fusion_value(2, 2) == 3 and Cards.fusion_value(0, 4) == 3 and Cards.fusion_value(8, 8) == 9, "Fusionswert: aufgerundete halbe Summe plus eins")
## Stapel: 40 verschiedene Karten, zwölf davon im ersten Zug bezahlbar; Kampagnengegner bekommen
## Stapel mit Kostendeckel und ab Kapitel 2 höhere Stufen; Startdecks je Element; Auffüllen.
## Zufällige Stapel; Elementstapel der Kampagne in der Stufe des Kapitels; Auffüllen.
func test_stapel() -> void:
var r := RandomNumberGenerator.new()
r.seed = 5
@@ -160,29 +164,45 @@ func test_stapel() -> void:
var uniq := {}
for id in d:
uniq[id] = true
check(d.size() == Game.DECK_MIN and uniq.size() == Game.DECK_MIN, "40 verschiedene Karten")
check(d.size() == Game.DECK_MIN and uniq.size() == Game.DECK_MIN, "30 verschiedene Karten")
check(d.filter(func(id): return Cards.by_id(id).cost <= Game.START_MANA).size() >= DeckGen.CHEAP_IN_RANDOM, "mindestens zwölf billige")
var easy := DeckGen.opponent_deck(r, 1)
check(easy.all(func(id): return Cards.by_id(id).cost <= 3), "Kapitel 1: nur bis Kosten 3")
var neutral := DeckGen.element_deck(Cards.NEUTRAL)
check(neutral.size() == 110 and neutral.all(func(id): return Cards.by_id(id).is_neutral()), "alle 110 neutralen Karten")
var start := DeckGen.curve_deck(Cards.NEUTRAL, r)
var per_cost := {}
var uniq2 := {}
for id in start:
per_cost[Cards.by_id(id).cost] = int(per_cost.get(Cards.by_id(id).cost, 0)) + 1
uniq2[id] = true
check(start.size() == Game.DECK_MIN and start.all(func(id): return Cards.by_id(id).is_neutral()) and per_cost.get(0, 0) == 2 and per_cost.get(3, 0) == 6 and per_cost.get(7, 0) == 1 and not per_cost.has(8), "Kurvenstapel: 30 Neutrale nach Manakurve (2 zu 0, 6 zu 3, 1 zu 7, nichts darüber)")
check(uniq2.size() == Game.DECK_MIN and DeckGen.curve_deck(Cards.NEUTRAL, r) != start, "nichts doppelt, jedes Mal anders gewürfelt")
var capped := DeckGen.curve_deck("Feuer", r, 3)
check(capped.size() == Game.DECK_MIN and capped.all(func(id): return Cards.by_id(id).cost <= 3 and Cards.by_id(id).elements[0] == "Feuer"), "Kostendeckel 3: 30 Feuerkarten bis Kosten 3")
var fenn := Campaign.opponent(0, r)
check(fenn.deck.size() == Game.DECK_MIN and fenn.deck.all(func(id): return Cards.by_id(id).is_neutral() and Cards.by_id(id).cost <= 3) and fenn.looks.values().all(func(l): return l == 0), "Kapitel 1, Stufe 1: 30 Neutrale bis Kosten 3 in Common")
var boss := Campaign.opponent(15, r)
check(boss.deck.size() == Game.DECK_MIN and boss.deck.all(func(id): return Cards.by_id(id).cost <= 5) and boss.looks.values().all(func(l): return l == 1), "Kapitel 2: bis Kosten 5, Stufe Rare")
for e in Cards.ELEMENTS:
var sd := DeckGen.start_deck(e)
var main := sd.filter(func(id): return Cards.by_id(id).elements[0] == e)
check(sd.size() == Game.DECK_MIN and main.size() == 25 and main.all(func(id): return Cards.by_id(id).attack <= 4), "Startdeck %s: 25 Karten des Elements bis Angriff 4" % e)
for o in Cards.ELEMENTS:
if o != e:
var others := sd.filter(func(id): return Cards.by_id(id).elements[0] == o)
check(others.size() == 5 and others.all(func(id): return Cards.by_id(id).cost <= Game.START_MANA), "Startdeck %s: fünf billige %s" % [e, o])
check(boss.deck.size() == Game.DECK_MIN and boss.deck.all(func(id): return Cards.by_id(id).elements[0] == "Feuer" and Cards.by_id(id).cost <= 6) and boss.looks.values().all(func(l): return l == 0), "Kapitel 2, Stufe 6: Feuer bis Kosten 6 in Common")
check(Campaign.stage(0).max_cost == 3 and Campaign.stage(9).max_cost == 10 and Campaign.stage(14).max_cost == 5, "Kostendeckel steigt je Stufe von 3 auf 10")
for c in Campaign.CHAPTER_ELEMENTS.size():
var e: String = Campaign.CHAPTER_ELEMENTS[c]
var o := Campaign.opponent(c * Campaign.STAGES_PER_CHAPTER + 9, r)
check(o.deck.all(func(id): return Cards.by_id(id).elements[0] == e) and o.looks.values().all(func(l): return l == maxi(0, c - 1)), "Boss von Kapitel %d: nur %s in Stufe %d" % [c + 1, e, maxi(0, c - 1)])
var filled := DeckGen.fill(["f_0_1"], ["f_0_1", "f_2_1", "e_0_4", "l_1_1", "w_5_5"], 3)
check(filled == ["f_0_1", "w_5_5", "e_0_4"], "Auffüllen ohne Stufen: teuerste zuerst, dann nach ID, nichts doppelt, bis zur Grenze")
check(filled == ["f_0_1", "e_0_4", "f_2_1"], "Auffüllen ohne Stufen: gleicher Wert, dann die billigeren, nach ID, nichts doppelt, bis zur Grenze")
var full := DeckGen.fill([], neutral, Game.DECK_MIN)
var curve_counts: Array[int] = []
curve_counts.resize(11)
curve_counts.fill(0)
for id in full:
curve_counts[Cards.by_id(id).cost] += 1
check(full.size() == Game.DECK_MIN and curve_counts == DeckGen.CURVE, "Auffüllen aus 110 Neutralen folgt der Manakurve")
filled = DeckGen.fill(["f_0_1"], ["f_0_1", "f_2_1", "e_0_4", "l_1_1", "w_5_5"], 4, {"l_1_1": 2, "e_0_4": 1, "f_2_1": 1})
check(filled == ["f_0_1", "l_1_1", "e_0_4", "f_2_1"], "Auffüllen: wertvollste zuerst, bei gleichem Wert die teuerste")
# Kartenwert: Grundkarten 100, je Stufe +100; Fusionen aus ihren Werten
check(Cards.all().all(func(c): return c.value() == 100), "jede Grundkarte ist 100 wert")
check(C("l_1_1").leveled(4).value() == 500 and C("w_4_5").leveled(2).value() == 300, "Ultimate Rare 500, Super Rare 300")
check(Cards.fused(C("f_3_2"), C("w_4_5")).value() == 100 and Cards.fused(C("f_3_2"), C("w_4_5"), 3).value() == 400, "Dampf: 100, in Stufe 3 400")
check(DeckGen.strength(["f_0_1", "l_1_1", "w_4_5"], {"l_1_1": 4, "w_4_5": 2}) == 300 and DeckGen.strength([], {}) == 0 and DeckGen.strength(DeckGen.start_deck("Feuer"), {}) == 100, "Stärke: Mittelwert 300, leer 0, Startdeck 100")
check(DeckGen.strength(["f_0_1", "l_1_1", "w_4_5"], {"l_1_1": 4, "w_4_5": 2}) == 300 and DeckGen.strength([], {}) == 0 and DeckGen.strength(DeckGen.element_deck(Cards.NEUTRAL), {}) == 100, "Stärke: Mittelwert 300, leer 0, Startsammlung 100")
## Start: die obersten fünf Karten vom eigenen Stapel, ungefiltert. Ziehen bringt eine Karte;
@@ -269,51 +289,42 @@ func test_elementvorteil() -> void:
check(Cards.matchup(["Neutral"], ["Feuer"]) == 0 and Cards.matchup(["Wasser"], ["Neutral"]) == 0, "Neutral: nie im Vorteil oder Nachteil")
var feuerritter := ["Feuer", "Neutral"]
check(Cards.matchup(feuerritter, ["Luft"]) == 1 and Cards.matchup(feuerritter, ["Wasser"]) == 0 and Cards.matchup(["Wasser"], feuerritter) == 0, "Feuerritter: Vorteil bleibt, die Schwäche ist weg")
# Ohne Stapelbonus bewirkt das Element nichts
# Schaden: im Vorteil doppelt, im Nachteil halb (aufgerundet), gegen Helden nichts
var s := setup()
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2]
var tiefen := put(h, "w_4_5") # Wasser 4/5
var wolf := put(o, "f_3_2") # Feuer 3/2
check(Game.matchup_bonus(tiefen, wolf) == 4 and Game.matchup_bonus(wolf, tiefen) == -1, "Wasser schlägt Feuer doppelt (4 → 8), Feuer kontert halb (3 → 2)")
var life: int = o.life
g.attack(tiefen, wolf)
check(wolf.health <= 0 and o.life == life and tiefen.health == 5 - 3, "Wasser gegen Feuer ohne Stapelbonus: 4 Schaden, Konter 3, nichts schlägt durch")
# Im Vorteil zählt der Stapelbonus des Ziels nicht; beim Konter fehlt dem Ziel sein Angriffsbonus
var s3 := setup()
var w3 := put(s3[1], "w_4_5")
var f3 := put(s3[2], "f_3_2")
s3[2].played["Feuer"] = 6
s3[0].apply_auras()
check(f3.aura == 2 and f3.power() == 5 and f3.max_health == 4 and Game.matchup_bonus(w3, f3) == 2 and Game.matchup_bonus(f3, w3) == -2, "Feuer mit +2/+2: Wasser schlägt 2 mehr, Feuer 2 weniger")
s3[0].attack(w3, f3)
check(f3.health <= 0 and w3.health == 5 - 3, "Wasser trifft für 4 + 2 durch den Bonus, der Konter kommt ohne Bonus: 3")
# Im Nachteil fehlt der eigene Angriffsbonus; der Konter geht durch den eigenen Lebensbonus
check(wolf.health <= 0 and o.life == life and tiefen.health == 5 - 2, "8 Schaden töten den Wolf, der Konter bringt 2, nichts schlägt durch")
# Nachteil beim Angriff: halb aufgerundet; der Konter des Ziels kommt doppelt
var s4 := setup()
var f4 := put(s4[1], "f_3_2")
var w4 := put(s4[2], "w_4_5")
s4[1].played["Feuer"] = 6
s4[0].apply_auras()
s4[0].attack(f4, w4)
check(w4.health == 5 - 3 and f4.health <= 0, "Feuer mit +2/+2 greift Wasser an: schlägt ohne Bonus für 3, der Konter 4 + 2 geht durch den Bonus")
# 0 Angriff: nur der Stapelbonus des Ziels gibt Schaden
check(w4.health == 5 - 2 and f4.health <= 0, "Feuer trifft Wasser für 2 (3 halbiert, aufgerundet), der Konter 4 × 2 tötet")
# Rundung und Null
var s5 := setup()
var funke := put(s5[1], "f_0_2") # Feuer 0/2
var hauch := put(s5[2], "l_0_2") # Luft 0/2
check(s5[0].can_attack(funke, hauch)[1] == "Schwelender Kobold würde keinen Schaden machen", "0 Angriff, Ziel ohne Bonus: kein Angriff")
s5[2].played["Luft"] = 3
s5[0].apply_auras()
check(hauch.power() == 1 and hauch.health == 3 and s5[0].attack(funke, hauch) == "" and hauch.health == 2 and funke.health == 2, "Ziel mit +1/+1: 0 Angriff trifft durch den Bonus für 1, der Konter 1 − 1 bleibt aus")
var wall := put(s5[2], "e_0_4")
var geist5 := put(s5[1], "w_2_2") # Wasser 2/2
check(s5[0].attack(geist5, wall) == "" and wall.health == 2 and geist5.health == 2, "Erdhüter ohne Bonus kontert nicht")
check(s5[0].can_attack(funke, hauch)[1] == "Schwelender Kobold würde keinen Schaden machen", "0 Angriff: auch im Vorteil kein Schaden")
var wall := put(s5[2], "w_5_5")
check(Game.matchup_bonus(put(s5[1], "f_1_1"), wall) == 0 and Game.matchup_bonus(put(s5[1], "f_2_1"), wall) == -1 and Game.matchup_bonus(put(s5[1], "f_5_1"), wall) == -2, "halbiert aufgerundet: 1 bleibt 1, 2 wird 1, 5 wird 3")
check(not s5[0].can_attack(put(s5[1], "l_0_2"), put(s5[2], "e_0_4"))[0], "0 Angriff im Vorteil (Luft gegen Erde): kein Angriff")
# Helden haben kein Element
var s2 := setup()
var g2: Game = s2[0]
var geist := put(s2[1], "w_2_2")
s2[1].played["Wasser"] = 3
g2.apply_auras()
g2.attack(geist, s2[2])
check(s2[2].life == 12, "gegen Helden gibt es keinen Vorteil, nur den eigenen Bonus")
s2[0].attack(geist, s2[2])
check(s2[2].life == 15 - 2, "gegen Helden gibt es keinen Vorteil")
# Merkmal: kein Nachteil
var s6 := setup()
var f6 := put(s6[1], "f_3_2")
var w6 := put(s6[2], "w_4_5")
s6[1].perks = ["kein_nachteil"]
check(Game.matchup_bonus(f6, w6) == 0 and Game.matchup_bonus(w6, f6) == 4, "Merkmal: kein Nachteil für die eigenen Wesen, der Gegner behält seinen Vorteil")
## Verdeckt: greift nicht an, zählt nicht für die Elementboni, darf erst nach einer Runde aufgedeckt
@@ -344,9 +355,8 @@ func test_verdeckt() -> void:
check(seen.card.id == "?" and seen.attack == 2, "die KI-Kopie kennt verdeckte Wesen nicht")
## Elementboni: zwei eigene offene Wesen eines Elements geben den Sondereffekt (Erde Spott, Luft
## Flink, Wasser Regeneration, Feuer Doppelschlag); je drei ausgespielte Karten eines Elements geben
## den eigenen Wesen des Elements +1/+1. Jeder Spieler zählt für sich.
## Elementpaare: zwei eigene offene Wesen eines Elements geben den Sondereffekt (Erde Spott, Luft
## Flink, Wasser Regeneration, Feuer Doppelschlag). Jeder Spieler zählt für sich, verdeckte nicht.
func test_elementboni() -> void:
var s := setup(["f_4_3", "f_4_3"])
var g: Game = s[0]
@@ -354,39 +364,17 @@ func test_elementboni() -> void:
var o: Game.Player = s[2]
var fire := [put(h, "f_3_2"), put(h, "f_2_1")]
var bird := put(o, "l_1_1") # Luft 1/1
g.apply_auras()
check(fire[0].aura == 0 and int(h.played["Feuer"]) == 0, "nur gelegt, nicht ausgespielt: kein Bonus")
check(fire[0].has("Doppelschlag") and fire[0].attacks_per_turn() == 2 and fire[0].keywords() == ["Doppelschlag"], "zwei eigene Feuer: Doppelschlag für beide")
check(not bird.has("Flink") and bird.power() == 1 and bird.health == 1, "der Gegner zählt für sich: nichts")
var hidden_fire := put(o, "f_4_3", true, true)
g.apply_auras()
check(not hidden_fire.has("Doppelschlag") and o.element_count("Feuer") == 0, "verdeckte zählen nicht fürs Paar")
# Ausgespielte Karten zählen; je drei gibt es +1/+1 für die eigenen Wesen des Elements
h.played["Feuer"] = 2
g.play_card(h, h.hand[0]) # Flammender Wolf: die dritte Feuerkarte
check(int(h.played["Feuer"]) == 3 and fire[0].aura == 1 and fire[0].power() == 4 and fire[0].health == 3 and h.board[2].aura == 1, "drei Feuerkarten ausgespielt: +1/+1 für alle eigenen Feuerwesen")
check(bird.aura == 0 and bird.health == 1 and hidden_fire.aura == 0, "der Gegner zählt für sich: nichts")
g._flip(hidden_fire)
check(hidden_fire.aura == 0 and int(o.played["Feuer"]) == 0, "aufgedeckt, aber nie ausgespielt gezählt: kein Bonus")
g.play_card(h, h.hand[0]) # Flammender Wolf
check(h.board[2].has("Doppelschlag") and h.board[2].power() == 4 and h.board[2].health == 3, "ausgespielt: Werte wie auf der Karte, dazu der Effekt")
g.play_card(h, h.hand[0], true) # zweiter Wolf verdeckt
var secret: Game.Creature = h.board.back()
check(secret.hidden and secret.aura == 0 and int(h.played["Feuer"]) == 4, "verdeckt gelegt zählt als ausgespielt, der Bonus kommt erst offen")
check(secret.hidden and not secret.has("Doppelschlag") and h.element_count("Feuer") == 3, "verdeckt gelegt: ohne Effekt, zählt nicht")
g._flip(secret)
check(secret.aura == 1 and secret.power() == 5 and secret.health == 4, "aufgedeckt kommt der Bonus")
# Vorschau
h.played["Luft"] = 2
check(g.forecast(h, C("l_1_1")) == 1 and g.forecast(h, C("w_2_2")) == 0, "Vorschau: der Hauch wäre die dritte Luftkarte und bekäme +1/+1")
# Merkmal: doppelt
h.perks = ["element_doppelt"]
g.apply_auras()
check(fire[0].aura == 2 and fire[0].power() == 5 and fire[0].max_health == 4, "Elementbonus zählt doppelt")
h.perks = []
h.played["Feuer"] = 6
g.apply_auras()
check(fire[0].aura == 2 and fire[0].power() == 5 and fire[0].max_health == 4, "sechs Feuerkarten: +2/+2")
fire[0].health = 1
h.played["Feuer"] = 0
g.apply_auras()
check(fire[0].aura == 0 and fire[0].max_health == 2 and fire[0].health == 1, "Bonus weg: nur das Höchstleben sinkt")
check(secret.has("Doppelschlag") and h.element_count("Feuer") == 4, "aufgedeckt zählt es und bekommt den Effekt")
# Die anderen Paare
var s5 := setup()
var walls := [put(s5[1], "e_0_4"), put(s5[1], "e_0_4")]
@@ -396,33 +384,22 @@ func test_elementboni() -> void:
var s6 := setup()
var geister := [put(s6[1], "w_2_2"), put(s6[1], "w_2_2")]
check(geister[0].has("Regeneration"), "zwei Wasser: Regeneration")
# Verschmolzene Wesen zählen für beide Elemente und bekommen beide Effekte und Boni
# Verschmolzene Wesen zählen für beide Elemente und bekommen beide Effekte
var s2 := setup()
var g2: Game = s2[0]
var h2: Game.Player = s2[1]
var dampf := put(h2, "f_3_2")
dampf.card = Cards.fused(C("f_3_2"), C("w_2_2"))
var fw := put(h2, "f_2_1")
var ww := put(h2, "w_2_2")
g2.apply_auras()
check(dampf.has("Doppelschlag") and dampf.has("Regeneration") and dampf.keywords() == ["Regeneration", "Doppelschlag"], "Dampf hat beide Sondereffekte")
check(fw.has("Doppelschlag") and ww.has("Regeneration"), "und zählt für beide Paare")
h2.played["Feuer"] = 3
h2.played["Wasser"] = 3
g2.apply_auras()
check(dampf.aura == 2 and fw.aura == 1 and ww.aura == 1, "drei Feuer und drei Wasser: Dampf bekommt +2/+2")
# Neutral: kein Paar, aber ein eigener Zähler
# Neutral: kein Paar
var s7 := setup(["n_2_2", "n_2_2"])
var g7: Game = s7[0]
var h7: Game.Player = s7[1]
g7.play_card(h7, h7.hand[0])
g7.play_card(h7, h7.hand[0])
check(h7.played["Neutral"] == 2 and h7.board[0].keywords().is_empty() and h7.board[0].aura == 0, "zwei Neutrale: kein Paar, der Zähler steht auf 2, noch kein Bonus")
check(g7.forecast(h7, C("n_5_5")) == 1 and g7.forecast(h7, C("f_5_5")) == 0, "Vorschau: die dritte Neutrale bringt +1/+1, nur für Neutrale")
h7.played["Neutral"] = 3
g7.apply_auras()
check(h7.board.all(func(c): return c.aura == 1 and c.health == 3), "drei Neutrale: +1/+1 für die neutralen Wesen")
check(h7.board.size() == 2 and h7.board[0].keywords().is_empty() and h7.board[0].health == 2, "zwei Neutrale: kein Paar, nichts ändert sich")
## Abwerfen: eine Handkarte loswerden, dafür 1 Mana.
func test_abwerfen() -> void:
@@ -493,7 +470,6 @@ func test_verschmelzen() -> void:
check(f.name == "Dampf" and f.attack == 5 and f.health == 5 and f.cost == 5, "Dampf: ⌈7/2⌉ + 1 = 5 Angriff und Leben, Kosten (10 + 5) / 3 = 5, die Verletzung ist weg")
check("Spott" in f.keywords and f.elements == ["Feuer", "Wasser"] and f.look == 0, "Eigenschaften und Elemente beider, Stufe Common")
check(g.play_card(h, f) == "" and h.energy == 5 and h.board[0].health == 5 and not h.board[0].ready, "ausgespielt für die neuen Kosten, unverletzt, erst nächsten Zug bereit")
check(h.played["Feuer"] == 1 and h.played["Wasser"] == 1, "zählt beim Legen für beide Elemente")
# Wesen mit Handkarte, auch verdeckt; stark plus schwach ergibt weniger als das starke
var dampf: Game.Creature = h.board[0]
var secret := put(h, "e_0_4", false, true)
@@ -639,27 +615,35 @@ func test_booster() -> void:
var r := RandomNumberGenerator.new()
r.seed = 3
for i in 50:
var rolled := Profile.roll_booster(r)
check(rolled.size() == 5, "5 Karten")
var rolled := Profile.roll_booster(r, "Feuer")
check(rolled.size() == 5 and rolled.all(func(x): return x.card.elements[0] == "Feuer"), "5 Feuerkarten")
check(rolled.any(func(x): return x.rarity != "Common"), "mindestens eine Rare")
var p := Profile.new()
p.coins = Profile.BOOSTER_COST
var got := p.open_booster(r)
check(p.coins == 0 and got.size() == 5, "Booster kostet %d" % Profile.BOOSTER_COST)
p.coins = Profile.ELEMENT_BOOSTER_COST
check(p.open_booster("Feuer", r).is_empty() and p.coins == Profile.ELEMENT_BOOSTER_COST and not p.booster_unlocked("Feuer") and p.booster_unlocked(Cards.NEUTRAL), "Feuer-Booster erst ab Kapitel 2, neutral immer")
p.campaign_stage = Campaign.STAGES_PER_CHAPTER
check(p.booster_unlocked("Feuer") and not p.booster_unlocked("Wasser"), "Kapitel 2 offen: Feuer ja, Wasser nein")
var got := p.open_booster("Feuer", r)
check(p.coins == 0 and got.size() == 5 and got.all(func(x): return x.card.elements[0] == "Feuer"), "Feuer-Booster kostet %d" % Profile.ELEMENT_BOOSTER_COST)
check(got.all(func(x): return p.owns(x.card.id) and x.card.id in p.deck), "alle fünf sind jetzt in Sammlung und Stapel")
var fresh := got.filter(func(x): return x.new)
check(not fresh.is_empty() and fresh.all(func(x): return x.dust == 0 and not x.upgraded), "neue Karten: kein Staub, kein Aufwerten")
check(p.dust == got.reduce(func(a, x): return a + int(x.dust), 0), "Doppelte werden zu Staub")
check(p.open_booster(r).is_empty(), "ohne Münzen kein Booster")
check(p.open_booster("Feuer", r).is_empty(), "ohne Münzen kein Booster")
p.coins = Profile.BOOSTER_COST
check(p.open_booster(Cards.NEUTRAL, r).size() == 5 and p.coins == 0, "neutraler Booster kostet %d" % Profile.BOOSTER_COST)
p.coins = Profile.ELEMENT_BOOSTER_COST * 3
check(p.open_boosters("Feuer", 4, r).is_empty() and p.coins == Profile.ELEMENT_BOOSTER_COST * 3, "vier Packs: zu teuer, nichts passiert")
check(p.open_boosters("Feuer", 3, r).size() == 15 and p.coins == 0, "drei Packs auf einmal: 15 Karten, alles bezahlt")
p.collection = {"f_3_2": 0}
p.deck = []
for i in Game.DECK_MAX:
p.deck.append("x%d" % i) # voll: neue Karten passen nicht mehr hinein
p.coins = Profile.BOOSTER_COST * 20
p.coins = Profile.ELEMENT_BOOSTER_COST * 20
var dust_before := p.dust
var upgraded := false
for i in 20:
for res in p.open_booster(r):
for res in p.open_booster("Feuer", r):
if res.card.id == "f_3_2" and res.upgraded:
upgraded = true
check(res.dust == Profile.DUST_DISMANTLE[Cards.RARITIES[Cards.RARITIES.find(res.rarity) - 1]] or res.dust > 0, "die alte Version wird zu Staub")
@@ -691,13 +675,11 @@ func test_booster() -> void:
check(h.hand[0].look == 3 and h.hand[0].rarity_in(h.looks) == "Ultra Rare", "die Stufe bleibt über zwei Fusionen")
## Sammlung und Deck: Start mit einem Startdeck, Herstellen für Staub, Zerlegen entfernt
## Common-Karten, der Stapel hält 40 bis 60 eigene Karten.
## Sammlung und Deck: Start mit den neutralen Karten, Herstellen für Staub, Zerlegen entfernt
## Common-Karten, der Stapel hält 30 bis 60 eigene Karten.
func test_sammlung_und_deck() -> void:
var p := Profile.starter()
check(not p.started() and p.collection.is_empty() and p.deck.is_empty(), "neues Profil: noch kein Startdeck")
p.choose_start("Feuer")
check(p.started() and p.collection.size() == Game.DECK_MIN and p.deck == DeckGen.start_deck("Feuer") and p.deck.all(func(id): return p.owns(id)), "Startdeck Feuer: 40 Karten in Sammlung und Stapel")
check(p.collection.size() == 110 and p.deck.size() == Game.DECK_MIN and p.deck.all(func(id): return p.owns(id) and p.level(id) == 0 and Cards.by_id(id).is_neutral()), "neues Profil: alle 110 Neutralen in Common in der Sammlung, 30 im Stapel")
check(p.toggle_deck("f_5_5") == "Karte nicht in der Sammlung", "fremde Karte nicht in den Stapel")
var first: String = p.deck[0]
check(p.toggle_deck(first) == "" and p.deck.size() == Game.DECK_MIN - 1, "Karte aus dem Stapel genommen")
@@ -708,7 +690,7 @@ func test_sammlung_und_deck() -> void:
p.deck = DeckGen.fill(p.deck, p.collection.keys(), Game.DECK_MAX)
var outside: Array = p.collection.keys().filter(func(id): return not (id in p.deck))
check(p.toggle_deck(outside[0]) == "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX, "60 ist voll")
p.choose_start("Feuer")
p = Profile.starter()
p.dust = Profile.CRAFT_COST - 1
check(p.craft("f_5_5") == "Nicht genug Staub (%d nötig)" % Profile.CRAFT_COST, "Herstellen braucht Staub")
p.dust = Profile.CRAFT_COST
@@ -717,7 +699,7 @@ func test_sammlung_und_deck() -> void:
var in_deck: String = p.deck[0]
check(p.dismantle(in_deck) == "" and not p.owns(in_deck) and not (in_deck in p.deck) and p.dust == Profile.DUST_DISMANTLE["Common"], "Common zerlegt: raus aus Sammlung und Stapel, Staub dafür")
var filled := p.deck_cards()
check(filled.size() == Game.DECK_MIN and filled.all(func(id): return p.owns(id)) and filled.slice(0, p.deck.size()) == p.deck and "f_5_5" in filled, "zum Kampf wird der Stapel mit eigenen Karten bis 40 aufgefüllt")
check(filled.size() == Game.DECK_MIN and filled.all(func(id): return p.owns(id)) and filled.slice(0, p.deck.size()) == p.deck, "zum Kampf wird der Stapel mit eigenen Karten bis 30 aufgefüllt")
p.deck = []
check(p.deck_cards().size() == Game.DECK_MIN, "leerer Stapel: Standard aus der Sammlung")
var d := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict())))
@@ -731,12 +713,11 @@ func test_spielstand() -> void:
if FileAccess.file_exists(path + suffix):
DirAccess.remove_absolute(abs + suffix)
var p := Profile.load_or_new(path)
check(p.coins == Profile.START_COINS and not p.started(), "Starterprofil: Startdeck noch zu wählen")
p.choose_start("Wasser")
check(p.collection.size() == Game.DECK_MIN and p.deck.size() == Game.DECK_MIN, "Startdeck gewählt")
check(p.coins == Profile.START_COINS and p.collection.size() == 110 and p.deck.size() == Game.DECK_MIN, "Starterprofil: neutrale Karten")
check(FileAccess.file_exists(path), "Datei angelegt")
p.coins = 999
p.collection["f_3_2"] = 2
p.collection["f_0_1"] = 0
p.deck = ["f_3_2", "f_0_1"]
p.save(path)
var p2 := Profile.load_or_new(path)
@@ -829,7 +810,7 @@ func test_spielstand_migration() -> void:
"collection": {"f_kobold": 2}, "decks": [], "settings": {"volume": 0.3}, "perk": "resonanz_doppelt"}
var p := Profile.from_dict(alt)
check(p.coins == 300 and p.dust == 70 and p.campaign_stage == 2, "alter Stand gelesen")
check(not p.started() and p.deck.is_empty() and p.perk == "", "alte Sammlung verworfen, Startdeck wird neu gewählt, unbekanntes Merkmal verworfen")
check(p.deck.size() == Game.DECK_MIN and p.collection.size() == 110 and p.perk == "", "alte Sammlung verworfen, neutrale Startsammlung, unbekanntes Merkmal verworfen")
check(is_equal_approx(p.settings.volume, 0.3) and p.settings.animations, "Einstellungen übernommen")
var fresh := Profile.new()
check(fresh.settings.volume == 0.0 and fresh.settings.music == 0.0, "Ton und Musik sind standardmäßig aus")
@@ -845,23 +826,26 @@ func test_spielstand_migration() -> void:
func test_kampagne() -> void:
var r := RandomNumberGenerator.new()
r.seed = 4
check(Campaign.STAGES.size() == 10, "zehn Stufen")
var boss := Campaign.opponent(4, r)
check(boss.name == "Vulkar" and boss.title == "Herr der Lava" and boss.life == 24, "Stufe 5: Vulkar mit 24 Leben")
check(Campaign.STAGES_PER_CHAPTER == 10 and Campaign.total() == 50, "fünf Kapitel mit je zehn Stufen")
var boss := Campaign.opponent(4)
check(boss.name == "Vulkar" and boss.title == "Söldnerfürst" and boss.life == 24, "Stufe 5: Vulkar mit 24 Leben")
check(boss.deck.size() == Game.DECK_MIN and boss.looks.values().all(func(l): return l == 0), "Kapitel 1: Stapel in Common")
var g := Game.new_battle_with(boss, r)
check(g.opponent == boss and g.human.life == Game.START_LIFE, "Kampagnenkampf gestartet")
check(Profile.battle_coins(false, 10, 20, 8) == 190 and Profile.battle_coins(true, 10, 20, 8) == 380, "Münzen: 5 je Zug, Karte und schnellem Zug, Sieg verdoppelt")
check(Profile.battle_coins(true, 10, 20, 8, 1.5) == 570 and Profile.battle_coins(true, 10, 20, 8, 2.0) == 760 and Profile.battle_coins(false, 5, 10, 4, 2.0) == 190, "Schwierigkeit ×1,5 und ×2")
check(Profile.DIFFICULTIES.leicht.reward == 1.0 and Profile.DIFFICULTIES.normal.reward == 1.5 and Profile.DIFFICULTIES.schwer.reward == 2.0, "Münzfaktoren 1 / 1,5 / 2")
var p := Profile.starter()
var reward := p.record_battle(true, 0)
check(p.campaign_stage == 1 and reward == Campaign.STAGES[0].reward, "Stufe 1 abgeschlossen")
reward = p.record_battle(true, 0)
check(p.campaign_stage == 1 and reward == Profile.REWARD_WIN, "Wiederholung gibt normale Belohnung")
reward = p.record_battle(false, 1)
check(p.campaign_stage == 1 and reward == Profile.REWARD_LOSS, "Niederlage schaltet nichts frei")
var reward := p.record_battle(true, 380, 0)
check(p.campaign_stage == 1 and reward == 380 + Campaign.stage(0).reward and p.coins == Profile.START_COINS + reward, "Stufe 1 abgeschlossen: Münzen plus Stufenbelohnung")
reward = p.record_battle(true, 380, 0)
check(p.campaign_stage == 1 and reward == 380, "Wiederholung nur die Münzen des Kampfs")
reward = p.record_battle(false, 190, 1)
check(p.campaign_stage == 1 and reward == 190, "Niederlage schaltet nichts frei")
p.campaign_stage = 9
p.record_battle(true, 9)
p.record_battle(true, 380, 9)
check(p.campaign_stage == 10, "Kampagne abgeschlossen")
var sim := Game.new_battle_with(Campaign.opponent(0, r), r)
var sim := Game.new_battle_with(Campaign.opponent(0), r)
var guard := 0
while sim.winner == "" and guard < 300:
AI.take_turn(sim, sim.current(), true)
@@ -918,17 +902,17 @@ func test_merkmale() -> void:
var mana := h.energy
check(g.draw_cost(h) == 0 and g.draw(h) == "" and h.energy == mana and g.draw_cost(h) == 1, "Merkmal: ein Ziehen mehr umsonst")
for perk in Game.PERKS:
check(Campaign.STAGES.any(func(st): return st.perk == perk), "Merkmal vergeben: " + perk)
check(range(Campaign.total()).any(func(i): return perk in Campaign.stage(i).perks), "Merkmal vergeben: " + perk)
var r := RandomNumberGenerator.new()
r.seed = 2
var mara: Game.Player = Game.new_battle_with(Campaign.opponent(1, r), r).opponent
var mara: Game.Player = Game.new_battle_with(Campaign.opponent(1), r).opponent
check(mara.life == 22 + 5 and mara.max_life == 27 and mara.perks == ["leben_35"], "Mara hat mit ihrem Merkmal 5 Leben mehr")
var g3 := Game.new_battle_with(Campaign.opponent(9, r), r, true)
var g3 := Game.new_battle_with(Campaign.opponent(9), r, true)
var archi: Game.Player = g3.opponent
check(g3.players[0] == g3.human, "feste Reihenfolge: Mensch beginnt")
g3.end_turn()
check(g3.current() == archi and archi.energy == Game.START_MANA + 2, "Archimagier hat im ersten Zug 2 Mana mehr")
var nyx: Game.Player = Campaign.opponent(5, r)
var nyx: Game.Player = Campaign.opponent(5)
var g4 := Game.new_battle_with(nyx, r, true)
g4.end_turn()
check(nyx.max_energy == Game.START_MANA and nyx.energy == Game.START_MANA + 1, "Nyx bekommt 1 Mana mehr je Zug")
@@ -956,17 +940,19 @@ func test_ki_zwei_schritte() -> void:
func test_kapitel_zwei_und_merkmalwahl() -> void:
check(Campaign.total() == 30 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2 and Campaign.chapter(29) == 3, "dreißig Stufen in drei Kapiteln")
check(Campaign.stage(25).perks.size() == 3 and Campaign.stage(25).name == "Nyx", "Kapitel 3 mit drei Merkmalen")
check(Campaign.total() == 50 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2 and Campaign.chapter(49) == 5, "fünfzig Stufen in fünf Kapiteln")
check(Campaign.stage(25).perks.size() == 2 and Campaign.stage(25).name == "Nyx" and Campaign.stage(25).element == "Wasser", "Kapitel 3: Wasser, zwei Merkmale")
check(Campaign.stage(45).perks.size() == 3 and Campaign.stage(40).element == "Erde" and Campaign.stage(49).reward == 1500 and Campaign.stage(10).reward == 350 and Campaign.stage(9).reward == 500, "Kapitel 5: Erde, drei Merkmale; Belohnungen 350 bis 1500, Boss 1 gibt 500")
check(Campaign.stage(0).perks == ["feuer_angriff"] and Campaign.stage(23).perks == ["ziehen_gratis", "kein_nachteil"] and Campaign.stage(49).perks == ["start_energie", "wesen_angriff", "extra_mana"], "Merkmale je drei Plätze versetzt, nichts doppelt")
for i in Campaign.total():
var st := Campaign.stage(i)
check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1))
var r := RandomNumberGenerator.new()
r.seed = 8
var archi2 := Campaign.opponent(19, r)
check(archi2.name == "Archimagier" and archi2.perks.size() == 2 and archi2.life == 28, "letzte Stufe von Kapitel 2: Archimagier mit zwei Merkmalen und 28 Leben")
check(archi2.looks.values().all(func(l): return l == 1), "Kapitel 2: seine Karten sind Rare")
check(Campaign.opponent(25, r).looks.values().all(func(l): return l == 2), "Kapitel 3: Super Rare")
var archi2 := Campaign.opponent(19)
check(archi2.name == "Archimagier" and archi2.perks.size() == 1 and archi2.life == 28, "letzte Stufe von Kapitel 2: Archimagier mit einem Merkmal und 28 Leben")
check(archi2.looks.values().all(func(l): return l == 0) and archi2.deck.all(func(id): return Cards.by_id(id).elements[0] == "Feuer"), "Kapitel 2: seine Karten sind Feuer in Common")
check(Campaign.opponent(25).looks.values().all(func(l): return l == 1) and Campaign.opponent(45).looks.values().all(func(l): return l == 3), "Kapitel 3 Rare, Kapitel 5 Ultra Rare")
var p := Profile.starter()
check(not p.perk_unlocked(), "Merkmalwahl erst nach Kapitel 1")
p.campaign_stage = 10
@@ -975,12 +961,90 @@ func test_kapitel_zwei_und_merkmalwahl() -> void:
var g := Game.new_battle(r, {"human_perks": [p.perk]})
check(g.human.perks == ["feuer_angriff"] and g.log.any(func(l): return l.begins_with("Spieler: Feuerwesen")), "eigenes Merkmal wirkt und steht im Verlauf")
p.campaign_stage = 19
p.record_battle(true, 19)
p.record_battle(true, 380, 19)
check(p.campaign_stage == 20, "Kapitel 2 abgeschlossen")
var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict())))
check(v.perk == "feuer_angriff" and v.campaign_stage == 20, "gespeichert und gelesen")
## Einzelspieler: der Gegner spielt ein offenes Element in einer Stufe relativ zu deiner Deckstärke.
func test_einzelspieler_gegner() -> void:
var r := RandomNumberGenerator.new()
r.seed = 21
var p := Profile.starter()
check(p.deck_level() == 0 and p.unlocked_elements() == [Cards.NEUTRAL] and p.unlocked_cards().size() == 110 and p.unlocked_cards().all(func(c): return c.is_neutral()), "Start: Stufe Common, nur Neutral offen, 110 Karten sichtbar")
check(Profile.DIFFICULTIES.leicht.level == -1 and Profile.DIFFICULTIES.normal.level == 0 and Profile.DIFFICULTIES.schwer.level == 1, "Versatz -1 / 0 / +1")
p.settings.difficulty = "schwer"
var o := p.single_opponent(r)
check(o.deck.size() == Game.DECK_MIN and o.deck.all(func(id): return Cards.by_id(id).is_neutral()) and o.looks.values().all(func(l): return l == 1) and o.life == Game.START_LIFE, "Schwer: 30 Neutrale, eine Stufe über dir, 30 Leben")
check(o.perks.size() == 1 and Game.PERKS.has(o.perks[0]) and o.title == Cards.NEUTRAL, "Schwer: ein zufälliges Merkmal, Element als Titel")
p.settings.difficulty = "leicht"
o = p.single_opponent(r)
check(o.looks.values().all(func(l): return l == 0) and o.perks.is_empty(), "Leicht: nicht unter Common, kein Merkmal")
for id in p.collection:
p.collection[id] = 3
check(p.deck_level() == 3 and p.opponent_level() == 2, "Ultra-Rare-Deck: Stufe 3, Leicht eine darunter")
p.settings.difficulty = "normal"
check(p.opponent_level() == 3 and p.single_opponent(r).looks.values().all(func(l): return l == 3), "Normal: deine Stufe")
for id in p.deck.slice(0, 3):
p.collection[id] = 4
check(DeckGen.strength(p.deck_cards(), p.collection) == 410 and p.deck_level() == 3, "410 rundet auf Stufe 3")
p.campaign_stage = Campaign.STAGES_PER_CHAPTER
check(p.unlocked_elements() == [Cards.NEUTRAL, "Feuer"] and p.unlocked_cards().size() == 220, "Kapitel 2 offen: Feuer dazu, 220 Karten sichtbar")
var seen := {}
for i in 30:
var e: String = Cards.by_id(p.single_opponent(r).deck[0]).elements[0]
seen[e] = true
check(seen.keys().size() == 2 and seen.has("Feuer") and seen.has(Cards.NEUTRAL), "Gegner würfelt aus Neutral und Feuer")
p.campaign_stage = Campaign.total()
check(p.unlocked_elements() == Array(Campaign.CHAPTER_ELEMENTS), "alles offen: alle fünf")
var g := Game.new_battle_with(p.single_opponent(r), r, false, {"deck": p.deck_cards(), "looks": p.looks()})
check(g.opponent.deck.size() + g.opponent.hand.size() == Game.DECK_MIN and g.opponent.hand.all(func(c): return c.look == 3), "im Kampf: 30 Karten in Stufe 3")
## Skilltree: Münzen sind Erfahrung, je Level ein Punkt, Skills wirken auf Booster, Staub, Münzen und Kampf.
func test_skilltree() -> void:
check(Skills.xp_for_level(1) == 100 and Skills.xp_for_level(2) == 300 and Skills.xp_for_level(5) == 1500 and Skills.level_for(0) == 0 and Skills.level_for(299) == 1 and Skills.level_for(300) == 2, "Level brauchen 100, 300, 600 … Erfahrung")
var p := Profile.starter()
check(p.player_level() == 0 and p.skill_points() == 0 and p.learn("packglueck") == "Kein Skillpunkt frei", "Start: Level 0, kein Punkt")
var reward := p.record_battle(true, 380)
check(reward == 380 and p.xp == 380 and p.player_level() == 2 and p.skill_points() == 2 and p.take_notices() == ["Level 2 erreicht: +2 Skillpunkte"], "380 Münzen: Level 2, zwei Punkte, Meldung")
check(p.learn("packglueck") == "" and p.skill("packglueck") == 1 and p.skill_points() == 1, "Packglück gelernt")
check(p.learn("gratisziehen") == "" and p.learn("gratisziehen") == "Schon auf der höchsten Stufe" and p.skill_points() == 0, "Freies Ziehen hat nur eine Stufe")
check(p.battle_perks() == ["ziehen_gratis"], "Kampfskill wird im Kampf zum Merkmal")
p.perk = "leben_35"
p.campaign_stage = Campaign.STAGES_PER_CHAPTER
check(p.battle_perks() == ["leben_35", "ziehen_gratis"], "das gewählte Merkmal kommt dazu")
p.xp = 100000
for i in 3:
p.learn("muenzen")
p.learn("staub")
p.learn("packpreis")
p.learn("packpreis")
check(is_equal_approx(p.coin_factor(), 1.3) and p.pack_price(Cards.NEUTRAL) == 80 and p.pack_price("Feuer") == 160 and p.dust_gain(100) == 175, "Münzsegen ×1,3, Händler −20 %, Staubsammler +75 %")
var coins := p.coins
check(p.record_battle(false, 200) == 260 and p.coins == coins + 260, "260 statt 200 Münzen")
p.coins = 80
check(p.open_booster(Cards.NEUTRAL).size() == 5 and p.coins == 0, "neutraler Booster kostet mit Händler 80")
var rr := RandomNumberGenerator.new()
rr.seed = 9
var commons := 0
var commons0 := 0
for i in 200:
commons += Profile.roll_booster(rr, Cards.NEUTRAL, 3).filter(func(x): return x.rarity == "Common").size()
commons0 += Profile.roll_booster(rr, Cards.NEUTRAL, 0).filter(func(x): return x.rarity == "Common").size()
check(commons < commons0 * 0.7, "Packglück 3: deutlich weniger Common (%d statt %d)" % [commons, commons0])
p.collection["n_0_1"] = 1
var dust := p.dust
check(p.dismantle("n_0_1") == "" and p.dust == dust + 175, "Zerlegen einer Rare mit Staubsammler 3: 175 statt 100")
var d := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict())))
check(d.xp == p.xp and d.skills == p.skills and d.skill_points() == p.skill_points(), "Erfahrung und Skills gespeichert")
var bad := Profile.from_dict({"version": Profile.SAVE_VERSION, "xp": 100, "skills": {"gibt_es_nicht": 2, "packglueck": 9}})
check(bad.skill("packglueck") == 3 and bad.skills.size() == 1 and bad.player_level() == 1, "unbekannte Skills fliegen raus, Stufen gedeckelt")
check(Skills.pack_weights(0) == PackedFloat32Array([45, 25, 15, 10, 5]) and Skills.pack_weights(3) == PackedFloat32Array([21, 34, 21, 16, 8]), "Packglück 3: Chancen 21/34/21/16/8")
check(Skills.effect("packglueck", 1) == "Chancen 37/28/17/12/6 %" and Skills.effect("packpreis", 2) == "Neutral 80, Element 160 Münzen" and Skills.effect("startmana", 1) == "erster Zug: 3 Mana" and Skills.effect("gratisziehen", 0) == "eine Karte je Zug gratis", "Wirkungstexte in Zahlen")
func test_ki_zeitbudget() -> void:
var r := RandomNumberGenerator.new()
r.seed = 12
+157 -68
View File
@@ -133,11 +133,8 @@ func screen():
return main.screen
## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist; ohne Startdeck wird
## Feuer gewählt.
## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist.
func ensure_menu() -> void:
if screen() is StartDeckScreen:
await click(find_button("Feuer wählen"))
if TranslationServer.get_locale().begins_with("en"):
main.set_language("de")
if not (screen() is MenuScreen):
@@ -188,7 +185,7 @@ func _ready() -> void:
main.sfx.muted = true # Tests bleiben still
await frames(3)
var suite := [test_start, test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen,
test_kampagne, test_sammlung, test_deck, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen]
test_kampagne, test_sammlung, test_skilltree, test_cheats, test_deck, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen]
suite += [test_kampf.bind(3), test_kampf_abbrechen]
suite.append(test_profil_zuruecksetzen)
var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_sammlung,test_kampf
@@ -204,16 +201,14 @@ func _ready() -> void:
get_tree().quit(1 if failed > 0 else 0)
## Erster Start: eines von vier Startdecks wählen (ensure_menu hat schon eins gewählt, darum frisch).
## Neues Profil: die neutralen Karten als Sammlung und Stapel, direkt ins Menü.
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")
check(screen() is MenuScreen and p.collection.size() == 110 and p.deck.size() == Game.DECK_MIN and p.deck.all(func(id): return Cards.by_id(id).is_neutral()), "neues Profil: 110 Neutrale, 30 im Stapel")
await shot("00_start")
check(Profile.load_or_new(SAVE).collection.size() == 110, "gespeichert")
func test_menu() -> void:
@@ -244,6 +239,32 @@ func test_booster() -> void:
else:
check(res.dust > 0, "sonst gibt es Staub")
check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand")
check(not scr.pack_tiles["Feuer"].visible and scr.pack_tiles[Cards.NEUTRAL].visible and scr.pack_tiles[Cards.NEUTRAL].position.x == 530, "Feuer-Pack bis Kapitel 2 unsichtbar, Neutral mittig")
check(scr.summary_label.text.ends_with("Staub") and scr.pack_tiles[Cards.NEUTRAL].pulse, "Fazit steht, das gekaufte Pack pulsiert")
var saved_coins: int = main.profile.coins
main.profile.campaign_stage = Campaign.STAGES_PER_CHAPTER
main.profile.coins = Profile.ELEMENT_BOOSTER_COST
scr.refresh()
check(scr.pack_tiles["Feuer"].visible and not scr.pack_buttons["Feuer"].disabled and "200" in scr.pack_tiles["Feuer"].hint and not scr.pack_tiles["Wasser"].visible, "Kapitel 2 offen: Feuer kaufbar, Wasser unsichtbar")
await click(scr.pack_buttons["Feuer"])
check(main.profile.coins == 0 and scr.cards.size() == 5 and scr.cards.all(func(res): return res.card.elements[0] == "Feuer"), "Feuer-Booster: fünf Feuerkarten")
await click(scr.b_all)
await shot("04b_booster_feuer")
main.profile.coins = Profile.BOOSTER_COST * 5
scr.refresh()
check(not scr.pack_tiles[Cards.NEUTRAL].buy_buttons[1].disabled and scr.pack_tiles[Cards.NEUTRAL].buy_buttons[2].disabled, "×5 bezahlbar, ×10 nicht")
await click(scr.pack_tiles[Cards.NEUTRAL].buy_buttons[1])
check(scr.cards.size() == 25 and main.profile.coins == 0 and scr.revealed.is_empty() and scr.slots.get_children().filter(func(c): return c is BoosterScreen.CardBack).size() == 25, "fünf Packs auf einmal: 25 verdeckte Karten")
check(Cards.RARITIES.find(scr.cards[0].rarity) >= Cards.RARITIES.find(scr.cards[24].rarity), "beste Stufe zuerst")
await frames(30)
await shot("04c_booster_fuenf_zu")
await click(scr.b_all)
check(scr.revealed.size() == 25 and scr.summary_label.text.begins_with(str(scr.cards.filter(func(x): return x.new).size())) and "Staub" in scr.summary_label.text, "alle aufgedeckt, Fazit zählt neu und Staub")
await frames(40)
await shot("04d_booster_fuenf_offen")
main.profile.campaign_stage = 0
main.profile.coins = saved_coins
main.save()
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
@@ -395,13 +416,13 @@ func test_gegnerauswahl() -> void:
check(find_button("Neu würfeln") == null and find_button("Feuer") == null and find_button("Starten") != null, "nur Schwierigkeit und Starten")
await click(find_button("Schwer"))
check(main.profile.settings.difficulty == "schwer" and Profile.load_or_new(SAVE).settings.difficulty == "schwer", "Schwer gewählt und gespeichert")
check(str(Profile.DIFFICULTIES.schwer.life) in sel.info_label.text and str(int(Profile.REWARD_WIN * 1.5)) in sel.info_label.text, "Leben des Gegners und Belohnung stehen dabei")
check("Rare" in sel.info_label.text and "Merkmal" in sel.info_label.text and "×2" in sel.info_label.text, "Schwer: Gegnerkarten Rare, Merkmal und Münzfaktor stehen dabei: " + sel.info_label.text)
await shot("07_gegnerauswahl")
await click(find_button("Starten"))
var hard = screen()
check(hard is BattleScreen and hard.game.players.size() == 2, "Duell gestartet")
var opp: Game.Player = hard.game.opponent
check(opp.max_life == Profile.DIFFICULTIES.schwer.life and hard.smart_ai, "Schwer: Gegner mit %d Leben, Vorausschau-KI" % Profile.DIFFICULTIES.schwer.life)
check(opp.max_life == Game.START_LIFE and hard.smart_ai and opp.perks.size() == 1 and opp.looks.values().all(func(l): return l == 1) and opp.title == Cards.NEUTRAL, "Schwer: 30 Leben, Vorausschau-KI, ein Merkmal, Neutral in Rare")
check(hard.game.human.hand.size() == Game.START_HAND and hard.hand_views.size() == Game.START_HAND, "Start: fünf Karten auf der Hand")
var mine: Array = main.profile.deck_cards()
check(hard.game.human.hand.all(func(c): return c.id in mine), "die Starthand kommt ungefiltert vom eigenen Stapel")
@@ -414,7 +435,7 @@ func test_gegnerauswahl() -> void:
await click(find_button("Starten"))
var easy = screen()
opp = easy.game.opponent
check(opp.max_life == Profile.DIFFICULTIES.leicht.life and not easy.smart_ai, "Leicht: Gegner mit %d Leben, einfache KI" % Profile.DIFFICULTIES.leicht.life)
check(opp.max_life == Game.START_LIFE and not easy.smart_ai and opp.perks.is_empty() and opp.looks.values().all(func(l): return l == 0), "Leicht: 30 Leben, einfache KI, kein Merkmal, Common")
check(easy.game.players[0].max_energy == Game.START_MANA and easy.game.human.life == Game.START_LIFE, "gleiche Regeln für beide")
await give_up()
await click(find_button("Einzelspieler"))
@@ -473,28 +494,89 @@ func test_einstellungen() -> void:
check(screen() is MenuScreen, "zurück im Menü")
## Deck: eigene Karten rein und raus, Auffüllen nach Filter, Speichern. Beginnt mit dem frischen Startdeck.
func test_deck() -> void:
main.profile.choose_start("Feuer")
## Skilltree: Level und Punkte aus der Erfahrung, Lernen verbraucht Punkte, Knöpfe folgen dem Stand.
func test_skilltree() -> void:
main.profile.xp = Skills.xp_for_level(3)
main.profile.skills = {}
main.save()
await click(find_button("Skilltree"))
var scr = screen()
check(scr is SkillScreen and scr.level_label.text == "Level 3" and scr.points_label.text == "Skillpunkte: 3", "Skilltree: Level 3, drei Punkte")
check(scr.learn_buttons.size() == Skills.SKILLS.size() and not scr.learn_buttons["packglueck"].disabled and scr.xp_label.text == "0 / 400 EP bis Level 4", "alle Skills mit Lernen-Knopf, Fortschritt bis Level 4")
check(scr.now_labels["packglueck"].text == "Jetzt: Chancen 45/25/15/10/5 %" and scr.next_labels["packglueck"].text == "Nächste Stufe: Chancen 37/28/17/12/6 %", "Wirkung jetzt und nächste Stufe in Zahlen")
await click(scr.learn_buttons["packglueck"])
check(main.profile.skill("packglueck") == 1 and scr.points_label.text == "Skillpunkte: 2" and scr.rank_labels["packglueck"].text == "Stufe 1 / 3", "Packglück gelernt, Punkt verbraucht")
await click(scr.learn_buttons["gratisziehen"])
check(scr.learn_buttons["gratisziehen"].disabled and scr.rank_labels["gratisziehen"].text == "Stufe 1 / 1" and scr.next_labels["gratisziehen"].text == "Höchste Stufe erreicht", "Freies Ziehen voll: Knopf aus")
await shot("28_skilltree")
check(Profile.load_or_new(SAVE).skill("packglueck") == 1, "gespeichert")
await click(find_button("Zurück"))
check(screen() is MenuScreen and screen().coll_label.text.ends_with("Level 3"), "zurück im Menü, Level steht dabei")
main.profile.xp = 0
main.profile.skills = {}
main.save()
## Cheats: Knöpfe setzen Münzen, Level, Kapitel und Sammlung sofort und speichern.
func test_cheats() -> void:
await click(find_button("Cheats"))
var scr = screen()
check(scr is CheatScreen and scr.status_label.text.begins_with("Münzen %d" % main.profile.coins), "Cheats geöffnet, Stand steht oben")
var coins: int = main.profile.coins
await click(find_button("+1.000 Münzen"))
check(main.profile.coins == coins + 1000 and Profile.load_or_new(SAVE).coins == coins + 1000 and scr.msg_label.text == "Gespeichert", "+1.000 Münzen, gespeichert")
await click(find_button("+1 Level"))
check(main.profile.player_level() == 1 and main.profile.skill_points() == 1, "+1 Level: ein Skillpunkt")
await click(find_button("Kapitel 3 frei"))
check(main.profile.campaign_stage == 20 and main.profile.unlocked_elements().size() == 3, "Kapitel 3 frei: Neutral, Feuer, Wasser")
await click(find_button("Alle Karten Ultimate Rare"))
check(main.profile.collection.size() == Cards.all().size() and main.profile.level("f_5_5") == 4, "alle 550 Karten in Ultimate Rare")
await shot("29_cheats")
await click(find_button("Sammlung zurücksetzen"))
check(main.profile.collection.size() == 110 and main.profile.deck.size() == Game.DECK_MIN, "Sammlung wieder am Start")
await click(find_button("Kampagne zurücksetzen"))
await click(find_button("Erfahrung auf 0"))
check(main.profile.campaign_stage == 0 and main.profile.xp == 0, "Kampagne und Erfahrung zurück")
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
## Deck: eigene Karten rein und raus, Auffüllen nach Filter, Speichern. Beginnt mit einem frischen Profil.
func test_deck() -> void:
main.reset_profile()
await frames(2)
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")
check(scr.strength_label.text == "Stärke 100", "Startdeck aus Grundkarten: Stärke 100")
check(scr.strength_label.text == "Stärke 100", "Startsammlung aus Grundkarten: Stärke 100")
p.collection[p.deck[0]] = 4
scr.refresh()
await frames(2)
check(scr.strength_label.text == "Stärke %d" % roundi((39 * 100 + 500) / 40.0), "eine Ultimate Rare hebt die Stärke: " + scr.strength_label.text)
check(scr.strength_label.text == "Stärke %d" % roundi((29 * 100 + 500) / 30.0), "eine Ultimate Rare hebt die Stärke: " + scr.strength_label.text)
check(scr.grid.views()[0].card.id == p.deck[0] and scr.grid.views()[0].rarity == "Ultimate Rare", "höchste Stufe steht vorn")
p.collection[p.deck[0]] = 0
scr.refresh()
await frames(2)
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")
check(views.size() == 110 and views.slice(0, Game.DECK_MIN).all(func(v): return not v.dim and v.marked) and views[Game.DECK_MIN].dim and not views[Game.DECK_MIN].marked and p.deck.size() == Game.DECK_MIN, "Stapelkarten vorn mit Haken, der Rest dunkel")
check(scr.curve.counts.size() == 11 and scr.curve.counts.reduce(func(a, v): return a + v, 0) == Game.DECK_MIN and scr.curve.counts[0] == 2, "Manakurve: 30 Karten verteilt, zwei zu 0 Mana")
check(scr.share.counts == {Cards.NEUTRAL: Game.DECK_MIN} and scr.share.shown() == [Cards.NEUTRAL], "Elementanteile: nur Neutral")
check(views[0].card.cost <= views[1].card.cost and views[Game.DECK_MIN].card.cost <= views[Game.DECK_MIN + 1].card.cost, "innerhalb von Stapel und Rest nach Kosten")
await click(scr.cost_buttons[3])
views = scr.grid.views()
check(scr.cost_filter == 3 and views.size() == 15 and views.all(func(v): return v.card.cost == 3), "Manafilter 3: nur die 15 Karten zu 3 Mana")
await click(scr.cost_buttons[3])
views = scr.grid.views()
check(scr.cost_filter == -1 and views.size() == 110, "derselbe Knopf hebt den Manafilter auf")
check(scr.element_buttons.size() == 5 and scr.element_buttons["Feuer"].tooltip_text == "Feuer" and not scr.element_buttons["Feuer"].visible and scr.element_buttons[Cards.NEUTRAL].visible and scr.element_buttons[Cards.NEUTRAL].position.x == 108, "Elementfilter als Symbolknöpfe, nur offene Elemente")
await hover(views[2])
check(scr.preview_holder.get_child_count() == 1 and scr.preview_holder.get_child(0).card.id == views[2].card.id, "Zeigen stellt die Karte groß in die Vorschau")
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")
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 and not v.marked), "Klick nimmt die Karte aus dem Stapel, sie wird dunkel und verliert den Haken")
check(scr.curve.counts.reduce(func(a, v): return a + v, 0) == Game.DECK_MIN - 1 and scr.msg_label.text == "Klick auf eine Karte: rein oder raus.", "Manakurve folgt, Hinweis steht")
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)
@@ -503,12 +585,21 @@ func test_deck() -> void:
await shot("19_deck")
await click(find_button("Leeren"))
check(p.deck.is_empty(), "geleert")
await click(find_button("Wasser"))
for id in ["w_0_1", "w_0_2", "w_0_3", "w_0_4", "w_0_5"]:
p.collection[id] = 0
main.profile.campaign_stage = 2 * Campaign.STAGES_PER_CHAPTER # Wasser offen
scr.refresh()
await frames(1)
check(scr.element_buttons["Wasser"].visible and scr.element_buttons["Wasser"].position.x == 108 + 2 * 40, "Kapitel 3 offen: Wasserfilter an dritter Stelle")
await click(scr.element_buttons["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")
check(scr.share.shown() == ["Wasser"] and scr.share.counts["Wasser"] == 5, "Elementanteile: fünf Wasser")
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")
check(p.deck.size() == Game.DECK_MAX and Profile.load_or_new(SAVE).deck == p.deck, "ohne Filter bis 60, gespeichert")
main.profile.campaign_stage = 0
main.save()
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
@@ -518,30 +609,38 @@ func test_sammlung() -> void:
var scr = screen()
check(scr is CollectionScreen, "Sammlung geöffnet")
var p: Profile = main.profile
check(scr.grid.views().size() == Cards.all().size(), "alle Karten 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")
check(scr.grid.views().size() == 110 and scr.grid.views().all(func(v): return v.card.is_neutral()), "nur die Karten offener Elemente: zu Beginn die 110 Neutralen")
check(find_button("Feuer") == null and find_button("Neutral") != null, "Filter nur für offene Elemente")
check(scr.grid.views().all(func(v): return not v.dim), "zu Beginn gehören dir alle gezeigten Karten")
p.dust = 5000
scr.search_box.text = "Lodernder Funke"
scr.search_box.text_changed.emit("Lodernder Funke")
scr.search_box.text = "Tapferer Knappe"
scr.search_box.text_changed.emit("Tapferer Knappe")
await frames(1)
var funke = scr.grid.views().filter(func(v): return v.card.id == "f_2_1")[0]
var funke = scr.grid.views().filter(func(v): return v.card.id == "n_2_1")[0]
await click(funke)
check(scr.selected.id == "f_2_1" and p.rarity("f_2_1") == "Common" and scr.b_upgrade.visible, "eigene Karte gewählt, noch Common")
check(scr.selected.id == "n_2_1" and p.rarity("n_2_1") == "Common" and scr.b_upgrade.visible, "eigene Karte gewählt, noch Common")
await click(scr.b_upgrade)
check(p.level("f_2_1") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["Rare"], "auf Rare aufgewertet")
check(p.level("n_2_1") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["Rare"], "auf Rare aufgewertet")
scr.search_box.text = ""
scr.search_box.text_changed.emit("")
await frames(1)
var front = scr.grid.views().filter(func(v): return v.card.id == "f_2_1")[0]
var front = scr.grid.views().filter(func(v): return v.card.id == "n_2_1")[0]
check(front.rarity == "Rare" and front.card.attack == 3 and front.card.health == 2 and scr.grid.views()[0].rarity != "Common", "die Rare zeigt +1/+1, aufgewertete stehen vorn: %s %d/%d" % [front.rarity, front.card.attack, front.card.health])
check(Profile.load_or_new(SAVE).level("f_2_1") == 1, "Sammlung gespeichert")
scr.search_box.text = "Lodernder Funke"
scr.search_box.text_changed.emit("Lodernder Funke")
check(Profile.load_or_new(SAVE).level("n_2_1") == 1, "Sammlung gespeichert")
scr.search_box.text = "Tapferer Knappe"
scr.search_box.text_changed.emit("Tapferer Knappe")
await frames(1)
var dust: int = p.dust
await click(scr.b_dismantle)
check(p.level("f_2_1") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["Rare"], "wieder zerlegt")
# Herstellen einer fehlenden Karte
check(p.level("n_2_1") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["Rare"], "wieder zerlegt")
# Herstellen einer fehlenden Karte (Feuer: erst ab Kapitel 2 zu sehen)
scr.search_box.text = ""
scr.search_box.text_changed.emit("")
main.profile.campaign_stage = Campaign.STAGES_PER_CHAPTER
scr.refresh()
await frames(1)
var owned_shown: int = scr.grid.views().filter(func(v): return not v.dim).size()
check(scr.grid.views().size() == 220 and owned_shown < 220 and scr.grid.views().slice(0, owned_shown).all(func(v): return not v.dim) and scr.grid.views().slice(owned_shown).all(func(v): return v.dim), "Kapitel 2 offen: auch Feuer zu sehen, eigene vorn, fehlende abgedunkelt")
scr.search_box.text = "Infernalischer Drache"
scr.search_box.text_changed.emit("Infernalischer Drache")
await frames(1)
@@ -551,6 +650,8 @@ func test_sammlung() -> void:
await click(scr.b_craft)
check(p.owns("f_5_5") and p.dust == dust - Profile.CRAFT_COST, "hergestellt")
await shot("20_sammlung")
main.profile.campaign_stage = 0
main.save()
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
@@ -594,9 +695,7 @@ func test_profil_zuruecksetzen() -> void:
check(not scr.confirm.visible, "abgebrochen")
await click(find_button("Profil zurücksetzen"))
await click(find_button("Ja, zurücksetzen"))
check(screen() is 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(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and main.profile.collection.size() == 110 and main.profile.campaign_stage == 0, "Profil zurückgesetzt: neutrale Startsammlung, zurück im Menü")
check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert")
@@ -745,9 +844,7 @@ func test_kampf_elemente_und_verschmelzen() -> void:
await wait_for_human(scr)
var g: Game = scr.game
g.human.board.clear()
scr.opponent().board.clear() # was die KI im ersten Zug gelegt hat, stört die Zähler
for e in Cards.ELEMENTS + [Cards.NEUTRAL]:
scr.opponent().played[e] = 0
scr.opponent().board.clear() # was die KI im ersten Zug gelegt hat, stört die Paare
var put := func(p: Game.Player, id: String, hidden: bool = false) -> Game.Creature:
var cr := Game.Creature.new(Cards.by_id(id).copy(), p, g.next_uid)
g.next_uid += 1
@@ -765,12 +862,11 @@ func test_kampf_elemente_und_verschmelzen() -> void:
g.human.hand = [Cards.by_id("f_2_1").copy(), Cards.by_id("l_1_1").copy()]
g.human.energy = 20
g.human.looks = {"w_3_3": 3}
g.apply_auras()
scr.refresh()
await frames(2)
check(scr.element_view.text() == "Feuer 2/0 (Doppelschlag) · Wasser 1/0", "Zähler: nur Elemente mit Wesen oder gespielten Karten, Feuerpaar: " + scr.element_view.text())
check(scr.opp_element_view.text() == "Luft 1/0", "die Liste des Gegners zählt nur seine offenen Wesen: " + scr.opp_element_view.text())
check(g.human.board[0].keywords() == ["Doppelschlag"] and scr.tiles[secret.uid].secret and scr.hand_views[0].bonus == 0, "Feuerpaar gibt Doppelschlag; fremdes Verdecktes zeigt den Rücken; noch kein Bonus in Sicht")
check(scr.element_view.text() == "Feuer 2 (Doppelschlag) · Wasser 1", "Liste: nur Elemente mit offenen Wesen, Feuerpaar: " + scr.element_view.text())
check(scr.opp_element_view.text() == "Luft 1", "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, "Feuerpaar gibt Doppelschlag; fremdes Verdecktes zeigt den Rücken")
# Rechtsklick auf die Handkarte: Menü mit Verdeckt legen, ohne Verschmelzen
await hover(scr.hand_views[0])
await right_click_at(scr.hand_views[0].get_global_rect().get_center())
@@ -786,7 +882,7 @@ func test_kampf_elemente_und_verschmelzen() -> void:
var mana: int = g.human.energy
await right_click_at(scr.hand_views[0].get_global_rect().get_center(), MOUSE_BUTTON_MIDDLE)
var mine_hidden: Game.Creature = g.human.board[3]
check(mine_hidden.hidden and g.human.energy == mana - 1 and scr.element_view.text().begins_with("Feuer 2/1"), "mittlere Maustaste legt verdeckt: zählt als ausgespielt, nicht als offen")
check(mine_hidden.hidden and g.human.energy == mana - 1 and scr.element_view.text().begins_with("Feuer 2 (Doppelschlag)"), "mittlere Maustaste legt verdeckt: zählt nicht als offen")
# Rechtsklick auf ein eigenes offenes Wesen: nichts zu wählen, also kein Menü
await right_click_at(scr.own_views[0].get_global_rect().get_center())
check(not scr.menu_layer.visible, "kein Menü ohne Einträge")
@@ -797,26 +893,15 @@ func test_kampf_elemente_und_verschmelzen() -> void:
check(mine_hidden.hidden and scr.msg_label.text == "Lodernder Funke kann erst im nächsten Zug aufgedeckt werden", "im Legezug nicht aufzudecken: " + scr.msg_label.text)
mine_hidden.ready = true
await click(scr.own_views[3])
check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3/1 (Doppelschlag)"), "aufgedeckt: drei offene Feuer, eine ausgespielt: " + scr.element_view.text())
check(wolf.power() == 3 and falke.power() == 3 and mine_hidden.can_attack(), "unter drei Karten kein Bonus, aufgedeckt greift sofort an")
g.human.played["Feuer"] = 3
g.apply_auras()
scr.refresh()
await frames(1)
check(scr.element_view.text().begins_with("Feuer 3/3 (Doppelschlag) (+1/+1)") and wolf.power() == 4 and falke.power() == 3, "drei Feuerkarten: +1/+1 für die eigenen Feuerwesen: " + scr.element_view.text())
check(scr.hand_views[0].bonus == 0, "die Luftkarte auf der Hand zeigt keinen Bonus")
await shot("23b_vier_feuer")
# Angriffsziel: Elementvorteil schlägt durch den Stapelbonus des Ziels, verdeckte ohne Anzeige, Held gesperrt
scr.opponent().played["Luft"] = 3
g.apply_auras()
scr.refresh()
await frames(1)
check(falke.aura == 1 and falke.power() == 4 and scr.opp_element_view.text() == "Luft 1/3 (+1/+1)", "der Gegner hat drei Luftkarten gespielt, der Falke trägt +1/+1: " + scr.opp_element_view.text())
check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3 (Doppelschlag)"), "aufgedeckt: drei offene Feuer: " + scr.element_view.text())
check(wolf.power() == 3 and falke.power() == 3 and mine_hidden.can_attack(), "Werte bleiben die der Karten, aufgedeckt greift sofort an")
await shot("23b_drei_feuer")
# Angriffsziel: Feuer gegen Luft schlägt doppelt, der Falke kontert halb; verdeckte ohne Anzeige, Held gesperrt
await click(scr.own_views[0])
check(scr.selected == wolf and scr.tiles[falke.uid].matchup == 1 and scr.tiles[secret.uid].matchup == 0, "Angreifer gewählt: +1 gegen den Falken (sein Bonus zählt nicht), nichts gegen Verdeckte")
check(scr.tiles[falke.uid].shown_stats() == [falke.power() - 1, falke.health], "der Falke zeigt seinen Konter gegen den Wolf (ohne seinen Bonus) und sein echtes Leben")
check(scr.selected == wolf and scr.tiles[falke.uid].matchup == -1 and scr.tiles[secret.uid].matchup == 0, "Angreifer gewählt: der Falke kontert halbiert, nichts gegen Verdeckte")
check(scr.tiles[falke.uid].shown_stats() == [2, falke.health], "der Falke zeigt seinen Konter gegen den Wolf: 3 halbiert, aufgerundet 2")
await hover(scr.tiles[falke.uid])
check(scr.tiles[falke.uid].doomed and not scr.own_views[0].doomed and scr.own_views[0].shown_stats()[0] == wolf.power() + 1, "über dem Falken: er stirbt, der Wolf nicht, der Wolf schlägt mit +1 zu")
check(scr.tiles[falke.uid].doomed and not scr.own_views[0].doomed and scr.own_views[0].shown_stats()[0] == wolf.power() * 2, "über dem Falken: er stirbt, der Wolf nicht, der Wolf schlägt doppelt zu")
await shot("23c_angreifer_gewaehlt")
check(not g.can_attack(wolf, scr.opponent())[0], "Held nicht angreifbar, solange Wesen stehen")
await click(scr.tiles[secret.uid])
@@ -866,7 +951,8 @@ func test_kampagne() -> void:
var scr = screen()
check(scr is CampaignScreen, "Kampagne geöffnet")
await shot("15_kampagne")
check(scr.node_buttons.filter(func(b): return b.disabled).size() == Campaign.STAGES.size() - 1 and find_button("Kämpfen") != null, "neun Stationen gesperrt, die erste offen")
check(scr.node_buttons.filter(func(b): return b.disabled).size() == Campaign.STAGES_PER_CHAPTER - 1 and find_button("Kämpfen") != null, "neun Stationen gesperrt, die erste offen")
check(find_button("Kapitel 5") != null and find_button("Kapitel 5").disabled, "fünf Kapitel, das letzte gesperrt")
await click(scr.node_buttons[3])
check(scr.selected == 0, "gesperrte Station lässt sich nicht wählen")
check(find_button("Kapitel 2").disabled and scr.perk_box == null, "Kapitel 2 und Merkmal noch gesperrt")
@@ -878,7 +964,7 @@ func test_kampagne() -> void:
await shot("16_kampagne_kampf")
await give_up()
# Kapitel 1 als abgeschlossen setzen: Kapitel 2 und Merkmalwahl
main.profile.campaign_stage = Campaign.STAGES.size()
main.profile.campaign_stage = Campaign.STAGES_PER_CHAPTER
await click(find_button("Kampagne"))
var scr2 = screen()
check(scr2.chapter == 2 and scr2.perk_box != null, "Kapitel 2 offen, Merkmal wählbar")
@@ -888,7 +974,8 @@ func test_kampagne() -> void:
await shot("27_kampagne_kapitel2")
await click(find_button("Kämpfen"))
var b2 = screen()
check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponent.perks.size() == 2 and b2.game.opponent.life == 22, "Kapitel-2-Boss mit zwei Merkmalen und 22 Leben")
check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponent.perks.size() == 1 and b2.game.opponent.life == 22, "Kapitel-2-Gegner mit einem Merkmal und 22 Leben")
check(b2.game.opponent.deck.all(func(id): return Cards.by_id(id).elements[0] == "Feuer") and b2.game.opponent.looks.values().all(func(l): return l == 0), "Kapitel 2: nur Feuer in Common")
check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf")
await give_up()
main.profile.campaign_stage = 0
@@ -934,6 +1021,8 @@ func test_kampf(shot_turn: int) -> void:
await frames(3)
await shot("09_kampf_ende")
check(scr.reward > 0 and main.profile.coins >= coins + scr.reward, "Belohnung gutgeschrieben")
check(scr.fast_turns > 0 and scr.reward == Profile.battle_coins(g.winner == "human", g.human.turns, g.human.cards_played, scr.fast_turns, main.profile.difficulty().reward), "Münzen aus Zügen, Karten und schnellen Zügen")
check(scr.detail_label.visible and tr("%d Züge") % g.human.turns in scr.detail_label.text and "= " in scr.detail_label.text, "Aufschlüsselung im Ergebnisbild")
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Belohnung gespeichert")
await click(find_button("Zurück zum Menü"))
check(screen() is MenuScreen, "zurück im Menü")
+75 -9
View File
@@ -27,7 +27,7 @@ KI-Tempo,AI speed
Animationen,Animations
Sprache,Language
Booster,Boosters
Booster kaufen (100 Münzen),Buy booster (100 coins)
%d neu · %d aufgewertet · +%d Staub,%d new · %d upgraded · +%d dust
Alle aufdecken,Reveal all
Zerlegen,Dismantle
Kämpfen,Fight
@@ -39,6 +39,9 @@ Du bist dran,Your turn
Rückgängig,Undo
Kapitel 1,Chapter 1
Kapitel 2,Chapter 2
Kapitel 3,Chapter 3
Kapitel 4,Chapter 4
Kapitel 5,Chapter 5
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
Wähle ein Angriffsziel,Choose an attack target
Abgebrochen,Cancelled
@@ -82,7 +85,63 @@ Sammlung,Collection
Verschmelzen,Fuse
Nur eigene Wesen,Only your own creatures
Nur eigene Karten,Only your own cards
Sammlung: %d von %d Karten · Kampagne: %d/%d,Collection: %d of %d cards · Campaign: %d/%d
Sammlung: %d von %d Karten · Kampagne: %d/%d · Level %d,Collection: %d of %d cards · Campaign: %d/%d · Level %d
Skilltree,Skill tree
Level %d,Level %d
%d / %d EP bis Level %d,%d / %d XP to level %d
Skillpunkte: %d,Skill points: %d
Lernen,Learn
Stufe %d / %d,Rank %d / %d
Sammeln,Collecting
Wirtschaft,Economy
Kampf,Battle
Packglück,Pack luck
Staubsammler,Dust collector
Münzsegen,Coin blessing
Händler,Merchant
Freies Ziehen,Free draw
Starker Start,Strong start
Seltenere Karten in Boostern,Rarer cards in boosters
Mehr Staub aus Doppelten und Zerlegen,More dust from duplicates and dismantling
Mehr Münzen nach jedem Kampf,More coins after every battle
Booster werden billiger,Boosters get cheaper
Das zweite Ziehen je Zug ist auch gratis,The second draw each turn is free as well
Mehr Mana im ersten Zug,More mana in the first turn
Chancen %d/%d/%d/%d/%d %%,Odds %d/%d/%d/%d/%d %%
+%d %% Staub,+%d %% dust
+%d %% Münzen,+%d %% coins
"Neutral %d, Element %d Münzen","Neutral %d, element %d coins"
zwei Karten je Zug gratis,two cards per turn for free
eine Karte je Zug gratis,one card per turn for free
erster Zug: %d Mana,first turn: %d mana
Jetzt: %s,Now: %s
Nächste Stufe: %s,Next rank: %s
Höchste Stufe erreicht,Highest rank reached
Cheats,Cheats
Währung,Currency
Erfahrung,Experience
+1.000 Münzen,"+1,000 coins"
+10.000 Münzen,"+10,000 coins"
+1.000 Staub,"+1,000 dust"
+10.000 Staub,"+10,000 dust"
+1 Level,+1 level
+5 Level,+5 levels
Skills zurücksetzen,Reset skills
Erfahrung auf 0,Experience to 0
Kapitel 2 frei,Unlock chapter 2
Kapitel 3 frei,Unlock chapter 3
Kapitel 4 frei,Unlock chapter 4
Kapitel 5 frei,Unlock chapter 5
Kampagne zurücksetzen,Reset campaign
Alle Karten (Common),All cards (Common)
Alle Karten Ultimate Rare,All cards Ultimate Rare
Eigene Karten +1 Stufe,Own cards +1 rank
Sammlung zurücksetzen,Reset collection
Gespeichert,Saved
"Münzen %d · Staub %d · EP %d (Level %d, %d Skillpunkte frei) · Kampagne %d/%d · Sammlung %d Karten, Stapel %d","Coins %d · Dust %d · XP %d (level %d, %d skill points free) · Campaign %d/%d · Collection %d cards, deck %d"
Schon auf der höchsten Stufe,Already at the highest rank
Kein Skillpunkt frei,No skill point available
Erfahrung sind deine verdienten Münzen. Jedes Level bringt einen Skillpunkt.,Experience is the coins you earn. Every level grants one skill point.
%s ist jetzt %s,%s is now %s
Alle Kapitel abgeschlossen,All chapters completed
Elemente,Elements
@@ -101,14 +160,23 @@ Nicht genug Mana,Not enough mana
Profil wirklich zurücksetzen?,Really reset your profile?
"Sammlung, Münzen und Fortschritt gehen verloren.","Collection, coins and progress are lost."
%d Leben,%d life
Gegner mit %d Leben · %d Münzen bei Sieg,Opponent with %d life · %d coins for a win
Gegnerkarten: %s,Opponent cards: %s
Vorausschau-KI,lookahead AI
einfache KI,simple AI
ein Merkmal,one perk
Münzen %s,coins %s
",","."
%d Züge,%d turns
%d Karten,%d cards
%d schnelle Züge,%d fast turns
Sieg ×2,Victory ×2
Kampagne +%d,Campaign +%d
Start gegen die KI,Start against the AI
Ziehen,Draw
Verdeckt legen ◆ %d,Set face-down ◆ %d
Aufdecken,Reveal
verdeckt,face-down
Aufgedeckt!,Revealed!
Elementbonus +%d,Element bonus +%d
Merkmal +%d,Perk +%d
Erst die Wesen,Creatures first
%s liegt verdeckt,%s is face-down
@@ -117,7 +185,7 @@ Unbekannte Eigenschaft,Unknown trait
%s hat schon %s,%s already has %s
Wesen kommen mit +1 Angriff,Creatures enter with +1 attack
Hat im ersten Zug 2 Mana mehr,Has 2 extra mana on the first turn
Elementbonus zählt doppelt,Element bonus counts twice
Wesen haben keinen Elementnachteil,Creatures have no element weakness
Beginnt mit 5 Leben mehr,Starts with 5 extra life
Greift zweimal je Zug,Attacks twice per turn
Verdeckt,Face-down
@@ -162,10 +230,8 @@ 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 wertvollsten Karten des Filters.,Click adds a card to the deck or removes it. Fill up takes the most valuable 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
Klick auf eine Karte: rein oder raus.,Click a card: in or out.
Manakurve,Mana curve
Optionen,Options
Hauptmenü,Main menu
Neutral,Neutral
1 keys en
27 Animationen Animations
28 Sprache Language
29 Booster Boosters
30 Booster kaufen (100 Münzen) %d neu · %d aufgewertet · +%d Staub Buy booster (100 coins) %d new · %d upgraded · +%d dust
31 Alle aufdecken Reveal all
32 Zerlegen Dismantle
33 Kämpfen Fight
39 Rückgängig Undo
40 Kapitel 1 Chapter 1
41 Kapitel 2 Chapter 2
42 Kapitel 3 Chapter 3
43 Kapitel 4 Chapter 4
44 Kapitel 5 Chapter 5
45 Münzen: %d · Staub: %d Coins: %d · Dust: %d
46 Wähle ein Angriffsziel Choose an attack target
47 Abgebrochen Cancelled
85 Verschmelzen Fuse
86 Nur eigene Wesen Only your own creatures
87 Nur eigene Karten Only your own cards
88 Sammlung: %d von %d Karten · Kampagne: %d/%d Sammlung: %d von %d Karten · Kampagne: %d/%d · Level %d Collection: %d of %d cards · Campaign: %d/%d Collection: %d of %d cards · Campaign: %d/%d · Level %d
89 Skilltree Skill tree
90 Level %d Level %d
91 %d / %d EP bis Level %d %d / %d XP to level %d
92 Skillpunkte: %d Skill points: %d
93 Lernen Learn
94 Stufe %d / %d Rank %d / %d
95 Sammeln Collecting
96 Wirtschaft Economy
97 Kampf Battle
98 Packglück Pack luck
99 Staubsammler Dust collector
100 Münzsegen Coin blessing
101 Händler Merchant
102 Freies Ziehen Free draw
103 Starker Start Strong start
104 Seltenere Karten in Boostern Rarer cards in boosters
105 Mehr Staub aus Doppelten und Zerlegen More dust from duplicates and dismantling
106 Mehr Münzen nach jedem Kampf More coins after every battle
107 Booster werden billiger Boosters get cheaper
108 Das zweite Ziehen je Zug ist auch gratis The second draw each turn is free as well
109 Mehr Mana im ersten Zug More mana in the first turn
110 Chancen %d/%d/%d/%d/%d %% Odds %d/%d/%d/%d/%d %%
111 +%d %% Staub +%d %% dust
112 +%d %% Münzen +%d %% coins
113 Neutral %d, Element %d Münzen Neutral %d, element %d coins
114 zwei Karten je Zug gratis two cards per turn for free
115 eine Karte je Zug gratis one card per turn for free
116 erster Zug: %d Mana first turn: %d mana
117 Jetzt: %s Now: %s
118 Nächste Stufe: %s Next rank: %s
119 Höchste Stufe erreicht Highest rank reached
120 Cheats Cheats
121 Währung Currency
122 Erfahrung Experience
123 +1.000 Münzen +1,000 coins
124 +10.000 Münzen +10,000 coins
125 +1.000 Staub +1,000 dust
126 +10.000 Staub +10,000 dust
127 +1 Level +1 level
128 +5 Level +5 levels
129 Skills zurücksetzen Reset skills
130 Erfahrung auf 0 Experience to 0
131 Kapitel 2 frei Unlock chapter 2
132 Kapitel 3 frei Unlock chapter 3
133 Kapitel 4 frei Unlock chapter 4
134 Kapitel 5 frei Unlock chapter 5
135 Kampagne zurücksetzen Reset campaign
136 Alle Karten (Common) All cards (Common)
137 Alle Karten Ultimate Rare All cards Ultimate Rare
138 Eigene Karten +1 Stufe Own cards +1 rank
139 Sammlung zurücksetzen Reset collection
140 Gespeichert Saved
141 Münzen %d · Staub %d · EP %d (Level %d, %d Skillpunkte frei) · Kampagne %d/%d · Sammlung %d Karten, Stapel %d Coins %d · Dust %d · XP %d (level %d, %d skill points free) · Campaign %d/%d · Collection %d cards, deck %d
142 Schon auf der höchsten Stufe Already at the highest rank
143 Kein Skillpunkt frei No skill point available
144 Erfahrung sind deine verdienten Münzen. Jedes Level bringt einen Skillpunkt. Experience is the coins you earn. Every level grants one skill point.
145 %s ist jetzt %s %s is now %s
146 Alle Kapitel abgeschlossen All chapters completed
147 Elemente Elements
160 Profil wirklich zurücksetzen? Really reset your profile?
161 Sammlung, Münzen und Fortschritt gehen verloren. Collection, coins and progress are lost.
162 %d Leben %d life
163 Gegner mit %d Leben · %d Münzen bei Sieg Gegnerkarten: %s Opponent with %d life · %d coins for a win Opponent cards: %s
164 Vorausschau-KI lookahead AI
165 einfache KI simple AI
166 ein Merkmal one perk
167 Münzen %s coins %s
168 , .
169 %d Züge %d turns
170 %d Karten %d cards
171 %d schnelle Züge %d fast turns
172 Sieg ×2 Victory ×2
173 Kampagne +%d Campaign +%d
174 Start gegen die KI Start against the AI
175 Ziehen Draw
176 Verdeckt legen ◆ %d Set face-down ◆ %d
177 Aufdecken Reveal
178 verdeckt face-down
179 Aufgedeckt! Revealed!
Elementbonus +%d Element bonus +%d
180 Merkmal +%d Perk +%d
181 Erst die Wesen Creatures first
182 %s liegt verdeckt %s is face-down
185 %s hat schon %s %s already has %s
186 Wesen kommen mit +1 Angriff Creatures enter with +1 attack
187 Hat im ersten Zug 2 Mana mehr Has 2 extra mana on the first turn
188 Elementbonus zählt doppelt Wesen haben keinen Elementnachteil Element bonus counts twice Creatures have no element weakness
189 Beginnt mit 5 Leben mehr Starts with 5 extra life
190 Greift zweimal je Zug Attacks twice per turn
191 Verdeckt Face-down
230 Auffüllen Fill up
231 Leeren Clear
232 Dein Stapel ist leer Your deck is empty
233 Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die wertvollsten Karten des Filters. Klick auf eine Karte: rein oder raus. Click adds a card to the deck or removes it. Fill up takes the most valuable cards of the filter. Click a card: in or out.
234 Wähle dein Startdeck Manakurve Choose your starting deck Mana curve
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
235 Optionen Options
236 Hauptmenü Main menu
237 Neutral Neutral
Binary file not shown.