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

This commit is contained in:
Team3
2026-09-28 19:44:30 +02:00
parent dfcd3cb713
commit f0a8f18de8
37 changed files with 1588 additions and 880 deletions

View File

@@ -1,6 +1,6 @@
# Dinger
Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle mit denselben Regeln.
Kartenspiel in 2D (Godot 4.7): ein Duell gegen einen KI-Magier, beide mit denselben Regeln.
## Starten
@@ -24,20 +24,20 @@ Der Spielstand liegt unter `user://spielstand.json`
- Kein Deck und keine Starthand: jeder hat einen eigenen Laden mit fünf Angeboten. Kaufen kostet Mana, Ausspielen nichts, neu würfeln kostet 1 Mana
- Mana wächst um 1 je Zug (bis 10) und bleibt liegen, wenn du es nicht ausgibst — sparen ist eine echte Entscheidung
- Der Laden zeigt nur Karten, die höchstens dein Einkommen plus eins kosten: teure Wesen tauchen erst später auf
- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik: sie ändert weder Werte noch Text
- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik wie bei Holo-Karten: selten farbiger Rahmen, Schein und Edelstein, episch ein wandernder Lichtstreifen, legendär Funkeln, mythisch schillernde Farben; auch auf dem Feld für alle sichtbar, Werte und Text bleiben gleich
- Booster (100 Münzen): fünf gewürfelte Stufen, höhere werten die Karte auf, der Rest wird zu Staub. Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück
- Resonanz: gleiche Elemente auf dem Feld verstärken sich (+1, +2, +3, +3)
- Resonanz: gleiche Elemente auf dem Feld verstärken sich (+1, +2, +3, +3); der Bonus steht als „+n“ am Angriff, verbundene Wesen sind unter der Reihe verlinkt
- Feldkombos: ein neues Wesen neben einem Wesen mit anderem Element löst einen Effekt aus
- Karteneffekte lösen beim Ausspielen aus (Schaden, Einfrieren, Heilen, Zurückwerfen, Verstärken, Schild, Mana); Kartenvorteil ist hier Manavorteil
- Elementvorteil im Kreis (Wasser > Feuer > Luft > Erde > Wasser): 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, auch für Karteneffekte gegen Wesen
- Gemeinsames Feld: vier gespielte Karten eines Elements kippen es (Vulkan, Flut, Fels, Wirbel), Wesen dieses Elements bekommen +1 Angriff
- Gemeinsames Feld: vier gespielte Karten eines Elements kippen es (Vulkan, Flut, Fels, Wirbel), Wesen dieses Elements bekommen +1 Angriff; die Feldzeile zeigt je Element die Ladung als vier Punkte
- Vorschau: Hand- und Ladenkarten zeigen, welchen Bonus und welche Feldkombo das Ausspielen jetzt brächte; beim Zeigen ist der Kombo-Partner markiert, beim Angreifen steht der Elementvorteil (+1/−1) am Ziel
- Verschmelzen: zwei eigene Wesen werden eins (Elemente und Schlüsselwörter beider, Werte Durchschnitt plus 1, Kosten der Durchschnitt der Karten); drei oder mehr Elemente ergeben eine Chimäre
- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner; die Hand fasst 8 Karten, das Feld 5 Wesen
- Duell mit ausgeloster Zugreihenfolge, wer nicht beginnt, bekommt einmalig 1 Mana; die Hand fasst 8 Karten, das Feld 5 Wesen
- Schwierigkeit Leicht/Normal/Schwer: Gegner mit 25/30/35 Leben, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach
- KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread
- Kampagne: drei Kapitel mit je zehn Magiern, Bosse mit ein bis drei Merkmalen, eigenes Merkmal nach Kapitel 1
- Netzwerkspiel im lokalen Netz: Host führt das Spiel, Mitspieler sehen nur ihre Hand, Zuschauer keine. Fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung, die anderen verbinden neu (Platz bleibt 60 s frei)
- Unterbrochene Einzelspieler-Kämpfe (auch Kampagne) werden laufend gespeichert und lassen sich im Menü fortsetzen
- 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
@@ -56,7 +56,7 @@ DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fe
DINGER_ONLY=test_sammlung godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
DINGER_NETZ=1 godot --path . tests/ui_test.tscn # zusätzlich die langsamen Netzwerktests (Bot-Prozesse)
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Ladenangebot, Musik
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf
```
## Export
@@ -79,4 +79,6 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
- `scripts/campaign.gd` – Kampagnenstufen
- `scenes/main.tscn` – Startszene
- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden
- `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `field_view.gd` die Feldzeile, `battle_layout.gd` die Aufteilung des Kampfes
- `translations/ui.csv` – englische Oberflächentexte
- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs

View File

@@ -5,15 +5,14 @@
## Grundidee
- Kartenspiel wie zum Beispiel Magic
- PC-Spiel
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
- Duell: Spieler gegen einen KI-Gegner (oder einen Mitspieler im Netz), beide mit gleichen Regeln
- Einzelspieler-Knopf führt zur Wahl der Schwierigkeit
- Kampagne gegen benannte Magier als Fortschritt
- Im lokalen Netz zu mehreren spielbar, auch als Zuschauer
- Ein unterbrochener Kampf lässt sich später fortsetzen
- Im lokalen Netz zu zweit spielbar, weitere als Zuschauer
## Sammeln
- Jede Karte gehört jedem von Anfang an: wer zum ersten Mal spielt, kann so gut sein wie wer tausend Partien hat
- Gesammelt wird die Seltenheitsstufe einer Karte, und die ist reine Optik (auch im Netzwerkspiel sichtbar)
- Gesammelt wird die Seltenheitsstufe einer Karte, und die ist reine Optik wie bei Holo-Karten: je höher die Stufe, desto mehr Glanz (Rahmen, Schein, Lichtstreifen, Funkeln, Schillern), auch im Netzwerkspiel sichtbar
- Booster öffnen: höhere Stufen werten Karten auf, der Rest wird zu Staub
- Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück
- Kein Deckbau und kein Nachziehstapel: fünf Angebote im Laden, Kaufen kostet Mana, Ausspielen nichts
@@ -23,8 +22,8 @@
## Elemente
- Jede Karte gehört zu genau einem Element
- Kombos entstehen erst im Spiel: beim Verschmelzen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) und als Feldkombo
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus; beides ist vor dem Ausspielen auf der Karte zu sehen
- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (1 Schaden mehr im Vorteil, 1 weniger im Nachteil; Mehrfachelemente nehmen den für sie besseren Wert)
- Das Feld gehört allen am Tisch: vier gespielte Karten eines Elements kippen es, Wesen dieses Elements bekommen +1 Angriff
- Das Feld gehört beiden: vier gespielte Karten eines Elements kippen es, Wesen dieses Elements bekommen +1 Angriff
- Zwei eigene Wesen lassen sich für Energie verschmelzen: das neue erbt Elemente und Fähigkeiten beider, seine Werte sind der Durchschnitt plus 1
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus

92
design.md Normal file
View File

@@ -0,0 +1,92 @@
# Design von Dinger
Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entwurf.
## Woran wir uns orientieren
- **Hearthstone** – Kosten und Werte springen aus dem Kartenrahmen (Kristall oben links, Angriff
und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt
kurz. Auf dem Brett: eigene Seite unten, Gegner oben, Verlauf links, Zug beenden rechts, Wesen
als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt.
- **Teamfight Tactics** – der Laden ist eine Reihe von fünf großen Karten unten, Neuwürfeln steht
mit seinem Preis direkt daneben, die Bank (bei uns die Hand) liegt als schmale Reihe darüber.
Der Laden zeigt an, was zum eigenen Brett passt (bei uns: „+n“ und die Feldkombo).
- **Pokémon TCG** – Seltenheit ist Optik: Folienglanz, Funkeln, Schillern. Werte und Symbole
statt Sätze.
- **Allgemeine Regeln für Kartenspiel-Oberflächen** (GDKeys, Medium): klar getrennte Zonen,
Lesefluss von links (Vergangenheit) über die Mitte (Spiel) nach rechts (Zukunft), die eigene
Seite größer als die der Gegner, Zahlen groß, Text klein, Symbole statt Wörter, und Rückmeldung
für jede Aktion (Bewegung, Ton, kurzes Zittern).
## Karte
- Kostenkristall oben links, Bild (Elementsymbol) oben, Namensband auf 36 % Höhe, darunter
Schlüsselwörter als Symbole (Mauer = Wächter, Schild, Doppelpfeil = Flink, gekreuzte Klingen =
Doppelschlag), dann der Effekttext ohne Einleitung, unten Angriff (gelb) und Leben (rot) als
Kreise, dazwischen der Edelstein der Stufe.
- Große Karten (Vorschau, Sammlung, Booster) schreiben das Wort neben das Symbol und die
Überschrift „Beim Ausspielen“ über den Effekt. Kleine Karten zeigen nur Symbole.
- Handkacheln (96×62) sind flach: Kosten, Name, Werte, Symbole oder das Kombo-Schild. Details
kommen aus der schwebenden Vorschau.
- Seltenheit: gewöhnlich schlicht; selten Rahmen, Schein, Edelstein, stehender Lichtstreifen;
episch wandernder Lichtstreifen; legendär Funkeln; mythisch schillernde Farben.
- Der Kartentext in Daten und Sammlungssuche bleibt vollständig (`Card.text()`), die Karte
zeichnet nur `effect_text()`.
## Kampfbildschirm (1280×800)
Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
- **Laden** als fünf große Karten, links daneben „Neu würfeln“ mit Preis, rechts „Zug beenden“.
- **Hand** als flache Kacheln (die Bank), für andere verdeckt.
- **Eigenes Brett** mit fünf sichtbaren Plätzen, färbt sich mit dem aktiven Feld; links daneben
die eigene Leiste (Leben, Mana, Einkommen, Zug), rechts „Verschmelzen“ und „Rückgängig“, nur
wenn sie gerade möglich sind.
- **Feldband** mit vier Ladepunkten je Element.
- **Gegner** oben in der Mitte mit seinem Brett in voller Größe. Das Spiel ist ein Duell wie
Hearthstone: mehr als zwei Bretter passen nicht lesbar auf einen Bildschirm, und ein Gegner
macht Ziele, Texte und Balance eindeutig.
- **Vorschau** schwebt neben der Karte, auf die man zeigt (über Ladenkarten), mit Resonanz, Feld
und Feldkombo darunter.
- **Verlauf** und **Aufgeben** als kleine Knöpfe oben links; der Verlauf öffnet als Überlagerung.
- Hand- und Ladenkarten tragen „+n“ (Bonus auf dem Brett) und das Kombo-Schild; beim Zeigen ist
der Partner auf dem Brett markiert; beim Angreifen steht der Elementkonter (+1/−1) am Ziel.
- Wesen mit gleichem Element sind unter der Reihe verbunden (Resonanz).
## Bewegung und Ton
- Zugbeginn: Band „Dein Zug“ quer über die Mitte, Laden wird ausgeteilt, Ton.
- Kaufen: die Karte fliegt aus dem Laden auf die Hand, Münzton.
- Ausspielen: die Kachel fliegt von der Hand auf ihren Platz und federt ein.
- Angriff: Ausfallschritt, roter Rahmen am Ziel, kurzes Zittern des Bretts, Schadenszahl.
- Feldwechsel: Band in Elementfarbe, das Brett färbt sich.
- Karten heben sich beim Zeigen an, Knöpfe federn beim Drücken, Bildschirme blenden ein.
- Booster: Karten drehen sich auf, seltene Stufen blitzen in ihrer Farbe auf.
- Alles hängt an der Einstellung „Animationen“.
## Menüs
- Hauptmenü: Titel und drei Spielarten links, darunter Booster, Sammlung, Einstellungen,
Beenden; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und
Staub oben rechts.
- Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter.
- Kampagne als Pfad mit zehn Stationen je Kapitel, die nächste pulsiert; darunter die gewählte
Stufe mit Gegner, Leben, Belohnung und Merkmalen. Die Bosse werden über Leben und Merkmale
stärker, nicht über Begleiter.
- Sammlung: sechs Spalten, aufgewertete Karten zuerst, rechts die große Vorschau.
## Offen
- Bilder statt Elementsymbolen, wenn es einmal Kartenkunst gibt.
- Hero-Porträts für Kampagnenbosse.
- Ein Tisch mit mehreren Spielern ließe sich als TFT-Rotation obendrauf setzen: jede Runde ein
anderer Gegner, das Brett bleibt.
## Quellen
- https://hearthstone.wiki.gg/wiki/Battlefield
- https://hearthstone.fandom.com/wiki/Card
- https://moirao.me/home/tft-ui
- https://gdkeys.com/the-card-games-ui-design-of-fairtravel-battle/
- https://medium.com/@acbassettone/5-ux-ui-lessons-from-designing-a-card-game
- https://hackread.com/the-juice-factor-designing-game-feel/

View File

@@ -39,8 +39,8 @@ static func hand_value(p: Game.Player) -> float:
## Stellung aus Sicht von p: eigenes Leben, Feld, Hand und übriges Mana zählen positiv,
## die der Gegner negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden
## bremsen). Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders.
## die des Gegners negativ. Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung,
## tödliche besonders.
static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive():
return -10000.0

View File

@@ -1,47 +1,47 @@
class_name Campaign
## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Gegnern am Tisch.
## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Leben und Merkmalen.
const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "reward": 150, "perk": "waechter_leben"},
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "reward": 150, "perk": "flink_angriff"},
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "wesen_leben"},
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_mana"},
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "reward": 250, "perk": "resonanz_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "reward": 250, "perk": "schild_wesen"},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 300, "perk": "alle_flink"},
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 500, "perk": "start_energie"},
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "life": 30, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "life": 30, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "life": 31, "reward": 150, "perk": "waechter_leben"},
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "life": 31, "reward": 150, "perk": "flink_angriff"},
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "life": 32, "reward": 200, "perk": "wesen_leben"},
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 200, "perk": "extra_mana"},
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "life": 33, "reward": 250, "perk": "resonanz_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "life": 33, "reward": 250, "perk": "schild_wesen"},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 300, "perk": "alle_flink"},
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 500, "perk": "start_energie"},
]
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück.
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Leben zurück.
const STAGES2 := [
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "reward": 300, "perks": ["leben_35", "extra_mana"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]},
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]},
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_mana", "flink_angriff"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "wesen_leben"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 500, "perks": ["alle_flink", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_mana"]},
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "life": 30, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "life": 30, "reward": 300, "perks": ["leben_35", "extra_mana"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "life": 31, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]},
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "life": 31, "reward": 350, "perks": ["flink_angriff", "alle_flink"]},
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "life": 32, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 400, "perks": ["extra_mana", "flink_angriff"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "life": 33, "reward": 450, "perks": ["resonanz_doppelt", "wesen_leben"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "life": 33, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 500, "perks": ["alle_flink", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_mana"]},
]
## Kapitel 3: drei Merkmale und volle Tische.
const STAGES3 := [
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]},
{"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "extra": 3, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]},
{"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "extra": 3, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_mana"]},
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "waechter_leben"]},
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_mana", "flink_angriff", "resonanz_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 4, "reward": 900, "perks": ["resonanz_doppelt", "wesen_leben", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]},
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 4, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_mana", "leben_35"]},
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "life": 32, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "life": 32, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]},
{"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "life": 33, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]},
{"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "life": 33, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_mana"]},
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "life": 34, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "waechter_leben"]},
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "life": 34, "reward": 800, "perks": ["extra_mana", "flink_angriff", "resonanz_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "life": 35, "reward": 900, "perks": ["resonanz_doppelt", "wesen_leben", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "life": 35, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "life": 36, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]},
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "life": 36, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_mana", "leben_35"]},
]
const CHAPTERS := [STAGES, STAGES2, STAGES3]
@@ -63,14 +63,9 @@ static func stage(i: int) -> Dictionary:
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
## Gegner einer Stufe: der Boss handelt nur mit seinen Elementen, Begleiter mit allen.
static func opponents(stage_index: int) -> Array:
## Boss einer Stufe: handelt nur mit seinen Elementen, mit dem Leben und den Merkmalen der Stufe.
static func opponent(stage_index: int) -> Game.Player:
var st := stage(stage_index)
var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE
var boss := Game.Player.new(st.name, false, life, st.title, st.elements)
var boss := Game.Player.new(st.name, false, int(st.life), st.title, st.elements)
boss.perks = Array(st.perks)
var out := [boss]
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
for k in extra:
out.append(DeckGen.random_opponent(k + 1))
return out
return boss

View File

@@ -26,7 +26,7 @@ const COMBOS := {
const FIELD_COMBO_TEXT := {
"Dampf": "Friere das stärkste gegnerische Wesen ein",
"Lava": "1 Schaden an das stärkste gegnerische Wesen",
"Blitz": "2 Schaden an den schwächsten Gegner",
"Blitz": "2 Schaden an den Gegner",
"Schlamm": "Heile dich um 3",
"Sturm": "Erhalte 1 Mana",
"Sand": "Das neue Wesen erhält +1/+1",
@@ -165,9 +165,9 @@ class Effect extends RefCounted:
"all_enemy_creatures":
return tr("%s Schaden an alle gegnerischen Wesen") % n
"all_enemy_heroes":
return tr("%s Schaden an alle Gegner") % n
return tr("%s Schaden an den Gegner") % n
"all_enemies":
return tr("%s Schaden an alle Gegner und ihre Wesen") % n
return tr("%s Schaden an den Gegner und seine Wesen") % n
"heal":
return tr("Heile dich um %s") % n
"mana":
@@ -250,6 +250,15 @@ class Card extends RefCounted:
return fx.target
return ""
## Nur die Effekte (die Karte zeigt Schlüsselwörter als Symbole).
func effect_text() -> String:
if effects.is_empty():
return ""
var fx: Array[String] = []
for f in effects:
fx.append(f.text())
return ", ".join(fx) + "."
func text() -> String:
var parts: Array[String] = []
if not keywords.is_empty():

View File

@@ -31,5 +31,5 @@ static func shop_cards(r: RandomNumberGenerator, income: int, elements: Array =
## Gegner mit eigenem Laden über alle Elemente.
static func random_opponent(index: int, life: int = Game.START_LIFE) -> Game.Player:
return Game.Player.new("Gegner %d" % index, false, life, "Magier")
static func random_opponent(life: int = Game.START_LIFE) -> Game.Player:
return Game.Player.new("Gegner", false, life)

View File

@@ -9,7 +9,6 @@ const SHOP_SIZE := 5 # so viele Karten liegen im Laden
const REROLL_COST := 1
# Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben.
const START_LIFE := 30
const MAX_OPPONENTS := 5
# Merkmale der Kampagnen-Bosse (Player.perks).
const PERKS := {
@@ -18,7 +17,7 @@ const PERKS := {
"waechter_leben": "Wächter kommen mit +2 Leben",
"wesen_leben": "Wesen kommen mit +1 Leben",
"extra_mana": "Bekommt jeden Zug 1 Mana mehr",
"leben_35": "Beginnt mit 35 Leben",
"leben_35": "Beginnt mit 5 Leben mehr",
"schild_wesen": "Wesen kommen mit Schild",
"alle_flink": "Alle Wesen sind flink",
"start_energie": "Beginnt mit 3 Mana",
@@ -151,8 +150,8 @@ class Player extends RefCounted:
var human: Player # der (erste) menschliche Spieler
var humans: Array = [] # alle menschlichen Spieler (im Netz mehrere)
var opponents: Array = [] # alle außer dem ersten Menschen
var humans: Array = [] # alle menschlichen Spieler (im Netz zwei)
var opponent: Player # der andere am Tisch
var players: Array = [] # in Zugreihenfolge
var winner_player: Player = null
var rng: RandomNumberGenerator
@@ -167,10 +166,10 @@ var field_element := "" # gemeinsames Feld aller am Tisch, "" = neutral
var field_charge := {} # Element -> gespielte Karten seit dem letzten Wechsel
func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void:
func _init(h: Player, o: Player, r: RandomNumberGenerator = null) -> void:
human = h
opponents = opps
players = [h] + opps
opponent = o
players = [h, o]
humans = players.filter(func(p): return p.is_human)
if r == null:
r = make_rng()
@@ -199,11 +198,15 @@ func ref_of(target) -> Dictionary:
return {"kind": "player", "index": players.find(target)}
## Jeder gegen jeden: alle anderen lebenden Spieler sind Gegner.
## Der Gegner von p, solange er lebt (als Liste, damit Effekte einheitlich bleiben).
func enemies_of(p: Player) -> Array:
return players.filter(func(o): return o != p and o.alive())
func enemy_of(p: Player) -> Player:
return opponent if p == human else human
func find_creature(uid: int) -> Creature:
for p in players:
for c in p.board:
@@ -218,7 +221,7 @@ func clone() -> Game:
for p in players:
copies.append(p.clone())
var idx := players.find(human)
var g := Game.new(copies[idx], copies.slice(0, idx) + copies.slice(idx + 1), RandomNumberGenerator.new())
var g := Game.new(copies[idx], copies[1 - idx], RandomNumberGenerator.new())
g.players = copies # gleiche Zugreihenfolge wie das Original
g.turn = turn
g.current_idx = current_idx
@@ -483,6 +486,26 @@ static func resonance_for(partners: int) -> int:
return DeckGen.RESONANCE_STEPS[mini(partners, DeckGen.RESONANCE_STEPS.size() - 1)]
## Vorschau fürs Ausspielen: Angriffsbonus aus Resonanz und Feld sowie die Feldkombo,
## die die Karte mit dem ersten eigenen Wesen aus anderem Element auslösen würde.
func forecast(p: Player, card: Cards.Card) -> Dictionary:
var partners := 0
var combo := ""
var partner: Creature = null
for o in p.board:
if o.card.elements.any(func(e): return e in card.elements):
partners += 1
if combo == "":
for e1 in card.elements:
for e2 in o.card.elements:
if e1 != e2 and combo == "":
combo = Cards.combo_name([e1, e2])
partner = o
var resonance := resonance_for(partners) * (2 if "resonanz_doppelt" in p.perks else 1)
var field := 1 if field_element != "" and field_element in card.elements else 0
return {"resonance": resonance, "field": field, "combo": combo, "partner": partner}
## Resonanz: abnehmender Angriffsbonus je Partner mit gleichem Element (siehe resonance_for).
func update_resonance() -> void:
for p in players:
@@ -642,20 +665,19 @@ func cleanup() -> void:
update_resonance()
## Ende: nur noch ein Spieler übrig, oder kein Mensch mehr am Tisch.
## Ende: einer von beiden hat kein Leben mehr.
func check_end() -> void:
if winner != "":
return
var alive := players.filter(func(p): return p.alive())
var humans_alive := alive.filter(func(p): return p.is_human)
if humans_alive.is_empty():
winner = "opponents"
winner_player = alive[0] if alive.size() == 1 else null
say("Du wurdest besiegt." if humans.size() == 1 else "Alle Spieler sind besiegt, die KI gewinnt.")
elif alive.size() == 1:
winner = "human"
winner_player = alive[0]
say("Alle Gegner sind besiegt!" if humans.size() == 1 else "%s gewinnt!" % winner_player.name)
if alive.size() != 1:
return
winner_player = alive[0]
winner = "human" if winner_player.is_human else "opponents"
if humans.size() == 1:
say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.")
else:
say("%s gewinnt!" % winner_player.name)
# --- Zustand für das Netzwerk (siehe NetState) ------------------------------------
@@ -678,28 +700,23 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
arr[j] = tmp
## rules: {"human_perks": [...], "opponent_life": int, "looks": {...}}
static func new_battle(n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner")
## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}}
static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
if r == null:
r = make_rng()
var life: int = int(rules.get("opponent_life", START_LIFE))
var opps := []
for i in n_opp:
opps.append(DeckGen.random_opponent(i + 1, life))
return new_battle_with(opps, r, false, rules)
return new_battle_with(DeckGen.random_opponent(int(rules.get("opponent_life", START_LIFE))), r, false, rules)
static func new_battle_with(opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game:
static func new_battle_with(opp: Player, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game:
if r == null:
r = make_rng()
var human := Player.new("Spieler", true, START_LIFE)
human.perks = Array(rules.get("human_perks", []))
human.looks = rules.get("looks", {})
var g := Game.new(human, opps, r)
var g := Game.new(human, opp, r)
for p in g.players:
if "leben_35" in p.perks and p.life == START_LIFE:
p.life = 35
p.max_life = 35
if "leben_35" in p.perks:
p.life += 5
p.max_life += 5
g.start(fixed_order)
return g

View File

@@ -48,7 +48,7 @@ func _ready() -> void:
func host(port: int = PORT) -> Error:
peer = ENetMultiplayerPeer.new()
var err := peer.create_server(port, Game.MAX_OPPONENTS)
var err := peer.create_server(port, 8) # ein Mitspieler, der Rest Zuschauer
if err != OK:
status = "Kann nicht hosten (Port %d belegt?)" % port
return err
@@ -307,10 +307,11 @@ func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
lobby_changed.emit()
## Host: Spiel mit allen Verbundenen und n_ai KI-Gegnern starten.
func start_battle(n_ai: int) -> void:
## Host: Duell mit dem ersten Mitspieler starten; allein gegen die KI.
func start_battle() -> void:
var ids: Array = names.keys()
ids.sort()
ids = ids.slice(0, 2)
var r := Game.make_rng()
var players := []
seats = {}
@@ -321,9 +322,9 @@ func start_battle(n_ai: int) -> void:
if Cards.has_id(cid):
p.looks[cid] = int(pick.looks[cid])
players.append(p)
for i in n_ai:
players.append(DeckGen.random_opponent(i + 1))
game = Game.new(players[0], players.slice(1), r)
if players.size() < 2:
players.append(DeckGen.random_opponent())
game = Game.new(players[0], players[1], r)
game.start()
for id in ids:
seats[id] = game.players.find(players[ids.find(id)])

View File

@@ -80,7 +80,7 @@ static func from_snapshot(d: Dictionary) -> Game:
p.board.append(c)
list.append(p)
var hi := int(d.human)
var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new())
var g := Game.new(list[hi], list[1 - hi], RandomNumberGenerator.new())
g.players = list
g.turn = int(d.turn)
g.current_idx = int(d.current_idx)

View File

@@ -10,7 +10,7 @@ const BOOSTER_COST := 100
const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
const START_COINS := 150
const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300}
const REWARD_WIN := 100
const REWARD_LOSS := 50
# Schwierigkeit: Leben der Gegner, KI und Münzen.
const DIFFICULTIES := {
@@ -190,10 +190,10 @@ func open_booster(r: RandomNumberGenerator = null) -> Array:
## 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, n_opp: int, stage: int = -1, reward_factor: float = 1.0) -> int:
func record_battle(won: bool, stage: int = -1, reward_factor: float = 1.0) -> int:
var reward := REWARD_LOSS
if won:
reward = int(round(REWARD_WIN[n_opp] * reward_factor))
reward = int(round(REWARD_WIN * reward_factor))
if stage >= 0 and stage == campaign_stage:
campaign_stage += 1
reward = Campaign.stage(stage).reward

View File

@@ -4,6 +4,8 @@ extends RefCounted
var screen # BattleScreen
var enabled := true
var banners: Array = [] # wartende Ansagen [Text, Farbe]
var banner_running := false
func _init(s) -> void:
@@ -25,6 +27,7 @@ func play_events(evs: Array, centers: Dictionary) -> void:
var atk: CreatureView = screen.tiles.get(ev.attacker.uid)
lunge(atk, center_of(ev.target, centers))
flash_target(ev.target)
shake()
delay = 0.12
"damage":
sfx("damage")
@@ -43,7 +46,7 @@ func play_events(evs: Array, centers: Dictionary) -> void:
var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid})
if where.x < 0:
where = screen.target_center({"kind": "player", "index": ev.player})
popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1)
popup("%s!" % tr(ev.name), Style.GOLD, where + Vector2(0, -30), 0.1)
"play":
sfx("play")
"fuse":
@@ -51,7 +54,7 @@ func play_events(evs: Array, centers: Dictionary) -> void:
popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
"field":
sfx("combo")
popup("Feld: %s" % Cards.FIELD_NAMES[ev.element], Cards.ELEMENT_COLORS[ev.element], Vector2(640, 196), 0.0)
banner("%s: %s" % [tr(Cards.FIELD_NAMES[ev.element]), tr(Cards.FIELD_TEXT[ev.element])], Cards.ELEMENT_COLORS[ev.element])
"buy":
sfx("click") # die Karte landet sichtbar auf der Hand, das reicht
"reroll":
@@ -165,3 +168,108 @@ func move_to(tile: CreatureView, pos: Vector2) -> void:
screen.create_tween().tween_property(tile, "position", pos, 0.2)
else:
tile.position = pos
## Ein Wesen fliegt von der Handkachel auf seinen Platz.
func fly_in(tile: CreatureView, from: Rect2) -> void:
if not enabled:
return
tile.busy = true
tile.position = from.position
tile.scale = from.size / tile.size
var tw := tile.create_tween().set_parallel(true)
tw.tween_property(tile, "position", tile.slot, 0.22).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.tween_property(tile, "scale", Vector2.ONE, 0.22).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tw.chain().tween_callback(func():
tile.busy = false
tile.position = tile.slot)
## Eine gekaufte Karte fliegt aus dem Laden auf die Hand.
func fly_card(card: Cards.Card, from: Rect2, to: Rect2, rarity: String) -> void:
if not enabled:
return
var v := CardView.new(card, from.size, false, rarity)
v.position = from.position
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
v.pivot_offset = Vector2.ZERO
screen.fx_layer.add_child(v)
var tw: Tween = screen.create_tween().set_parallel(true)
tw.tween_property(v, "position", to.position, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(v, "scale", to.size / from.size, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(v, "modulate:a", 0.0, 0.1).set_delay(0.2)
tw.chain().tween_callback(v.queue_free)
## Ladenkarten werden nacheinander ausgeteilt.
func deal(v: CardView, i: int) -> void:
if not enabled:
return
v.modulate.a = 0.0
v.position.y += 24
var tw: Tween = screen.create_tween().set_parallel(true)
tw.tween_property(v, "modulate:a", 1.0, 0.15).set_delay(i * 0.05)
tw.tween_property(v, "position:y", v.position.y - 24, 0.2).set_delay(i * 0.05).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
## Kurzes Zittern des Bretts beim Treffer.
func shake() -> void:
if not enabled:
return
var layer: Control = screen.tile_layer
var tw: Tween = screen.create_tween()
for i in 3:
tw.tween_property(layer, "position", Vector2(3 if i % 2 == 0 else -3, 0), 0.03)
tw.tween_property(layer, "position", Vector2.ZERO, 0.03)
## Ansage quer über die Mitte: Zugbeginn, Feldwechsel. Mehrere folgen nacheinander.
func banner(text: String, color: Color) -> void:
if not enabled:
return
banners.append([text, color])
if not banner_running:
_next_banner()
func _next_banner() -> void:
if banners.is_empty() or not is_instance_valid(screen) or not screen.is_inside_tree():
banner_running = false
return
banner_running = true
var next: Array = banners.pop_front()
var text: String = next[0]
var color: Color = next[1]
var delay := 0.0
var band := ColorRect.new()
band.color = Color(0, 0, 0, 0.55)
band.position = Vector2(0, 300)
band.size = Vector2(1280, 90)
band.mouse_filter = Control.MOUSE_FILTER_IGNORE
band.scale.y = 0.0
band.pivot_offset = Vector2(640, 45)
screen.fx_layer.add_child(band)
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 40)
l.add_theme_font_override("font", Style.FONT_BOLD)
l.add_theme_color_override("font_color", color)
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
l.add_theme_constant_override("outline_size", 8)
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
screen.fx_layer.add_child(l)
l.reset_size()
l.position = Vector2(640, 345) - l.size / 2
l.pivot_offset = l.size / 2
l.scale = Vector2(0.6, 0.6)
l.modulate.a = 0.0
var tw: Tween = screen.create_tween().set_parallel(true)
tw.tween_property(band, "scale:y", 1.0, 0.15).set_delay(delay).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.tween_property(l, "scale", Vector2.ONE, 0.25).set_delay(delay).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tw.tween_property(l, "modulate:a", 1.0, 0.12).set_delay(delay)
tw.tween_property(l, "modulate:a", 0.0, 0.3).set_delay(delay + 0.8)
tw.tween_property(band, "scale:y", 0.0, 0.2).set_delay(delay + 0.9)
tw.chain().tween_callback(func():
l.queue_free()
band.queue_free()
_next_banner())

View File

@@ -1,42 +1,71 @@
class_name BattleLayout
## Feste Bildschirmaufteilung des Kampfes (1280x800).
## Feste Bildschirmaufteilung des Duells (1280x800), von unten nach oben: Laden mit Neuwürfeln
## und Zug beenden, Hand, eigenes Brett (mit eigener Leiste links), Feldband, oben der Gegner
## mit seinem Brett.
const SIZES := {1: [110, 110, 10], 2: [96, 110, 8], 3: [78, 100, 4], 4: [54, 72, 4], 5: [44, 62, 3]}
const HAND_W := 130
const HAND_H := 185
const HAND_GAP := 8
const BAR := Rect2(335, 415, 610, 68)
const OWN_X := (1280 - 5 * 110 - 4 * 10) / 2
static func compact(n_others: int) -> bool:
return n_others >= 4
static func hero_box_rect(n_others: int, i: int) -> Rect2:
var col_w := 1280 / n_others
var margin := 6 if compact(n_others) else 10
return Rect2(i * col_w + margin, 10, col_w - 2 * margin, 64)
const B_GIVEUP := Rect2(10, 10, 96, 32)
const B_LOG := Rect2(112, 10, 96, 32)
const OPP_BOX := Rect2(330, 10, 620, 56)
const FIELD := Rect2(240, 226, 800, 32)
const MSG := Rect2(240, 262, 800, 22)
const OWN_PANEL := Rect2(240, 288, 800, 160)
const STATUS := Rect2(20, 288, 210, 160)
const B_FUSE := Rect2(1050, 298, 210, 40)
const B_UNDO := Rect2(1050, 346, 210, 40)
const OWN_X := 345
const HAND_W := 96
const HAND_H := 62
const HAND_GAP := 4
const HAND_Y := 458
const SHOP_Y := 532
const SHOP_W := 150
const SHOP_H := 205
const B_REROLL := Rect2(20, 606, 200, 52)
const B_END := Rect2(1060, 600, 200, 60)
const LOG_PANEL := Rect2(240, 60, 800, 560)
const PREVIEW_SIZE := Vector2(180, 252)
const CENTER_X := 240
const CENTER_W := 800
static func own_slot(j: int) -> Rect2:
return Rect2(OWN_X + j * 120, 250, 110, 140)
return Rect2(OWN_X + j * 120, 298, 110, 140)
static func other_slot(n_others: int, i: int, j: int) -> Rect2:
var col_w := 1280 / n_others
var sz: Array = SIZES[n_others]
var bx: int = i * col_w + (col_w - 5 * sz[0] - 4 * sz[2]) / 2
return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1])
## Brett des Gegners unter seinem Kasten.
static func opp_slot(j: int) -> Rect2:
return Rect2(OWN_X + j * 120, 76, 110, 140)
## Laden: fünf Angebote über der Hand.
## Laden: fünf große Angebote unten, wie in TFT.
static func shop_rect(i: int) -> Rect2:
return Rect2(340 + i * 121, 487, 115, 112)
return Rect2(245 + i * (SHOP_W + 10), SHOP_Y, SHOP_W, SHOP_H)
## Hand: flache Kacheln zwischen Brett und Laden (die Bank).
static func hand_rect(count: int, i: int) -> Rect2:
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
var x := (1280 - total) / 2
return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H)
var x := CENTER_X + (CENTER_W - total) / 2
return Rect2(x + i * (HAND_W + HAND_GAP), HAND_Y, HAND_W, HAND_H)
## Schwebende Vorschau neben der gezeigten Karte: über Hand- und Ladenkarten, sonst rechts
## davon (links, wenn rechts kein Platz ist); nie aus dem Bild.
static func preview_rect(anchor: Rect2) -> Rect2:
if anchor.position.y >= HAND_Y - 10:
var x := clampf(anchor.get_center().x - PREVIEW_SIZE.x / 2, 10, 1270 - PREVIEW_SIZE.x)
return Rect2(Vector2(x, anchor.position.y - 12 - PREVIEW_SIZE.y), PREVIEW_SIZE)
var x := anchor.end.x + 12
if x + PREVIEW_SIZE.x > 1270:
x = anchor.position.x - 12 - PREVIEW_SIZE.x
var y := clampf(anchor.get_center().y - PREVIEW_SIZE.y / 2, 10, 800 - PREVIEW_SIZE.y - 10)
return Rect2(Vector2(x, y), PREVIEW_SIZE)
## Zusatzzeile (Resonanz, Feld, Feldkombo) neben der Vorschau, rechts davon oder links.
static func info_rect(preview: Rect2) -> Rect2:
var w := 230.0
var x := preview.end.x + 8
if x + w > 1275:
x = preview.position.x - 8 - w
return Rect2(x, preview.position.y + preview.size.y / 2 - 40, w, 80)

View File

@@ -4,7 +4,6 @@ extends Control
## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer).
var app
var n_opp: int
var stage := -1 # Kampagnenstufe oder -1
var net_play := false # Netzwerkspiel (auch als Zuschauer)
var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1
@@ -24,8 +23,6 @@ var ai_copy: Game # Spielkopie, auf der die KI rechnet
var ai_box := {} # Ergebnis der Berechnung (vom Thread befüllt)
var reward := -1
var ended := false
var zoom_player = null
var preview_timer := 0.0
var pad: BattleGamepad
var gp_zone: String:
get:
@@ -36,7 +33,6 @@ var gp_index: int:
var skip_ai := false # Gegnerzüge im Schnelldurchlauf
var smart_ai := true # Vorausschau-KI (Leicht: einfache KI)
var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion
var saved_game: Game = null # fortgesetzter Kampf (siehe resume)
var undo_log_len := 0
var undo_t := 0.0
@@ -54,11 +50,12 @@ var hero_layer: Control
var tile_layer: Control
var hand_layer: Control
var shop_layer: Control
var zoom_layer: Control
var log_overlay: Control
var b_log: Button
var status_panel: Panel
var fx_layer: Control
var log_box: RichTextLabel
var preview_holder: Control
var bar_panel: Panel
var name_label: Label
var life_label: Label
var energy_label: Label
@@ -69,38 +66,35 @@ var b_end: Button
var b_fuse: Button
var b_reroll: Button
var b_giveup: Button
var field_label: Label
var field_view: FieldView
var link_view: Links # Resonanzlinien unter den Feldreihen
var info_label: Label # Vorschau: Resonanz, Feld, Feldkombo
var board_panel: Panel # eigenes Brett, färbt sich mit dem Feld
var arrivals := {} # uid -> Rect2: Wesen, das gerade aus der Hand aufs Brett fliegt
var banner_turn := -1 # Zug, für den die Zugansage schon lief
var deal_shop := false # nächster Aufbau teilt den Laden animiert aus
var overlay: Control
var result_label: Label
var reward_label: Label
var notice_label: Label
var confirm: Control
var b_save_leave: Button
var cursor_layer: Control
var wait_label: Label
var b_skip: Button
var b_undo: Button
func _init(n: int = 2, campaign_stage: int = -1) -> void:
n_opp = n
func _init(campaign_stage: int = -1) -> void:
stage = campaign_stage
static func online(seat: int) -> BattleScreen:
var b := BattleScreen.new(0, -1)
var b := BattleScreen.new(-1)
b.net_play = true
b.online_seat = seat
return b
## Unterbrochenen Kampf aus der Datei fortsetzen (siehe save_battle).
static func resume(data: Dictionary) -> BattleScreen:
var s := BattleScreen.new(int(data.get("n_opp", 1)), int(data.get("stage", -1)))
s.saved_game = Game.from_snapshot(data.game)
return s
func is_online() -> bool:
return net_play
@@ -137,13 +131,11 @@ func _ready() -> void:
net.snapshot_received.connect(_on_snapshot)
wait_label.visible = true
return # der erste Zustand kommt vom Host
elif saved_game != null:
game = saved_game
elif stage >= 0:
var r := Game.make_rng()
game = Game.new_battle_with(Campaign.opponents(stage), r, false, own_rules())
game = Game.new_battle_with(Campaign.opponent(stage), r, false, own_rules())
else:
game = Game.new_battle(n_opp, Game.make_rng(), own_rules())
game = Game.new_battle(Game.make_rng(), own_rules())
smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile()
_begin()
@@ -160,7 +152,6 @@ func own_rules() -> Dictionary:
func _begin() -> void:
n_opp = game.players.size() - 1
if is_online():
perspective = game.players[maxi(online_seat, 0)]
else:
@@ -170,67 +161,94 @@ func _begin() -> void:
if is_online() and net.is_host:
net.broadcast()
game.events.clear()
b_save_leave.visible = resumable()
save_battle()
refresh()
announce_turn()
func _build_static() -> void:
Style.panel(self, Rect2(10, 205, 320, 290))
Style.label(self, "Verlauf", 14, Style.MUTED, Rect2(20, 209, 200, 20))
log_box = RichTextLabel.new()
log_box.position = Vector2(18, 230)
log_box.size = Vector2(304, 258)
log_box.bbcode_enabled = true
log_box.scroll_following = true
log_box.add_theme_font_size_override("normal_font_size", 13)
log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(log_box)
b_giveup = Style.button(self, "Aufgeben", Rect2(10, 553, 150, 40), func(): confirm.visible = true)
Style.panel(self, Rect2(950, 205, 320, 380))
Style.panel(self, Rect2(BattleLayout.OWN_X - 10, 240, 5 * 110 + 4 * 10 + 20, 160), Style.darker(Style.PANEL, 0.8), 10)
bar_panel = Style.panel(self, BattleLayout.BAR)
name_label = Style.label(self, "", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true)
life_label = Style.label(self, "", 24, Style.RED, Rect2(470, 418, 100, 34), "left", true)
energy_label = Style.label(self, "", 24, Style.BLUE, Rect2(570, 418, 150, 34), "left", true)
income_label = Style.label(self, "", 16, Style.MUTED, Rect2(760, 424, 170, 24), "right")
status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24))
msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true)
b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c"))
b_skip = Style.button(self, "Gegnerzüge überspringen", Rect2(1005, 527, 210, 48), func(): skip_ai = true)
var L := BattleLayout
# oben links: Aufgeben und Verlauf, klein
b_giveup = Style.button(self, "Aufgeben", L.B_GIVEUP, func(): confirm.visible = true)
b_giveup.add_theme_font_size_override("font_size", 13)
b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log)
b_log.add_theme_font_size_override("font_size", 13)
# Mitte: Feldband, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben
Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8)
field_view = FieldView.new(L.FIELD)
add_child(field_view)
msg_label = Style.label(self, "", 15, Style.ORANGE, L.MSG, "center", true)
board_panel = Style.panel(self, L.OWN_PANEL, Style.darker(Style.PANEL, 0.8), 10)
var slots := Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
slots.draw.connect(func():
for j in Game.MAX_BOARD:
Style.border(slots, L.own_slot(j), Color(1, 1, 1, 0.08), 2, 8)
Style.border(slots, L.opp_slot(j), Color(1, 1, 1, 0.05), 2, 8))
add_child(slots)
status_panel = Style.panel(self, L.STATUS)
var sx: float = L.STATUS.position.x
var sy: float = L.STATUS.position.y
name_label = Style.label(self, "", 18, Style.TEXT, Rect2(sx + 12, sy + 8, 190, 26), "left", true)
name_label.clip_text = true
life_label = Style.label(self, "", 26, Style.RED, Rect2(sx + 12, sy + 40, 190, 34), "left", true)
energy_label = Style.label(self, "", 26, Style.BLUE, Rect2(sx + 12, sy + 78, 100, 34), "left", true)
income_label = Style.label(self, "", 15, Style.MUTED, Rect2(sx + 100, sy + 88, 100, 22), "left")
status_label = Style.label(self, "", 13, Style.MUTED, Rect2(sx + 12, sy + 124, 190, 22), "left")
b_fuse = Style.button(self, "Verschmelzen", L.B_FUSE, toggle_fuse, Color("3c2858"))
b_fuse.add_theme_font_size_override("font_size", 15)
b_fuse.visible = false
b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo)
b_undo.add_theme_font_size_override("font_size", 15)
b_undo.visible = false
# unten: Laden mit Neuwürfeln links und Zug beenden rechts
b_reroll = Style.button(self, "", L.B_REROLL, do_reroll, Color("285a5a"))
b_reroll.add_theme_font_size_override("font_size", 15)
b_end = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY)
b_end.add_theme_font_size_override("font_size", 20)
b_skip = Style.button(self, "Gegnerzüge überspringen", L.B_END, func(): skip_ai = true)
b_skip.add_theme_font_size_override("font_size", 14)
b_skip.visible = false
b_undo = Style.button(self, "Rückgängig", Rect2(170, 553, 150, 40), undo)
b_undo.visible = false
field_label = Style.label(self, "", 15, Style.MUTED, Rect2(340, 211, 600, 24), "center")
b_fuse = Style.button(self, "Verschmelzen", Rect2(10, 503, 150, 40), toggle_fuse, Color("3c2858"))
b_fuse.visible = false
b_reroll = Style.button(self, "", Rect2(170, 503, 150, 40), do_reroll, Color("285a5a"))
b_reroll.add_theme_font_size_override("font_size", 14)
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false
hero_layer = _layer()
link_view = Links.new()
add_child(link_view)
tile_layer = _layer()
hand_layer = _layer()
shop_layer = _layer()
preview_holder = _layer()
zoom_layer = _layer()
info_label = Style.label(self, "", 13, Style.TEXT, Rect2(0, 0, 220, 60), "center")
info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
info_label.visible = false
fx_layer = _layer()
cursor_layer = _layer()
# Verlauf als Überlagerung
log_overlay = Style.overlay(self)
log_overlay.get_child(0).gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed:
log_overlay.visible = false)
Style.panel(log_overlay, L.LOG_PANEL, Style.PANEL, 10, Style.MUTED, 2)
Style.label(log_overlay, "Verlauf", 20, Style.GOLD, Rect2(L.LOG_PANEL.position + Vector2(20, 12), Vector2(300, 30)), "left", true)
log_box = RichTextLabel.new()
log_box.position = L.LOG_PANEL.position + Vector2(20, 50)
log_box.size = L.LOG_PANEL.size - Vector2(40, 110)
log_box.bbcode_enabled = true
log_box.scroll_following = true
log_box.add_theme_font_size_override("normal_font_size", 14)
log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
log_overlay.add_child(log_box)
Style.button(log_overlay, "Schließen", Rect2(L.LOG_PANEL.end.x - 180, L.LOG_PANEL.end.y - 52, 160, 40), func(): log_overlay.visible = false)
# Spielende
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")
Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
# Aufgeben bestätigen
confirm = Style.overlay(self)
Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.MUTED, 2)
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
Style.button(confirm, "Ja, aufgeben", Rect2(355, 420, 180, 46), leave, Color("5a2828"))
b_save_leave = Style.button(confirm, "Speichern und zurück", Rect2(550, 420, 180, 46), save_and_leave, Color("285a3c"))
b_save_leave.add_theme_font_size_override("font_size", 14)
Style.button(confirm, "Weiterspielen", Rect2(745, 420, 180, 46), func(): confirm.visible = false)
confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen")
func toggle_log() -> void:
log_overlay.visible = not log_overlay.visible
func _layer() -> Control:
@@ -244,54 +262,27 @@ func _overlay() -> Control:
return Style.overlay(self)
## Einzelspieler-Kämpfe (auch Kampagne) lassen sich unterbrechen und fortsetzen.
func resumable() -> bool:
return counts_for_profile()
func save_battle() -> void:
if not resumable() or game == null or game.winner != "":
return
var d := {"version": 1, "n_opp": n_opp, "stage": stage, "game": game.snapshot(-1)}
var f := FileAccess.open(app.battle_save_path(), FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify(d))
f.close()
func save_and_leave() -> void:
save_battle()
app.show_screen(MenuScreen.new())
func leave() -> void:
if is_online():
net.leave()
if resumable() and game != null and game.winner == "":
app.clear_battle_save() # aufgegeben
app.show_screen(MenuScreen.new())
# --- Layout-Helfer ------------------------------------------------------------------
## Der Gegner aus Sicht der eigenen Perspektive (Zuschauer: der zweite Spieler).
func opponent() -> Game.Player:
return game.enemy_of(perspective)
func others() -> Array:
var out := []
var n := game.players.size()
var start := game.players.find(perspective)
for i in range(1, n):
out.append(game.players[(start + i) % n])
return out
func compact() -> bool:
return BattleLayout.compact(others().size())
return [opponent()]
func slot_rect(p: Game.Player, j: int) -> Rect2:
if p == perspective:
return BattleLayout.own_slot(j)
var os := others()
return BattleLayout.other_slot(os.size(), os.find(p), j)
return BattleLayout.opp_slot(j)
func ref_key(ref: Dictionary) -> String:
@@ -306,47 +297,37 @@ func target_center(ref: Dictionary) -> Vector2:
return Vector2(-1, -1)
var p: Game.Player = game.players[ref.index]
if p == perspective:
return BattleLayout.BAR.position + BattleLayout.BAR.size / 2
return BattleLayout.STATUS.get_center()
if hero_panels.has(p):
var panel: Panel = hero_panels[p]
return panel.position + panel.size / 2
return Vector2(-1, -1)
## Kasten des Gegners oben in der Mitte; ein Klick zielt auf den Helden.
func build_hero_boxes() -> void:
Style.clear(hero_layer)
hero_panels = {}
hero_labels = {}
var os := others()
var c := compact()
for i in os.size():
var o: Game.Player = os[i]
var box := BattleLayout.hero_box_rect(os.size(), i)
var panel := Style.panel(hero_layer, box)
panel.gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
on_click("hero", o))
if not o.perks.is_empty():
panel.tooltip_text = "Merkmal: " + ", ".join(o.perks.map(func(k): return Game.PERKS[k]))
hero_panels[o] = panel
var labels := {}
var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name
if not o.perks.is_empty():
title += " ★"
labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT,
Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (58 if c else 20), 26), "left", true)
labels.name.clip_text = true
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED,
Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true)
labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED,
Rect2(box.position.x + 90, box.position.y + 38, box.size.x - 100, 20), "right")
hero_labels[o] = labels
if c:
var b := Style.button(hero_layer, "Lupe", Rect2(box.end.x - 44, box.position.y + 5, 38, 22), func():
zoom_player = o
refresh())
b.add_theme_font_size_override("font_size", 11)
var o := opponent()
var box := BattleLayout.OPP_BOX
var panel := Style.panel(hero_layer, box)
panel.gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
on_click("hero", o))
if not o.perks.is_empty():
panel.tooltip_text = "Merkmal: " + ", ".join(o.perks.map(func(k): return Game.PERKS[k]))
hero_panels[o] = panel
var labels := {}
var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name
if not o.perks.is_empty():
title += " ★"
labels["name"] = Style.label(hero_layer, title, 18, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 4, box.size.x - 16, 22), "left", true)
labels.name.clip_text = true
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
labels["life"] = Style.label(hero_layer, "", 20, Style.RED, Rect2(box.position.x + 8, box.position.y + 30, 120, 24), "left", true)
labels["stats"] = Style.label(hero_layer, "", 14, Style.MUTED, Rect2(box.position.x + 80, box.position.y + 34, box.size.x - 88, 20), "right")
hero_labels[o] = labels
# --- Ablauf und Eingabe ------------------------------------------------------------------
@@ -358,7 +339,7 @@ func my_turn() -> bool:
func any_overlay() -> bool:
return confirm.visible or overlay.visible
return confirm.visible or overlay.visible or log_overlay.visible
func _input(event: InputEvent) -> void:
@@ -376,7 +357,9 @@ func _input(event: InputEvent) -> void:
KEY_ESCAPE:
if confirm.visible:
confirm.visible = false
elif selected != null or pending != null or fusing or zoom_player != null:
elif log_overlay.visible:
log_overlay.visible = false
elif selected != null or pending != null or fusing:
cancel_all()
elif not any_overlay():
confirm.visible = true
@@ -402,7 +385,6 @@ func cancel_all() -> void:
if confirm.visible:
confirm.visible = false
cancel()
zoom_player = null
refresh()
@@ -412,10 +394,6 @@ func _process(delta: float) -> void:
msg_t = maxf(0.0, msg_t - delta)
if msg_t <= 0.0 and msg_label.text != "":
msg_label.text = ""
if preview_timer > 0.0:
preview_timer -= delta
if preview_timer <= 0.0:
show_preview(null, false)
if undo_t > 0.0:
undo_t -= delta
if undo_t <= 0.0:
@@ -477,10 +455,9 @@ func _finish() -> void:
var won := game.winner == "human"
if counts_for_profile():
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
reward = app.profile.record_battle(won, n_opp, stage, factor)
reward = app.profile.record_battle(won, stage, factor)
app.save()
app.sfx.play("win" if won else "lose")
app.clear_battle_save()
else:
var wp := game.winner_player
var mine: bool = wp != null and wp == perspective
@@ -506,10 +483,13 @@ func play(card: Cards.Card, target) -> void:
return
var before: Game = game.clone() if not is_online() else null
var log_len := game.log.size()
var from := _hand_rect_of(card)
var err := game.play_card(perspective, card, target)
if err != "":
say(tr(err))
return
if from.size.x > 0 and not perspective.board.is_empty() and perspective.board[-1].card == card:
arrivals[perspective.board[-1].uid] = from
# Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte und keine Kombos)
if before != null and game.events.size() == 1 and game.events[0].type == "play":
undo_state = before
@@ -534,9 +514,15 @@ func do_buy(slot: int) -> void:
if is_client():
net.send_action({"type": "buy", "slot": slot})
return
var from := BattleLayout.shop_rect(slot)
var offer = perspective.shop[slot] if slot < perspective.shop.size() else null
var err := game.buy(perspective, slot)
if err != "":
say(tr(err))
elif offer != null:
fx.sfx("buy")
fx.fly_card(offer, from, BattleLayout.hand_rect(perspective.hand.size(), perspective.hand.size() - 1),
Cards.RARITIES[int(perspective.looks.get(offer.id, 0))])
## Würfelt den Laden neu (kostet Mana).
@@ -551,6 +537,8 @@ func do_reroll() -> void:
var err := game.reroll(perspective)
if err != "":
say(tr(err))
else:
deal_shop = true
after_action()
@@ -596,7 +584,6 @@ func end_turn() -> void:
return
cancel()
clear_undo()
zoom_player = null
if is_client():
net.send_action({"type": "end"})
return
@@ -629,7 +616,6 @@ func on_click(kind: String, obj) -> void:
play(card, obj)
else:
say(tr("Abgebrochen"))
zoom_player = null
after_action()
return
if selected != null:
@@ -643,7 +629,6 @@ func on_click(kind: String, obj) -> void:
var err := g.attack(cr, obj)
if err != "":
say(tr(err))
zoom_player = null
after_action()
return
match kind:
@@ -677,17 +662,121 @@ func on_tile_click(cr: Game.Creature) -> void:
on_click("enemy", cr)
func show_preview(card, entered: bool, owner_looks: Dictionary = {}) -> void:
## Zugansage, wenn der eigene Zug beginnt.
func announce_turn() -> void:
if game == null or not my_turn() or banner_turn == game.turn:
return
banner_turn = game.turn
fx.banner(tr("Dein Zug"), Style.GOLD)
fx.sfx("turn")
deal_shop = true
refresh()
func _hand_rect_of(card: Cards.Card) -> Rect2:
for v in hand_views:
if v.card == card:
return Rect2(v.position, v.size)
return Rect2()
## Vorschau schwebt neben der gezeigten Karte (anchor), darunter die Zusatzzeile (info).
func show_preview(card, entered: bool, owner_looks: Dictionary = {}, info: String = "", anchor: Rect2 = Rect2()) -> void:
Style.clear(preview_holder)
info_label.visible = entered and info != ""
info_label.text = info if entered else ""
if entered and card != null:
var v := CardView.new(card, Vector2(210, 294), false, Cards.RARITIES[int(owner_looks.get(card.id, 0))])
v.position = Vector2(1005, 215)
var r := BattleLayout.preview_rect(anchor)
var v := CardView.new(card, r.size, false, Cards.RARITIES[int(owner_looks.get(card.id, 0))])
v.position = r.position
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
var ir := BattleLayout.info_rect(r)
info_label.position = ir.position
info_label.size = ir.size
func show_hand_preview(card: Cards.Card, entered: bool) -> void:
show_preview(card, entered, perspective.looks)
## Eigene Hand- und Ladenkarte: zeigt, was das Ausspielen jetzt brächte, und markiert den Kombo-Partner.
func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void:
var f := game.forecast(perspective, card)
var lines: Array[String] = []
var parts: Array[String] = []
if f.resonance > 0:
parts.append(tr("Resonanz +%d") % f.resonance)
if f.field > 0:
parts.append(tr("Feld +%d") % f.field)
if not parts.is_empty():
lines.append(" · ".join(parts))
if f.combo != "":
lines.append(tr("Feldkombo %s mit %s: %s") % [tr(f.combo), f.partner.card.title(), tr(Cards.FIELD_COMBO_TEXT[f.combo])])
for t in tiles.values():
t.mark = ""
t.queue_redraw()
if entered and f.partner != null and tiles.has(f.partner.uid):
tiles[f.partner.uid].mark = f.combo
tiles[f.partner.uid].queue_redraw()
show_preview(card, entered, perspective.looks, "\n".join(lines), anchor)
## Hand- und Ladenkarte tragen ihren Bonus und ihre Feldkombo als Vorschau.
func apply_forecast(v: CardView) -> void:
var f := game.forecast(perspective, v.card)
v.bonus = f.resonance + f.field
v.tag = f.combo
## Was den Angriff eines Wesens gerade hebt.
func creature_info(cr: Game.Creature) -> String:
var parts: Array[String] = []
if cr.resonance > 0:
parts.append(tr("Resonanz +%d") % cr.resonance)
if cr.field_bonus > 0:
parts.append(tr("Feld +%d") % cr.field_bonus)
var perk := cr.power() - cr.attack - cr.resonance - cr.field_bonus
if perk > 0:
parts.append(tr("Merkmal +%d") % perk)
return " · ".join(parts)
## Elementvorteil gegen ein mögliches Ziel: des gewählten Angreifers oder eines Schadenszaubers auf der Hand.
func matchup_for(cr: Game.Creature, valid: Array) -> int:
if selected != null and game.can_attack(selected, cr)[0]:
return Game.matchup_bonus(selected, cr)
if pending != null and cr in valid and pending.effects.any(func(fx): return fx.kind == "damage" and fx.target in Cards.CHOSEN_TARGETS):
return Cards.matchup(pending.elements, cr.card.elements)
return 0
## Resonanzgruppen je Reihe: Wesen mit gleichem Element werden unter der Reihe verbunden.
func resonance_groups() -> Array:
var out := []
for p in game.players:
var k := 0
for e in Cards.ELEMENTS:
var xs: Array[float] = []
for j in p.board.size():
if e in p.board[j].card.elements:
xs.append(slot_rect(p, j).get_center().x)
if xs.size() < 2:
continue
out.append({"color": Style.lighter(Cards.ELEMENT_COLORS[e], 0.25), "y": slot_rect(p, 0).end.y + 4 + k * 6, "xs": xs})
k += 1
return out
class Links extends Control:
var groups: Array = []
func _init() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void:
for gr in groups:
var y: float = gr.y
draw_line(Vector2(gr.xs[0], y), Vector2(gr.xs[-1], y), gr.color, 3.0)
for x in gr.xs:
draw_circle(Vector2(x, y), 4.0, gr.color)
# --- Netzwerk -------------------------------------------------------------------------------
@@ -744,7 +833,7 @@ func after_action() -> void:
centers[ref_key(ev[key])] = target_center(ev[key])
refresh()
fx.play_events(evs, centers)
save_battle()
announce_turn()
# --- Ansicht angleichen --------------------------------------------------------------------
@@ -781,8 +870,7 @@ func refresh() -> void:
labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED)
if o.alive():
labels.life.text = "♥ %d" % maxi(o.life, 0)
labels.stats.text = ("H %d ◆ %d" % [o.hand.size(), o.energy]) if compact() \
else ("Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy])
labels.stats.text = "Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy]
else:
labels.life.text = "besiegt"
labels.life.add_theme_color_override("font_color", Style.MUTED)
@@ -798,10 +886,11 @@ func refresh() -> void:
if tiles.has(cr.uid):
tile = tiles[cr.uid]
tile.creature = cr
if tile.size != rect.size:
tile.size = rect.size
if tile.size != rect.size: # erst die Mindestgröße, sonst bleibt die Kachel groß
tile.custom_minimum_size = rect.size
tile.size = rect.size
tile.pivot_offset = rect.size / 2
tile.set_process(Style.rarity_animated(tile.rarity()))
else:
tile = CreatureView.new(cr, rect.size)
tile.position = rect.position
@@ -809,13 +898,17 @@ func refresh() -> void:
tiles[cr.uid] = tile
tile_layer.add_child(tile)
var looks: Dictionary = p.looks
tile.hover.connect(func(card, entered): show_preview(card, entered, looks))
tile.hover.connect(func(card, entered): show_preview(card, entered, looks, creature_info(tile.creature) if entered else "", Rect2(tile.slot, tile.size)))
var uid := cr.uid
tile.clicked.connect(func(_v):
var c := game.find_creature(uid)
if c != null:
on_tile_click(c))
fx.pop_in(tile)
if arrivals.has(cr.uid):
fx.fly_in(tile, arrivals[cr.uid])
arrivals.erase(cr.uid)
else:
fx.pop_in(tile)
tile.slot = rect.position
if not tile.busy and tile.position != rect.position:
fx.move_to(tile, rect.position)
@@ -835,6 +928,8 @@ func refresh() -> void:
w = 3
tile.border = b
tile.width = w
tile.mark = ""
tile.matchup = matchup_for(cr, valid)
tile.queue_redraw()
if p == h:
own_views.append(tile)
@@ -845,6 +940,8 @@ func refresh() -> void:
var t: CreatureView = tiles[uid]
tiles.erase(uid)
fx.fade_out(t)
link_view.groups = resonance_groups()
link_view.queue_redraw()
Style.clear(shop_layer)
if not spectating():
@@ -856,17 +953,22 @@ func refresh() -> void:
continue
var sv := CardView.new(offer, sr.size, false, Cards.RARITIES[int(h.looks.get(offer.id, 0))])
sv.position = sr.position
sv.lift = mine
apply_forecast(sv)
sv.dim = not mine or h.energy < offer.cost or h.hand.size() >= Game.MAX_HAND
sv.hover.connect(show_hand_preview)
sv.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(sv.position, sv.size)))
var slot := i
sv.set_meta("slot", slot) # der Platz im Laden, auch für das Gamepad
sv.clicked.connect(func(_view): on_click("shop", slot))
shop_layer.add_child(sv)
shop_views.append(sv)
if deal_shop:
fx.deal(sv, i)
deal_shop = false
Style.clear(hand_layer)
if spectating():
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(0, 690, 1280, 30), "center")
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(240, BattleLayout.HAND_Y + 18, 800, 30), "center")
else:
for i in h.hand.size():
var card: Cards.Card = h.hand[i]
@@ -874,12 +976,14 @@ func refresh() -> void:
var r := BattleLayout.hand_rect(h.hand.size(), i)
var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))])
v.position = r.position
v.lift = mine
apply_forecast(v)
if card == pending:
v.border = Style.YELLOW
elif playable:
v.border = Style.GREEN
v.dim = not playable
v.hover.connect(show_hand_preview)
v.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(v.position, v.size)))
v.clicked.connect(func(_view): on_click("hand", card))
hand_layer.add_child(v)
hand_views.append(v)
@@ -887,7 +991,11 @@ func refresh() -> void:
var bar_border = Style.BLUE if mine else null
if int(flash_until.get(g.players.find(h), 0)) > now:
bar_border = Style.RED
bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
status_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
var board_col := Style.darker(Style.PANEL, 0.8)
if g.field_element != "":
board_col = board_col.lerp(Cards.ELEMENT_COLORS[g.field_element], 0.22) # das Feld färbt das Brett
board_panel.add_theme_stylebox_override("panel", Style.box(board_col, 10))
name_label.text = h.name
life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d" % h.energy
@@ -902,18 +1010,10 @@ func refresh() -> void:
b_undo.visible = undo_state != null and mine
b_fuse.visible = mine and not spectating() and h.board.size() >= 2
b_reroll.visible = mine and not spectating()
b_reroll.text = tr("Neu würfeln (%d)") % Game.REROLL_COST
b_reroll.text = tr("Neu würfeln ◆ %d") % Game.REROLL_COST
b_reroll.disabled = h.energy < Game.REROLL_COST
b_fuse.text = tr("Abbrechen") if fusing else tr("Verschmelzen")
if g.field_element == "":
var parts: Array[String] = []
for e in Cards.ELEMENTS:
parts.append("%s %d" % [tr(e), int(g.field_charge.get(e, 0))])
field_label.text = tr("Feld: neutral") + " · " + " ".join(parts)
field_label.add_theme_color_override("font_color", Style.MUTED)
else:
field_label.text = "%s: %s" % [tr(Cards.FIELD_NAMES[g.field_element]), tr(Cards.FIELD_TEXT[g.field_element])]
field_label.add_theme_color_override("font_color", Cards.ELEMENT_COLORS[g.field_element])
field_view.update_from(g)
var lines: Array[String] = []
var start := maxi(0, g.log.size() - 60)
for i in range(start, g.log.size()):
@@ -929,10 +1029,6 @@ func refresh() -> void:
log_box.text = "\n".join(lines)
pad.draw_cursor()
Style.clear(zoom_layer)
if zoom_player != null and zoom_player.alive():
build_zoom(valid)
if g.winner != "":
var wp := g.winner_player
if counts_for_profile():
@@ -951,30 +1047,3 @@ func refresh() -> void:
overlay.visible = true
confirm.visible = false
func build_zoom(valid: Array) -> void:
var o: Game.Player = zoom_player
var g := game
var box := Rect2(190, 190, 900, 300)
var back := Style.panel(zoom_layer, box, Style.PANEL, 10, Style.GOLD, 2)
back.mouse_filter = Control.MOUSE_FILTER_STOP
Style.label(zoom_layer, "%s · %s ♥ %d Hand %d ◆ %d (+%d)" % [o.name, o.title, o.life, o.hand.size(), o.energy, o.max_energy],
18, Style.TEXT, Rect2(box.position.x + 20, box.position.y + 12, 800, 30), "left", true)
for j in o.board.size():
var cr: Game.Creature = o.board[j]
var b = null
if cr in valid or (selected != null and g.can_attack(selected, cr)[0]):
b = Style.ORANGE
var v := CreatureView.new(cr, Vector2(110, 140), b, 3 if b != null else 2)
v.position = Vector2(box.position.x + 20 + j * 122, box.position.y + 56)
v.hover.connect(show_preview)
v.clicked.connect(func(_x): on_tile_click(cr))
zoom_layer.add_child(v)
if o.board.is_empty():
Style.label(zoom_layer, "Keine Wesen auf dem Feld", 16, Style.MUTED, Rect2(box.position.x + 20, box.position.y + 110, 600, 30))
var hero_ok: bool = (o in valid) or (selected != null and g.can_attack(selected, o)[0])
var bh := Style.button(zoom_layer, "Held als Ziel", Rect2(box.position.x + 20, box.end.y - 60, 180, 44), func(): on_click("hero", o), Color("5a3c28"))
bh.disabled = not hero_ok
Style.button(zoom_layer, "Schließen", Rect2(box.end.x - 180, box.end.y - 60, 160, 44), func():
zoom_player = null
refresh())

View File

@@ -12,6 +12,8 @@ var msg_label: Label
var b_buy: Button
var b_all: Button
var slots: Control
var fx_layer: Control
var fresh := {} # gerade aufgedeckt: wird umgedreht
class CardBack extends Control:
@@ -47,15 +49,18 @@ func slot_rect(i: int) -> Rect2:
func _ready() -> void:
Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true)
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
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)
slots = Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(slots)
msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 700, 1280, 22), "center")
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(290, 720, 330, 48), buy, Color("5a4628"))
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all)
Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new()))
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)
refresh()
@@ -81,16 +86,44 @@ func buy() -> void:
func reveal(i: int) -> void:
revealed[i] = true
app.sfx.play("click")
fresh = {i: 0.0}
app.sfx.play("combo" if cards[i].rarity != "gewöhnlich" else "click")
refresh()
func reveal_all() -> void:
fresh = {}
var k := 0
for i in Profile.BOOSTER_SIZE:
if not revealed.has(i):
fresh[i] = k * 0.12
k += 1
revealed[i] = true
app.sfx.play("play")
refresh()
## Umdrehen: die Karte wächst aus der Mitte; seltene Stufen blitzen in ihrer Farbe auf.
func flip(v: CardView, delay: float, rarity: String) -> void:
if not Style.animations:
return
v.scale.x = 0.0
var tw := create_tween()
tw.tween_interval(delay)
tw.tween_property(v, "scale:x", 1.0, 0.18).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
if rarity == "gewöhnlich":
return
var burst := Style.panel(fx_layer, 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()
bt.tween_interval(delay + 0.1)
bt.tween_property(burst, "modulate:a", 1.0, 0.05)
bt.parallel().tween_property(burst, "scale", Vector2(1.25, 1.25), 0.45).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
bt.tween_property(burst, "modulate:a", 0.0, 0.3)
bt.tween_callback(burst.queue_free)
func refresh() -> void:
var p: Profile = app.profile
coins_label.text = "Münzen: %d" % p.coins
@@ -107,6 +140,8 @@ func refresh() -> void:
var v := CardView.new(c, r.size, false, res.rarity)
v.position = r.position
slots.add_child(v)
if fresh.has(i):
flip(v, fresh[i], res.rarity)
Style.label(slots, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if res.upgraded:
Style.label(slots, "Aufgewertet", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
@@ -117,3 +152,4 @@ func refresh() -> void:
back.position = r.position
back.clicked.connect(reveal.bind(i))
slots.add_child(back)
fresh = {}

View File

@@ -1,32 +1,37 @@
class_name CampaignScreen
extends Control
## Kampagne als Pfad: zehn Stationen je Kapitel, darunter die gewählte Stufe im Detail.
var app
var chapter := 1
var selected := 0 # gewählte Stufe (Index über alle Kapitel)
var perk_box: OptionButton
var node_buttons: Array = []
func _ready() -> void:
var p: Profile = app.profile
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size())
selected = mini(p.campaign_stage, Campaign.total() - 1)
build()
func build() -> void:
Style.clear(self)
node_buttons = []
var p: Profile = app.profile
var col := Style.column(self, 1000, 16, 5)
Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true)
var tabs := Style.row(col, 10)
tabs.alignment = BoxContainer.ALIGNMENT_CENTER
Style.header(self, "Kampagne", func(): app.show_screen(MenuScreen.new()))
var n := Campaign.STAGES.size()
var x := 40
for c in Campaign.CHAPTERS.size():
var b := Style.cbutton(tabs, "Kapitel %d" % (c + 1), Vector2(140, 36), set_chapter.bind(c + 1), Color("285a3c") if chapter == c + 1 else null)
b.disabled = p.campaign_stage < c * Campaign.STAGES.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)
b.disabled = p.campaign_stage < c * n
x += 160
if p.perk_unlocked():
var pr := Style.row(tabs, 8)
Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110)
Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(760, 96, 130, 24), "right")
perk_box = OptionButton.new()
perk_box.custom_minimum_size = Vector2(300, 36)
perk_box.position = Vector2(900, 88)
perk_box.size = Vector2(340, 38)
perk_box.add_item("keins")
var keys: Array = Game.PERKS.keys()
for k in keys:
@@ -35,54 +40,90 @@ func build() -> void:
perk_box.item_selected.connect(func(i: int):
p.perk = "" if i == 0 else keys[i - 1]
app.save())
pr.add_child(perk_box)
var offset := (chapter - 1) * Campaign.STAGES.size()
for k in Campaign.STAGES.size():
add_child(perk_box)
# Pfad mit Stationen
var offset := (chapter - 1) * n
var y := 190.0
var step := (1280 - 200) / float(n - 1)
var centers: Array[Vector2] = []
for k in n:
centers.append(Vector2(100 + k * step, y))
var path := Control.new()
path.mouse_filter = Control.MOUSE_FILTER_IGNORE
path.draw.connect(func():
for k in n - 1:
var done: bool = offset + k + 1 <= p.campaign_stage
path.draw_line(centers[k], centers[k + 1], Style.GREEN if done else Style.PANEL_LIGHT, 4.0))
add_child(path)
for k in n:
var i := offset + k
var st := Campaign.stage(i)
var done := i < p.campaign_stage
var next := i == p.campaign_stage
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6,
Style.GOLD if next else null, 2 if next else 0))
col.add_child(panel)
var pad := MarginContainer.new() # Innenabstand, damit Name und Knopf nicht am Rahmen kleben
for side in ["left", "right"]:
pad.add_theme_constant_override("margin_" + side, 14)
panel.add_child(pad)
var r := Style.row(pad, 10)
r.custom_minimum_size = Vector2(0, 50)
var nm := Style.clabel(r, "%s · %s" % [st.name, st.title], 16, Style.TEXT if not done else Style.MUTED, "left", true, 420)
nm.size_flags_horizontal = Control.SIZE_EXPAND_FILL # schiebt die Punkte und den Knopf nach rechts
var dots := Control.new()
dots.custom_minimum_size = Vector2(60, 0)
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
var elems: Array = st.elements
dots.draw.connect(func():
for m in elems.size():
dots.draw_circle(Vector2(10 + m * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[m]]))
r.add_child(dots)
if done:
Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i))
elif next:
Style.cbutton(r, "Kämpfen", Vector2(130, 36), _start.bind(i), Color("285a3c"))
else:
var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
b.disabled = true
var b := Button.new()
b.text = str(i + 1)
b.position = centers[k] - Vector2(24, 24)
b.size = Vector2(48, 48)
var base: Color = Style.PRIMARY if done else (Color("5a4628") if next else Style.PANEL)
var edge: Color = Style.GOLD if i == selected else (Style.GREEN if done else Style.MUTED)
b.add_theme_stylebox_override("normal", Style.box(base, 24, edge, 3 if i == selected else 2))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 24, edge, 3))
b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 24, edge, 3))
b.add_theme_stylebox_override("disabled", Style.box(Style.darker(Style.PANEL, 0.8), 24, Style.PANEL_LIGHT, 2))
b.add_theme_stylebox_override("focus", Style.box(Color(0, 0, 0, 0), 24, Style.GOLD, 3))
b.disabled = i > p.campaign_stage
b.pressed.connect(func():
selected = i
build())
Style.juice(b)
add_child(b)
node_buttons.append(b)
if next and Style.animations:
b.pivot_offset = b.size / 2
var tw := b.create_tween().set_loops()
tw.tween_property(b, "scale", Vector2(1.1, 1.1), 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(b, "scale", Vector2.ONE, 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
# Gewählte Stufe
var st := Campaign.stage(selected)
var done := selected < p.campaign_stage
var next := selected == p.campaign_stage
Style.panel(self, Rect2(240, 270, 800, 300))
Style.label(self, "%s · %s" % [st.name, st.title], 28, Style.TEXT, Rect2(270, 290, 740, 40), "center", true)
var dots := Control.new()
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
var elems: Array = st.elements
dots.draw.connect(func():
var w := elems.size() * 26 - 6
for m in elems.size():
dots.draw_circle(Vector2(640 - w / 2.0 + 10 + m * 26, 348), 10, Cards.ELEMENT_COLORS[elems[m]]))
add_child(dots)
Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 370, 740, 24), "center")
var perks: Array = st.perks
var py := 402
for k in perks:
Style.label(self, "★ " + tr(Game.PERKS[k]), 15, Style.GOLD, Rect2(270, py, 740, 22), "center")
py += 24
if done:
Style.button(self, "Wiederholen", Rect2(540, 500, 200, 50), _start.bind(selected))
elif next:
var b := Style.button(self, "Kämpfen", Rect2(540, 500, 200, 50), _start.bind(selected), Style.PRIMARY)
b.add_theme_font_size_override("font_size", 20)
else:
var b := Style.button(self, "Gesperrt", Rect2(540, 500, 200, 50), func(): pass)
b.disabled = true
if p.campaign_stage >= Campaign.total():
Style.clabel(col, "Alle Kapitel abgeschlossen", 16, Style.GREEN, "center", true)
var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
Style.label(self, "Alle Kapitel abgeschlossen", 16, Style.GREEN, Rect2(0, 600, 1280, 24), "center", true)
func set_chapter(c: int) -> void:
chapter = c
var p: Profile = app.profile
var first := (c - 1) * Campaign.STAGES.size()
selected = p.campaign_stage if Campaign.chapter(mini(p.campaign_stage, Campaign.total() - 1)) == c else first
build()
func _start(stage: int) -> void:
app.show_screen(BattleScreen.new(0, stage))
app.show_screen(BattleScreen.new(stage))
func _input(event: InputEvent) -> void:

View File

@@ -1,13 +1,13 @@
class_name CardGrid
extends ScrollContainer
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte.
## Scrollbares Kartenraster.
signal card_clicked(card: Cards.Card)
const COLS := 7
const CW := 100
const CH := 140
const GAP := 12
const COLS := 6
const CW := 112
const CH := 157
const GAP := 14
var grid: GridContainer
@@ -24,19 +24,16 @@ func _init(rect: Rect2) -> void:
add_child(grid)
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool; rarity_fn(card) -> String (Optik)
func fill(cards: Array, label_fn: Callable, dim_fn: Callable, rarity_fn: Callable = Callable()) -> void:
## cards: Karten; rarity_fn(card) -> String: die Stufe, die die Karte zeigt (Optik)
func fill(cards: Array, rarity_fn: Callable) -> void:
Style.clear(grid)
for c in cards:
var cell := Control.new()
cell.custom_minimum_size = Vector2(CW, CH + 16)
cell.custom_minimum_size = Vector2(CW, CH)
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c) if rarity_fn.is_valid() else "gewöhnlich")
v.dim = dim_fn.call(c)
var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c))
v.clicked.connect(func(_view): card_clicked.emit(c))
cell.add_child(v)
var text: String = label_fn.call(c)
Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center")
grid.add_child(cell)

View File

@@ -1,25 +1,37 @@
class_name CardView
extends Control
## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau).
## Zeichnet eine Karte (Laden, Hand, Sammlung, Booster, Vorschau).
## Aufbau wie bei Hearthstone: Kostenkristall oben links, Bild, Namensband, Schlüsselwörter als
## Symbole, Effekttext, Angriff und Leben als Kreise unten. Das Element steht im Hintergrund,
## die Seltenheit in Rahmen, Glanz und Edelstein. Sehr flache Karten (Hand) zeigen nur das Nötigste.
signal clicked(view: CardView)
signal hover(card: Cards.Card, entered: bool)
var card: Cards.Card
var border = null # Color oder null (dann Seltenheitsfarbe)
var rarity := "gewöhnlich" # nur Optik: Rahmenfarbe der eigenen Stufe
var border = null # Color oder null (dann Rahmen der Seltenheitsstufe)
var rarity := "gewöhnlich" # nur Optik
var dim := false
var bonus := 0 # Vorschau im Kampf: Angriff, den die Karte auf dem Feld dazubekäme
var tag := "" # Vorschau im Kampf: Feldkombo, die das Ausspielen auslöst
var lift := false # hebt sich beim Zeigen an (Hand und Laden)
## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "gewöhnlich") -> void:
card = c
rarity = rar
set_process(Style.rarity_animated(rarity)) # bewegter Glanz
custom_minimum_size = sz
size = sz
pivot_offset = sz / 2
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(card, true))
mouse_exited.connect(func(): hover.emit(card, false))
mouse_entered.connect(func():
hover.emit(card, true)
_lift(true))
mouse_exited.connect(func():
hover.emit(card, false)
_lift(false))
if focusable:
focus_mode = Control.FOCUS_ALL
focus_entered.connect(func():
@@ -30,6 +42,17 @@ func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "g
queue_redraw())
func _lift(up: bool) -> void:
if not lift or not Style.animations:
return
z_index = 1 if up else 0
create_tween().tween_property(self, "scale", Vector2(1.07, 1.07) if up else Vector2.ONE, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func _process(_delta: float) -> void:
queue_redraw()
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
@@ -41,47 +64,135 @@ func _gui_input(event: InputEvent) -> void:
func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size)
var big := size.x >= 180
var mid := size.x >= 115
var f_name := 18 if big else (14 if mid else 12)
var f_text := 14 if big else (11 if mid else 10)
var f_stat := 24 if big else (18 if mid else 15)
var seed := hash(card.id)
var frame: Color = border if border != null else Style.rarity_frame(rarity, seed)
if rarity != "gewöhnlich":
Style.glow(self, rect, Style.rarity_color(rarity, seed), 8, 8 if size.x >= 180 else 6)
Style.fill_elements(self, rect, card.elements, 8)
var bcol: Color = border if border != null else Cards.RARITY_COLORS[rarity]
Style.border(self, rect, bcol, 3 if big else 2, 8)
# Kosten
var r := 16 if big else 12
var cc := Vector2(r + 4, r + 4)
draw_circle(cc, r, Color("14141c"))
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true)
# Name und Kombo
var name_left := 2 * r + 10
var nm := Style.fit(card.title(), true, f_name, size.x - name_left - 6)
Style.text(self, nm, f_name, Style.TEXT, Vector2((name_left + size.x - 6) / 2.0, 4), "midtop", true)
if mid:
var sub: String = tr(card.combo()) if card.is_combo() else ""
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop")
# Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest
var tsize := Style.fs(f_text)
var text_w: float = size.x - 20
var needed: float = Style.FONT.get_multiline_string_size(card.text(), HORIZONTAL_ALIGNMENT_LEFT, text_w, tsize).y + 8 if card.text() != "" else size.y * 0.42
var box_bottom: float = size.y - (28 if big else 22)
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0) # unter Name und Kombo
var box_top: float = maxf(maxf(size.y * 0.30, band_top), box_bottom - maxf(needed, size.y * 0.42))
var box := Rect2(6, box_top, size.x - 12, box_bottom - box_top)
# Bild
var band_bottom: float = box_top - 4
if band_bottom - band_top > 14:
var gsize: float = minf(band_bottom - band_top, size.x * 0.42)
Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize)
draw_rect(box, Color(0.06, 0.06, 0.09, 0.67))
var max_lines := maxi(1, int((box.size.y - 6) / Style.FONT.get_height(tsize))) # große Schrift: lieber kürzen als überlaufen
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)),
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
# Werte
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
if size.y < 90:
_draw_compact(rect, frame, seed)
else:
_draw_full(rect, frame, seed)
if dim:
draw_rect(rect, Color(0, 0, 0, 0.47))
if has_focus():
Style.border(self, rect, Style.GOLD, 4, 8)
## Volle Karte: Laden, Sammlung, Booster, Vorschau.
func _draw_full(rect: Rect2, frame: Color, seed: int) -> void:
var big := size.x >= 180
var mid := size.x >= 140
var f_name := 17 if big else (14 if mid else 11)
var f_text := 13 if big else (11 if mid else 10)
var f_stat := 20 if big else (17 if mid else 14)
var r_cost := 17.0 if big else (14.0 if mid else 12.0)
var r_stat := 18.0 if big else (15.0 if mid else 12.0)
var icon := 11.0 if big else (9.0 if mid else 7.0)
# Bild: das Elementsymbol füllt den oberen Teil
var band_top: float = size.y * 0.36
Style.glyph(self, card, Vector2(size.x / 2, band_top * 0.56), minf(size.x * 0.62, band_top * 0.95), 0.4)
Style.rarity_sheen(self, rect, rarity, seed)
# Namensband über die volle Breite
var band_h: float = Style.fs(f_name) + 10
var band := Rect2(0, band_top, size.x, band_h)
draw_rect(band, Color(0.05, 0.05, 0.08, 0.82))
var nf := Style.fit_size(card.title(), true, f_name, size.x - 12)
Style.text(self, Style.fit(card.title(), true, Style.fs(nf), size.x - 12), nf, Style.TEXT, Vector2(size.x / 2, band.get_center().y), "center", true)
var y := band.end.y + 4
# Kombo eines verschmolzenen Wesens
if card.is_combo():
Style.text(self, tr(card.combo()), f_text, Color("e6e6e6"), Vector2(size.x / 2, y), "midtop")
y += Style.fs(f_text) + 4
# Schlüsselwörter als Symbole, auf großen Karten mit Wort
var kws: Array[String] = []
for k in Cards.KEYWORDS:
if k in card.keywords:
kws.append(k)
if not kws.is_empty():
var with_words := big
var widths: Array[float] = []
var total := 0.0
for k in kws:
var w: float = icon * 2 + 6 + (Style.FONT.get_string_size(tr(k), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(f_text)).x + 8 if with_words else 0)
widths.append(w)
total += w + 6
if with_words and total > size.x - 12:
with_words = false
total = kws.size() * (icon * 2 + 12)
var x := (size.x - total + 6) / 2
for i in kws.size():
var k := kws[i]
Style.keyword_icon(self, k, Vector2(x + icon, y + icon + 2), icon)
if with_words:
Style.text(self, tr(k), f_text, Style.TEXT, Vector2(x + icon * 2 + 6, y + icon + 2), "midleft")
x += widths[i] + 6
else:
x += icon * 2 + 12
y += icon * 2 + 8
# Effekttext: so hoch wie nötig, endet über den Wertkreisen
var box := Rect2()
var fx := card.effect_text()
var box_bottom: float = size.y - r_stat * 2 - 4
if fx != "" and box_bottom - y > Style.fs(f_text) + 6:
var tsize := Style.fs(f_text)
var heading := big
var needed: float = Style.FONT.get_multiline_string_size(fx, HORIZONTAL_ALIGNMENT_LEFT, size.x - 20, tsize).y + 8 + (tsize + 2 if heading else 0)
var box_top: float = maxf(y, y + (box_bottom - y - needed) / 2)
box = Rect2(6, box_top, size.x - 12, box_bottom - box_top)
draw_rect(box, Color(0.06, 0.06, 0.09, 0.72))
var ty := box.position.y + 4
if heading:
Style.text(self, tr("Beim Ausspielen"), f_text - 2, Style.MUTED, Vector2(box.position.x + 4, ty), "topleft")
ty += tsize + 2
var max_lines := maxi(1, int((box.end.y - ty - 2) / Style.FONT.get_height(tsize)))
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, ty + Style.FONT.get_ascent(tsize)),
fx, HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
# Werte springen aus den unteren Ecken, dazwischen der Edelstein
Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat)
Style.stat_circle(self, Vector2(size.x - r_stat + 2, size.y - r_stat + 2), r_stat, str(card.health), Style.CRIMSON, f_stat)
if bonus > 0:
Style.text_outlined(self, "+%d" % bonus, f_text, Style.GREEN, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3)
Style.rarity_gem(self, rarity, Vector2(size.x / 2, size.y - r_stat + 1), 8 if big else 6, seed)
Style.border(self, rect, frame, 3 if big else 2, 8)
Style.cost_crystal(self, Vector2(r_cost + 2, r_cost + 2), r_cost, card.cost, f_stat)
if tag != "":
_draw_tag(Vector2(size.x - 6, 6), f_text - 1)
Style.rarity_sparkle(self, rect, rarity, seed, box)
## Flache Handkachel: Kosten, Name, Symbole, Werte.
func _draw_compact(rect: Rect2, frame: Color, seed: int) -> void:
Style.rarity_sheen(self, rect, rarity, seed)
Style.border(self, rect, frame, 2, 8)
Style.cost_crystal(self, Vector2(13, 13), 11, card.cost, 13)
var name_w: float = size.x - 30 - (12 if rarity != "gewöhnlich" else 0)
var nf := Style.fit_size(card.title(), true, 12, name_w)
Style.text(self, Style.fit(card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(28, 13), "midleft", true)
Style.rarity_gem(self, rarity, Vector2(size.x - 9, 13), 5, seed)
if tag != "":
var w := Style.FONT_BOLD.get_string_size(tr(tag), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(9)).x + 12
_draw_tag(Vector2(size.x / 2 + w / 2, size.y - 21), 9)
else:
var kws: Array[String] = []
for k in Cards.KEYWORDS:
if k in card.keywords:
kws.append(k)
var x := size.x / 2 - (kws.size() * 18 - 4) / 2.0
for k in kws:
Style.keyword_icon(self, k, Vector2(x + 6, size.y - 13), 6)
x += 18
Style.stat_circle(self, Vector2(11, size.y - 11), 10, str(card.attack), Style.AMBER, 12)
Style.stat_circle(self, Vector2(size.x - 11, size.y - 11), 10, str(card.health), Style.CRIMSON, 12)
if bonus > 0:
Style.text_outlined(self, "+%d" % bonus, 10, Style.GREEN, Vector2(4, size.y - 29), "midleft", 3)
Style.rarity_sparkle(self, rect, rarity, seed)
## Goldenes Schild mit der Feldkombo, rechts oben.
func _draw_tag(top_right: Vector2, font: int) -> void:
var w := Style.FONT_BOLD.get_string_size(tr(tag), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(font)).x + 12
var h := Style.fs(font) + 6
var r := Rect2(top_right - Vector2(w, 0), Vector2(w, h))
draw_style_box(Style.box(Color("14141c"), 6, Style.GOLD, 1), r)
Style.text(self, tr(tag), font, Style.GOLD, r.get_center(), "center", true)

View File

@@ -18,33 +18,32 @@ var b_upgrade: Button
func _ready() -> void:
Style.label(self, "Sammlung", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
Style.header(self, "Sammlung", func(): app.show_screen(MenuScreen.new()))
Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
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)
search_box = LineEdit.new()
search_box.position = Vector2(424, 55)
search_box.size = Vector2(200, 34)
search_box.position = Vector2(444, 86)
search_box.size = Vector2(220, 34)
search_box.placeholder_text = "Suche"
search_box.text_changed.connect(func(t: String):
search = t
refresh())
add_child(search_box)
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
grid.card_clicked.connect(select)
add_child(grid)
Style.panel(self, Rect2(845, 50, 420, 680))
dust_label = Style.label(self, "", 22, Style.BLUE, Rect2(860, 58, 390, 30), "left", true)
Style.panel(self, Rect2(820, 86, 420, 696))
dust_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
preview_holder = Control.new()
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(preview_holder)
info_label = Style.label(self, "", 14, Style.MUTED, Rect2(860, 396, 390, 66), "center")
b_upgrade = Style.button(self, "", Rect2(860, 480, 390, 44), upgrade, Color("285a3c"))
b_dismantle = Style.button(self, "", Rect2(860, 534, 390, 44), dismantle, Color("5a3c28"))
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 600, 390, 48), "center")
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
info_label = Style.label(self, "", 15, Style.MUTED, Rect2(840, 476, 380, 30), "center")
b_upgrade = Style.button(self, "", Rect2(840, 530, 380, 46), upgrade, Style.PRIMARY)
b_dismantle = Style.button(self, "", Rect2(840, 586, 380, 46), dismantle, Color("5a3c28"))
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(840, 650, 380, 48), "center")
refresh()
@@ -58,6 +57,7 @@ func set_filter(e: String) -> void:
refresh()
## Aufgewertete Karten zuerst, sonst in Kartenreihenfolge.
func cards() -> Array:
var out: Array = Cards.all().duplicate()
if filter != "":
@@ -65,6 +65,11 @@ func cards() -> Array:
if search != "":
var q := search.to_lower()
out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
var p: Profile = app.profile
var order := {}
for i in out.size():
order[out[i].id] = i
out.sort_custom(func(a, b): return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id])
return out
@@ -109,7 +114,7 @@ func dismantle() -> void:
func refresh() -> void:
var p: Profile = app.profile
dust_label.text = "Staub: %d" % p.dust
grid.fill(cards(), func(c): return tr(p.rarity(c.id)), func(_c): return false, func(c): return p.rarity(c.id))
grid.fill(cards(), func(c): return p.rarity(c.id))
Style.clear(preview_holder)
if selected == null:
info_label.text = "Keine Karte gewählt"
@@ -118,8 +123,8 @@ func refresh() -> void:
b_upgrade.disabled = true
b_dismantle.disabled = true
return
var v := CardView.new(selected, Vector2(200, 280), false, p.rarity(selected.id))
v.position = Vector2(955, 100)
var v := CardView.new(selected, Vector2(220, 308), false, p.rarity(selected.id))
v.position = Vector2(920, 152)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
var lvl := p.level(selected.id)

View File

@@ -11,6 +11,8 @@ var width := 2
var flash := false # kurz rot: wurde gerade angegriffen
var slot: Vector2 # Zielposition im Raster
var busy := false # wird gerade animiert (kein Neupositionieren)
var mark := "" # Feldkombo, die dieses Wesen als Partner auslösen würde
var matchup := 0 # Elementvorteil des gewählten Angreifers gegen dieses Wesen (+1/-1)
func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
@@ -20,58 +22,93 @@ func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
custom_minimum_size = sz
size = sz
pivot_offset = sz / 2
set_process(Style.rarity_animated(rarity())) # bewegter Glanz
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(creature.card, true))
mouse_exited.connect(func(): hover.emit(creature.card, false))
func _process(_delta: float) -> void:
queue_redraw()
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit(self)
func rarity() -> String:
var owner = creature.owner()
return Cards.RARITIES[int(owner.looks.get(creature.card.id, 0)) if owner != null else 0]
func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size)
var small := size.x < 100
var mini := size.x < 60
var rar := rarity()
var seed := hash(creature.card.id) + creature.uid
if rar != "gewöhnlich":
Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 4 if mini else 6)
Style.fill_elements(self, rect, creature.card.elements, 6)
var owner = creature.owner()
var lvl: int = int(owner.looks.get(creature.card.id, 0)) if owner != null else 0
var bcol: Color = border if border != null else Cards.RARITY_COLORS[Cards.RARITIES[lvl]]
if not mini:
Style.glyph(self, creature.card, Vector2(size.x / 2, size.y * 0.5), size.x * 0.75, 0.3)
Style.rarity_sheen(self, rect, rar, seed)
var bcol: Color = border if border != null else Style.rarity_frame(rar, seed)
if flash:
bcol = Style.RED
Style.border(self, rect, bcol, 4 if flash else width, 6)
if mark != "":
Style.border(self, rect.grow(3), Style.GOLD, 3, 8)
if mini:
_draw_mini()
Style.rarity_sparkle(self, rect, rar, seed)
return
var fs := 11 if small else 13
var nm := Style.fit(creature.card.title(), true, fs, size.x - 6)
Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true)
var badges: Array[String] = []
if creature.taunt():
badges.append("Wächter")
if creature.shield:
badges.append("Schild")
if creature.attacks_per_turn() == 2:
badges.append("x2")
if creature.frozen:
badges.append("Eis")
if creature.resonance > 0:
badges.append("Resonanz")
if creature.field_bonus > 0:
badges.append("Feld")
var y := 18.0 if small else 22.0
for b in badges:
Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop")
y += 12 if small else 15
var f := 16 if small else 20
var atk_col := Style.GREEN if creature.resonance + creature.field_bonus > 0 else Style.YELLOW
Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true)
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true)
var name_w: float = size.x - (30 if matchup != 0 else 6) # Platz für den Vorteilskreis oben rechts
var nf := Style.fit_size(creature.card.title(), true, fs, name_w)
draw_rect(Rect2(2, 2, size.x - 4, Style.fs(fs) + 8), Color(0.05, 0.05, 0.08, 0.7))
Style.text(self, Style.fit(creature.card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(3 + name_w / 2, 6), "midtop", true)
# Schlüsselwörter als Symbole
var icon := 7.0 if small else 9.0
var kws: Array[String] = []
for k in Cards.KEYWORDS:
if k in creature.card.keywords:
kws.append(k)
if creature.shield and not ("Schild" in kws):
kws.append("Schild")
var x := size.x / 2 - (kws.size() * (icon * 2 + 6) - 6) / 2.0
var y := Style.fs(fs) + 14 + icon
for k in kws:
Style.keyword_icon(self, k, Vector2(x + icon, y), icon)
x += icon * 2 + 6
_draw_stats(15 if small else 17, 10 if small else 12, 12.0 if small else 14.0)
Style.rarity_gem(self, rar, Vector2(size.x / 2, size.y - 9), 5, seed)
if mark != "":
var w := Style.FONT_BOLD.get_string_size(tr(mark), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(11)).x + 14
draw_style_box(Style.box(Color("14141c"), 6, Style.GOLD, 1), Rect2(size.x / 2 - w / 2, -9, w, 18))
Style.text(self, tr(mark), 11, Style.GOLD, Vector2(size.x / 2, 0), "center", true)
if creature.frozen:
draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35))
Style.text_outlined(self, "❄", 22 if small else 28, Color("dff2ff"), size / 2, "center", 3)
Style.rarity_sparkle(self, rect, rar, seed)
## Angriff (grün mit „+n“, wenn Resonanz, Feld oder Merkmale ihn heben), Leben, Elementvorteil.
func _draw_stats(f: int, f_small: int, r: float) -> void:
var extra := creature.power() - creature.attack
var atk_col := Style.GREEN if extra > 0 else Style.AMBER
Style.stat_circle(self, Vector2(r - 1, size.y - r + 1), r, str(creature.power()), atk_col, f)
if extra > 0:
Style.text_outlined(self, "+%d" % extra, f_small, Style.GREEN, Vector2(r * 2 + 3, size.y - r + 1), "midleft", 3)
var hp_col := Style.CRIMSON if creature.health < creature.max_health else Color("b84a4a")
Style.stat_circle(self, Vector2(size.x - r + 1, size.y - r + 1), r, str(creature.health), hp_col, f)
if matchup != 0:
var c := Vector2(size.x - 12, 12)
draw_circle(c, 11, Color("14141c"))
Style.text(self, ("+%d" % matchup) if matchup > 0 else ("−%d" % -matchup), f_small + 1,
Style.GREEN if matchup > 0 else Style.RED, c, "center", true)
## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe.
@@ -84,15 +121,14 @@ func _draw_mini() -> void:
if creature.attacks_per_turn() == 2:
letters.append("2")
if creature.frozen:
letters.append("E")
if creature.resonance > 0:
letters.append("R")
if creature.field_bonus > 0:
letters.append("F")
letters.append("❄")
Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true)
var atk_col := Style.GREEN if creature.resonance + creature.field_bonus > 0 else Style.YELLOW
Style.text(self, str(creature.power()), 13, atk_col, Vector2(4, size.y - 2), "bottomleft", true)
var extra := creature.power() - creature.attack
Style.text_outlined(self, str(creature.power()), 13, Style.GREEN if extra > 0 else Style.YELLOW, Vector2(9, size.y - 9), "center", 3)
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true)
Style.text_outlined(self, str(creature.health), 13, hp_col, Vector2(size.x - 9, size.y - 9), "center", 3)
if matchup != 0:
Style.text(self, ("+%d" % matchup) if matchup > 0 else ("−%d" % -matchup), 10,
Style.GREEN if matchup > 0 else Style.RED, Vector2(size.x - 3, 2), "topright", true)
if creature.frozen:
draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35))

53
scripts/ui/field_view.gd Normal file
View File

@@ -0,0 +1,53 @@
class_name FieldView
extends Control
## Das gemeinsame Feld: je Element vier Ladepunkte, bei vollen Punkten kippt das Feld.
## Das aktive Feld steht farbig mit seinem Bonus da.
var element := ""
var charge := {}
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from(g: Game) -> void:
element = g.field_element
charge = g.field_charge.duplicate()
queue_redraw()
## Der Inhalt als Text (Tests, Protokoll).
func text() -> String:
var parts: Array[String] = []
for e in Cards.ELEMENTS:
if e == element:
parts.append("%s: +1 %s" % [tr(Cards.FIELD_NAMES[e]), tr("Angriff")])
else:
parts.append("%s %d/%d" % [tr(e), int(charge.get(e, 0)), Cards.FIELD_CHARGE])
return " · ".join(parts)
func _draw() -> void:
var w := size.x / Cards.ELEMENTS.size()
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var col: Color = Cards.ELEMENT_COLORS[e]
var x0 := i * w
var cy := size.y / 2
if e == element:
draw_style_box(Style.box(Color(col, 0.35), 6, col, 2), Rect2(x0 + 2, 0, w - 4, size.y))
Style.text(self, "%s +1 %s" % [tr(Cards.FIELD_NAMES[e]), tr("Angriff")], 13, Style.lighter(col, 0.4), Vector2(x0 + w / 2, cy), "center", true)
continue
Style.text(self, tr(e), 13, Style.MUTED, Vector2(x0 + 12, cy), "midleft")
var n := int(charge.get(e, 0))
var px := x0 + 12 + Style.FONT.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 14
for k in Cards.FIELD_CHARGE:
var c := Vector2(px + k * 13, cy)
if k < n:
draw_circle(c, 4.5, col)
else:
draw_circle(c, 4.5, Color(0, 0, 0, 0.35))
draw_arc(c, 4.5, 0, TAU, 16, Color(col, 0.6), 1.0)

View File

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

View File

@@ -11,8 +11,6 @@ var b_host: Button
var b_join: Button
var b_watch: Button
var b_start: Button
var ai_buttons: Array = []
var n_ai := 0
var addr_label: Label
@@ -20,9 +18,8 @@ func _ready() -> void:
net = app.net
net.my_name = "Spieler"
net.my_pick = {"looks": app.profile.looks()}
var col := Style.column(self, 760, 40, 10)
Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true)
Style.spacer(col, 10)
Style.header(self, "Netzwerkspiel", _back)
var col := Style.column(self, 760, 110, 10)
var hr := Style.row(col, 12)
hr.alignment = BoxContainer.ALIGNMENT_CENTER
b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c"))
@@ -38,17 +35,10 @@ func _ready() -> void:
Style.spacer(col, 6)
Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true)
list_label = Style.clabel(col, "", 16, Style.TEXT)
Style.spacer(col, 6)
Style.clabel(col, "KI-Gegner", 16, Style.MUTED)
var ar := Style.row(col, 10)
ar.alignment = BoxContainer.ALIGNMENT_CENTER
for n in range(0, 5):
ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 40), func(): _set_ai(n)))
Style.spacer(col, 10)
var br := Style.row(col, 20)
br.alignment = BoxContainer.ALIGNMENT_CENTER
b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c"))
Style.cbutton(br, "Zurück", Vector2(160, 48), _back)
net.lobby_changed.connect(refresh)
net.started.connect(_started)
net.ended.connect(func(_reason): refresh())
@@ -79,13 +69,8 @@ func _watch() -> void:
refresh()
func _set_ai(n: int) -> void:
n_ai = n
refresh()
func _start() -> void:
net.start_battle(n_ai)
net.start_battle()
func _started(seat: int) -> void:
@@ -112,9 +97,8 @@ func refresh() -> void:
b_watch.disabled = net.connected
ip_box.editable = not net.connected
var humans := ids.size()
b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1)
for i in ai_buttons.size():
ai_buttons[i].disabled = not net.is_host or i == n_ai or humans + i > Game.MAX_OPPONENTS + 1
b_start.disabled = not net.is_host
b_start.text = tr("Start") if humans >= 2 else tr("Start gegen die KI")
addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else ""

View File

@@ -32,13 +32,13 @@ func apply_settings() -> void:
sfx.set_music_volume(profile.settings.music)
set_language(profile.settings.language)
Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0
Style.animations = bool(profile.settings.get("animations", true))
theme = Style.make_theme()
## Neues Profil: Sammlung und Fortschritt werden verworfen.
func reset_profile() -> void:
profile = Profile.starter()
clear_battle_save()
save()
apply_settings()
@@ -54,6 +54,9 @@ func show_screen(s: Control) -> void:
s.app = self
s.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(s)
if Style.animations:
s.modulate.a = 0.0
create_tween().tween_property(s, "modulate:a", 1.0, 0.15)
if pad_mode:
focus_first_button.call_deferred()
@@ -107,16 +110,6 @@ func save() -> void:
profile.save(save_path)
## Datei für einen unterbrochenen Kampf (neben dem Spielstand).
func battle_save_path() -> String:
return save_path.get_basename() + "_kampf.json"
func clear_battle_save() -> void:
if FileAccess.file_exists(battle_save_path()):
DirAccess.remove_absolute(ProjectSettings.globalize_path(battle_save_path()))
func _exit_tree() -> void:
if net != null and net.is_inside_tree():
net.leave()

View File

@@ -1,53 +1,55 @@
class_name MenuScreen
extends Control
## Hauptmenü: links Titel und Spielarten, rechts drei Karten der eigenen Sammlung als Schaufenster.
var app
var b_resume: Button
var coins_label: Label
var coll_label: Label
var notice_label: Label
func _ready() -> void:
var col := Style.column(self, 640, 40, 5)
Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true)
Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED)
Style.spacer(col, 8)
coins_label = Style.clabel(col, "", 20, Style.GOLD)
coll_label = Style.clabel(col, "", 16, Style.MUTED)
Style.spacer(col, 8)
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 12)
grid.add_theme_constant_override("v_separation", 8)
var center := CenterContainer.new()
center.add_child(grid)
col.add_child(center)
var sz := Vector2(270, 44)
if FileAccess.file_exists(app.battle_save_path()):
b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628"))
Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
Style.cbutton(grid, "Sammlung", sz, func(): app.show_screen(CollectionScreen.new()))
Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new()))
Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit())
Style.spacer(col, 10)
notice_label = Style.clabel(col, "", 16, Style.GREEN)
var p: Profile = app.profile
Style.label(self, "Dinger", 76, Style.GOLD, Rect2(80, 64, 600, 90), "left", true)
Style.label(self, "Ein Kartenspiel: Duell um das Feld", 22, Style.MUTED, Rect2(84, 156, 600, 30), "left")
coins_label = Style.chip(self, "", Rect2(930, 20, 310, 38))
coll_label = Style.label(self, "", 15, Style.MUTED, Rect2(700, 64, 540, 24), "right")
var y := 230
for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())],
["Kampagne", func(): app.show_screen(CampaignScreen.new())],
["Netzwerkspiel", func(): app.show_screen(LobbyScreen.new())]]:
var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY)
b.add_theme_font_size_override("font_size", 24)
y += 74
y += 18
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())],
["Beenden", func(): get_tree().quit()]]:
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
x += 176
notice_label = Style.label(self, "", 16, Style.GREEN, Rect2(80, y + 70, 760, 60), "left")
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
_showcase(p)
refresh()
## Unterbrochenen Kampf laden; eine unlesbare Datei wird verworfen.
func _resume() -> void:
var json := JSON.new()
var text := FileAccess.get_file_as_string(app.battle_save_path())
if json.parse(text) != OK or not (json.data is Dictionary) or not json.data.has("game"):
app.clear_battle_save()
app.show_screen(MenuScreen.new())
return
app.show_screen(BattleScreen.resume(json.data))
## Drei Karten der Sammlung, die höchsten Stufen zuerst, leicht gefächert.
func _showcase(p: Profile) -> void:
var cards: Array = Cards.all().duplicate()
cards.sort_custom(func(a, b):
if p.level(a.id) != p.level(b.id):
return p.level(a.id) > p.level(b.id)
return hash(a.id) % 97 < hash(b.id) % 97)
var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]]
for i in 3:
var c: Cards.Card = cards[i]
var v := CardView.new(c, Vector2(180, 252), false, p.rarity(c.id))
v.position = spots[i][0]
v.rotation = spots[i][1]
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
func refresh() -> void:

View File

@@ -1,79 +1,54 @@
class_name OpponentSelectScreen
extends Control
## Einzelspieler: Schwierigkeit und Gegnerzahl wählen. Die Karten kommen im Kampf
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf
## aus dem Laden (siehe DeckGen.shop_cards).
var app
var diff_buttons := {}
var count_slider: HSlider
var count_label: Label
var info_label: Label
func _ready() -> void:
var col := Style.column(self, 1000, 50, 12)
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true)
Style.spacer(col, 8)
Style.header(self, "Einzelspieler", func(): app.show_screen(MenuScreen.new()))
var col := Style.column(self, 1000, 170, 12)
Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true)
var dr := Style.row(col, 10)
dr.alignment = BoxContainer.ALIGNMENT_CENTER
for key in Profile.DIFFICULTIES:
var k: String = key
diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k))
Style.spacer(col, 20)
count_label = Style.clabel(col, "", 20, Style.TEXT, "center", true)
var sr := Style.row(col, 12)
sr.alignment = BoxContainer.ALIGNMENT_CENTER
Style.clabel(sr, "1", 15, Style.MUTED, "right", false, 20)
count_slider = HSlider.new()
count_slider.min_value = 1
count_slider.max_value = Game.MAX_OPPONENTS
count_slider.step = 1
count_slider.value = 1
count_slider.tick_count = Game.MAX_OPPONENTS
count_slider.ticks_on_borders = true
count_slider.custom_minimum_size = Vector2(420, 32)
count_slider.value_changed.connect(func(_v): refresh())
sr.add_child(count_slider)
Style.clabel(sr, str(Game.MAX_OPPONENTS), 15, Style.MUTED, "left", false, 20)
info_label = Style.clabel(col, "", 15, Style.MUTED)
Style.spacer(col, 24)
var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Starten", Vector2(190, 46), start, Color("285a3c"))
Style.cbutton(br, "Zurück", Vector2(150, 46), func(): app.show_screen(MenuScreen.new()))
var b := Style.cbutton(br, "Starten", Vector2(260, 56), start, Style.PRIMARY)
b.add_theme_font_size_override("font_size", 22)
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
func set_difficulty(key: String) -> void:
app.profile.settings.difficulty = key
app.save()
refresh()
func opponents() -> int:
return int(count_slider.value)
func start() -> void:
app.show_screen(BattleScreen.new(opponents()))
app.show_screen(BattleScreen.new())
func refresh() -> void:
var p: Profile = app.profile
for key in diff_buttons:
_mark(diff_buttons[key], key == p.settings.difficulty)
var n := opponents()
count_label.text = tr("1 Gegner") if n == 1 else tr("%d Gegner") % n
info_label.text = (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n]
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)))]
func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void:
var base: Color = col if active else Style.PANEL_LIGHT
b.add_theme_stylebox_override("normal", Style.box(base, 8, Style.GOLD if active else Style.MUTED, 2))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 8, Style.GOLD if active else Style.MUTED, 2))
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())

View File

@@ -16,9 +16,8 @@ var speed_label: Label
func _ready() -> void:
var s: Dictionary = app.profile.settings
var col := Style.column(self, 700, 50, 24)
Style.clabel(col, "Einstellungen", 36, Style.GOLD, "center", true)
Style.spacer(col, 30)
Style.header(self, "Einstellungen", func(): app.show_screen(MenuScreen.new()))
var col := Style.column(self, 700, 110, 24)
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 24)
@@ -80,7 +79,6 @@ func _ready() -> void:
var r := Style.row(col, 16)
r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.",
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
_labels()
@@ -135,6 +133,7 @@ func _on_speed(v: float) -> void:
func _on_anim(on: bool) -> void:
app.profile.settings.animations = on
Style.animations = on
app.save()

View File

@@ -18,6 +18,8 @@ func _ready() -> void:
streams["damage"] = _tone(140, 0.12, "square")
streams["heal"] = _tone(523, 0.22, "sine", 784)
streams["combo"] = _tone(660, 0.2, "sine", 1100)
streams["buy"] = _tone(1200, 0.08, "sine", 1700)
streams["turn"] = _tone(520, 0.3, "sine", 1040)
streams["win"] = _tone(400, 0.6, "sine", 1200)
streams["lose"] = _tone(320, 0.7, "square", 90)
for i in 4:

View File

@@ -4,6 +4,7 @@ class_name Style
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
static var font_scale := 1.0 # Einstellung „Große Schrift“
static var animations := true # Einstellung „Animationen“: der Glanz seltener Karten bewegt sich
const BG := Color("1a1c24")
const PANEL := Color("282b36")
@@ -17,6 +18,11 @@ const BLUE := Color("6eaaf0")
const YELLOW := Color("ffe65a")
const ORANGE := Color("ff963c")
const PURPLE := Color("8c78dc")
const AMBER := Color("f0b429") # Angriff
const CRIMSON := Color("d64545") # Leben
const MANA := Color("3a78d2") # Kostenkristall
const PRIMARY := Color("285a3c") # Hauptaktion
const DANGER := Color("5a2828")
## Schriftgröße mit der Einstellung „Große Schrift“.
@@ -80,7 +86,11 @@ static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8,
var p := Panel.new()
p.position = rect.position
p.size = rect.size
p.add_theme_stylebox_override("panel", box(bg, radius, border_col, border_w))
var sb := box(bg, radius, border_col, border_w)
sb.shadow_color = Color(0, 0, 0, 0.35)
sb.shadow_size = 6
sb.shadow_offset = Vector2(0, 3)
p.add_theme_stylebox_override("panel", sb)
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(p)
return p
@@ -115,10 +125,35 @@ static func button(parent: Node, text: String, rect: Rect2, on_pressed: Callable
b.add_theme_stylebox_override("normal", box(color, 8, MUTED, 2))
b.add_theme_stylebox_override("hover", box(lighter(color), 8, MUTED, 2))
b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2))
juice(b)
parent.add_child(b)
return b
## Knöpfe federn beim Drücken kurz ein.
static func juice(b: Button) -> void:
b.button_down.connect(func():
if animations:
b.pivot_offset = b.size / 2
b.create_tween().tween_property(b, "scale", Vector2(0.96, 0.96), 0.05))
b.button_up.connect(func():
if animations:
b.pivot_offset = b.size / 2
b.create_tween().tween_property(b, "scale", Vector2.ONE, 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT))
## Kopfzeile eines Unterbildschirms: Titel links, Zurück rechts oben.
static func header(parent: Node, title: String, on_back: Callable) -> Button:
label(parent, title, 34, GOLD, Rect2(40, 22, 700, 46), "left", true)
return button(parent, "Zurück", Rect2(1120, 26, 120, 40), on_back)
## Kleine Kachel mit Text, etwa für Münzen und Staub.
static func chip(parent: Node, text: String, rect: Rect2, color: Color = GOLD) -> Label:
panel(parent, rect, darker(PANEL, 0.9), rect.size.y / 2, PANEL_LIGHT, 1)
return label(parent, text, 17, color, rect, "center", true)
## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme).
static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer:
var v := VBoxContainer.new()
@@ -230,6 +265,33 @@ static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vec
ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
## Text mit dunklem Rand: bleibt auf jeder Farbe lesbar (Werte, Kosten).
static func text_outlined(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "center", outline: int = 4) -> void:
var size := fs(size_in)
var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var x := pos.x
var y := pos.y
match anchor:
"center":
x -= dim.x / 2
y -= dim.y / 2
"midtop":
x -= dim.x / 2
"midleft":
y -= dim.y / 2
var at := Vector2(x, y + FONT_BOLD.get_ascent(size))
ci.draw_string_outline(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, outline, Color(0.05, 0.05, 0.08, 0.95))
ci.draw_string(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
## Schriftgröße, mit der ein Text in eine Breite passt (bis zur Mindestgröße).
static func fit_size(s: String, bold: bool, size: int, max_w: float, min_size: int = 9) -> int:
var font: Font = FONT_BOLD if bold else FONT
while size > min_size and font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, fs(size)).x > max_w:
size -= 1
return size
static func fit(s: String, bold: bool, size: int, max_w: float) -> String:
var font: Font = FONT_BOLD if bold else FONT
while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3:
@@ -246,6 +308,88 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius
ci.draw_style_box(sb, rect)
# --- Seltenheit als Aussehen ------------------------------------------------------
## Ab episch bewegt sich der Glanz; dann zeichnet die Karte jeden Frame neu.
static func rarity_animated(rar: String) -> bool:
return animations and Cards.RARITIES.find(rar) >= 2
## Farbe einer Stufe; mythisch schillert über die Zeit durch alle Farben.
static func rarity_color(rar: String, seed: int = 0) -> Color:
if rar == "mythisch" and animations:
return Color.from_hsv(fmod(Time.get_ticks_msec() / 1000.0 * 0.12 + (seed % 97) / 97.0, 1.0), 0.55, 1.0)
return Cards.RARITY_COLORS[rar]
## Rahmenfarbe einer Stufe: gewöhnlich bleibt schlicht, alles darüber trägt seine Farbe.
static func rarity_frame(rar: String, seed: int = 0) -> Color:
return Color("3a3e4c") if rar == "gewöhnlich" else rarity_color(rar, seed)
## Lichtstreifen wie auf einer Folienkarte: selten steht er, ab episch wandert er über die Karte.
static func rarity_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> void:
var lvl := Cards.RARITIES.find(rar)
if lvl < 1:
return
var phase := (seed % 100) / 100.0
if lvl >= 2 and animations:
phase = fmod(Time.get_ticks_msec() / 2500.0 + phase, 1.0)
var frame := PackedVector2Array([rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)])
var w := rect.size.x * 0.34
var k := rect.size.y * 0.75 # Schräge
var x0 := rect.position.x - w + (rect.size.x + w + k) * phase
for layer in [[1.0, 0.07], [0.55, 0.09], [0.22, 0.13]]:
var lw: float = w * layer[0]
var lx := x0 + (w - lw) / 2
var band := PackedVector2Array([Vector2(lx, rect.position.y), Vector2(lx + lw, rect.position.y), Vector2(lx + lw - k, rect.end.y), Vector2(lx - k, rect.end.y)])
for poly in Geometry2D.intersect_polygons(band, frame):
ci.draw_colored_polygon(poly, Color(1, 1, 1, layer[1]))
## Funkeln ab legendär: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen.
## avoid: Bereich, der frei bleibt (der Kartentext).
static func rarity_sparkle(ci: CanvasItem, rect: Rect2, rar: String, seed: int, avoid: Rect2 = Rect2()) -> void:
var lvl := Cards.RARITIES.find(rar)
if lvl < 3:
return
var t := Time.get_ticks_msec() / 1000.0
var n := 5 if lvl == 3 else 9
for i in n:
var h := absi(hash(Vector2i(seed, i)))
var p := rect.position + Vector2(8 + (h % 1000) / 1000.0 * (rect.size.x - 16), 8 + ((h / 1000) % 1000) / 1000.0 * (rect.size.y - 16))
var tw := 0.5 + 0.5 * sin(t * 2.5 + i * 1.9 + seed % 7) if animations else 0.35 + 0.65 * ((i * 7 + seed) % 5) / 4.0
if tw < 0.15 or avoid.has_point(p):
continue
var s := 2.0 + 4.0 * tw
var col := Color(1, 1, 1, 0.3 + 0.65 * tw)
ci.draw_line(p - Vector2(s, 0), p + Vector2(s, 0), col, 1.4)
ci.draw_line(p - Vector2(0, s), p + Vector2(0, s), col, 1.4)
ci.draw_circle(p, 1.3, col)
## Weicher Schein um eine Karte höherer Stufe; vor dem Hintergrund zeichnen.
static func glow(ci: CanvasItem, rect: Rect2, color: Color, radius: int = 8, spread: int = 8) -> void:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0, 0, 0, 0)
sb.set_corner_radius_all(radius)
sb.shadow_color = Color(color, 0.6)
sb.shadow_size = spread
ci.draw_style_box(sb, rect)
## Edelstein in der Farbe der Stufe (ab selten).
static func rarity_gem(ci: CanvasItem, rar: String, c: Vector2, s: float, seed: int = 0) -> void:
if rar == "gewöhnlich":
return
var col := rarity_color(rar, seed)
var diamond := func(h: float, k: Color) -> void:
ci.draw_colored_polygon(PackedVector2Array([c + Vector2(0, -h), c + Vector2(h * 0.72, 0), c + Vector2(0, h), c + Vector2(-h * 0.72, 0)]), k)
diamond.call(s + 2, Color("14141c"))
diamond.call(s, col)
ci.draw_colored_polygon(PackedVector2Array([c + Vector2(0, -s * 0.7), c + Vector2(s * 0.3, -s * 0.15), c + Vector2(-s * 0.35, -s * 0.1)]), Color(1, 1, 1, 0.55))
## Hintergrund in Elementfarbe; ein verschmolzenes Wesen zeigt seine Elemente als Streifen.
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
if elements.size() == 1:
@@ -265,12 +409,63 @@ static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius:
ci.draw_style_box(sb, Rect2(rect.position + Vector2(floorf(i * w), 0), Vector2(ceilf(w), rect.size.y)))
# --- Kartenteile ------------------------------------------------------
## Kostenkristall: sechseckig, blau, Zahl in der Mitte.
static func cost_crystal(ci: CanvasItem, c: Vector2, r: float, cost: int, font: int) -> void:
var pts := PackedVector2Array()
var outer := PackedVector2Array()
for i in 6:
var a := -PI / 2 + i * PI / 3
pts.append(c + Vector2(cos(a), sin(a)) * r)
outer.append(c + Vector2(cos(a), sin(a)) * (r + 2))
ci.draw_colored_polygon(outer, Color("14141c"))
ci.draw_colored_polygon(pts, MANA)
var shine := PackedVector2Array([pts[5], pts[0], c + Vector2(0, -r * 0.1), c + Vector2(-r * 0.55, r * 0.15)])
ci.draw_colored_polygon(shine, Color(1, 1, 1, 0.22))
text_outlined(ci, str(cost), font, Color.WHITE, c, "center", 3)
## Wertkreis (Angriff gelb, Leben rot) mit Zahl.
static func stat_circle(ci: CanvasItem, c: Vector2, r: float, value: String, color: Color, font: int) -> void:
ci.draw_circle(c, r + 2, Color("14141c"))
ci.draw_circle(c, r, color)
ci.draw_circle(c + Vector2(-r * 0.25, -r * 0.3), r * 0.45, Color(1, 1, 1, 0.16))
text_outlined(ci, value, font, Color.WHITE, c, "center", 3)
## Schlüsselwort als Symbol auf dunklem Kreis: Wächter Mauer, Schild Schild, Flink Doppelpfeil,
## Doppelschlag gekreuzte Klingen.
static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: Color = Color("f2f2f6")) -> void:
ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.7))
var w := maxf(1.5, r * 0.22)
match kw:
"Wächter":
ci.draw_rect(Rect2(c + Vector2(-r * 0.75, -r * 0.15), Vector2(r * 1.5, r * 0.85)), col)
for i in 3:
ci.draw_rect(Rect2(c + Vector2(-r * 0.75 + i * r * 0.6, -r * 0.6), Vector2(r * 0.3, r * 0.5)), col)
"Schild":
var pts := PackedVector2Array([c + Vector2(-r * 0.7, -r * 0.65), c + Vector2(r * 0.7, -r * 0.65), c + Vector2(r * 0.7, r * 0.05),
c + Vector2(0, r * 0.8), c + Vector2(-r * 0.7, r * 0.05)])
ci.draw_colored_polygon(pts, col)
ci.draw_line(c + Vector2(0, -r * 0.55), c + Vector2(0, r * 0.6), Color(0.05, 0.05, 0.08, 0.6), w)
"Flink":
for i in 2:
var x := c.x - r * 0.55 + i * r * 0.55
ci.draw_polyline(PackedVector2Array([Vector2(x, c.y - r * 0.6), Vector2(x + r * 0.5, c.y), Vector2(x, c.y + r * 0.6)]), col, w)
"Doppelschlag":
ci.draw_line(c + Vector2(-r * 0.65, r * 0.65), c + Vector2(r * 0.6, -r * 0.6), col, w)
ci.draw_line(c + Vector2(r * 0.65, r * 0.65), c + Vector2(-r * 0.6, -r * 0.6), col, w)
ci.draw_line(c + Vector2(-r * 0.7, r * 0.35), c + Vector2(-r * 0.35, r * 0.7), col, w)
ci.draw_line(c + Vector2(r * 0.7, r * 0.35), c + Vector2(r * 0.35, r * 0.7), col, w)
# --- Kartensymbole (prozedural, je Element) --------------------------
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
static func glyph(ci: CanvasItem, card, center: Vector2, size: float, alpha: float = 0.95) -> void:
var variant := absi(hash(card.id)) % 3
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
col.a = 0.95
col.a = alpha
var elems: Array = card.elements
if elems.size() == 1:
_element_glyph(ci, elems[0], center, size, variant, col)
@@ -278,7 +473,7 @@ static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
# Verschmolzen: die ersten beiden Elemente nebeneinander, ab dem dritten ein Ring
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
col2.a = 0.95
col2.a = alpha
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
if elems.size() >= 3:
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
@@ -321,7 +516,7 @@ static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
ci.draw_colored_polygon(pts, col)
var inner := PackedVector2Array([
c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)])
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75))
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75 * col.a))
## Dezenter Hintergrund: Farbverlauf und schräge Linien.

View File

@@ -1,5 +1,5 @@
extends Node
## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen (5 Gegner).
## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen.
var main: Node
var max_dt := 0.0
@@ -14,7 +14,7 @@ func _ready() -> void:
add_child(main)
await get_tree().process_frame
await get_tree().process_frame
var scr: BattleScreen = BattleScreen.new(5)
var scr: BattleScreen = BattleScreen.new()
main.show_screen(scr)
await get_tree().process_frame
await get_tree().process_frame
@@ -23,7 +23,7 @@ func _ready() -> void:
var t0 := Time.get_ticks_usec()
for i in 30:
scr.refresh()
print("refresh() mit 5 Gegnern und 8 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
print("refresh() mit 8 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
scr.ai_delay = 0.2
measuring = true
var start := Time.get_ticks_msec()

View File

@@ -38,7 +38,7 @@ func _ready() -> void:
net.host()
net.lobby_changed.connect(func():
if net.game == null and net.names.size() >= 2:
net.start_battle(0))
net.start_battle())
net.action_applied.connect(func():
net.broadcast()
_act_host.call_deferred())

View File

@@ -15,12 +15,9 @@ func _initialize() -> void:
var r := RandomNumberGenerator.new()
r.seed = 3
print("Ladenangebot: %.2f ms" % ms(func(): DeckGen.shop_cards(r, 5), 50))
print("Vorschau 1..5 Gegner: %.1f ms" % ms(func():
for n in range(1, 6):
for i in n:
DeckGen.random_opponent(i + 1)))
for n in [1, 3, 5]:
var g := Game.new_battle(n, r)
print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20))
for n in [1]:
var g := Game.new_battle(r)
# ein volles Brett für alle, damit die Entscheidung teuer wird
var uid := 5000
for p in g.players:
@@ -41,7 +38,7 @@ func _initialize() -> void:
var t_budget := ms(func(): AI.choose_action(g, h))
var cands := AI.candidate_actions(g, h).size()
print("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget])
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(5, r).clone(), 20))
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(r).clone(), 20))
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(5, r).snapshot(0), 10))
print("Snapshot: %.2f ms" % ms(func(): Game.new_battle(r).snapshot(0), 10))
quit()

View File

@@ -1,6 +1,6 @@
extends SceneTree
## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln
## ist etwa 1/(Gegner+1) Siege zu erwarten:
## sind etwa 50 % Siege zu erwarten:
## godot --headless --path . -s tests/simulate.gd
@@ -9,25 +9,37 @@ func _initialize() -> void:
var r := RandomNumberGenerator.new()
r.seed = 0
var t0 := Time.get_ticks_msec()
print("Alle gierig (Erwartung etwa 1/(Gegner+1)):")
for n in range(1, Game.MAX_OPPONENTS + 1):
_run(n, games, r, true, true)
print("Beide gierig (Erwartung etwa 50 %):")
_run(games, r, true, true)
print("Du mit Vorausschau, Gegner gierig:")
for n in [1, 2, 3]:
_run(n, games / 2, r, false, true)
print("Alle mit Vorausschau:")
_run(1, games / 2, r, false, false)
_run(2, games / 4, r, false, false)
_run(games / 2, r, false, true)
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]:
_campaign(stage, games / 4, r)
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
quit()
func _run(n_opp: int, games: int, r: RandomNumberGenerator,
human_greedy: bool, opp_greedy: bool) -> 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)
while g.winner == "":
AI.take_turn(g, g.current(), false)
assert(g.turn < 300, "Spiel endet nicht")
if g.winner == "human":
wins += 1
var st := Campaign.stage(stage)
print("Stufe %2d %-12s %2d Leben, %d Merkmale: Siege %3d%%" % [stage + 1, st.name, int(st.life), st.perks.size(), wins * 100 / games])
func _run(games: int, r: RandomNumberGenerator, human_greedy: bool, opp_greedy: bool) -> void:
var wins := 0
var turns := 0
for i in games:
var g := Game.new_battle(n_opp, r)
var g := Game.new_battle(r)
while g.winner == "":
var p := g.current()
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy)
@@ -35,4 +47,4 @@ func _run(n_opp: int, games: int, r: RandomNumberGenerator,
if g.winner == "human":
wins += 1
turns += g.turn
print("gegen %d: Siege %3d%% Ø Züge %.1f" % [n_opp, wins * 100 / games, float(turns) / games])
print("Siege %3d%% Ø Züge %.1f" % [wins * 100 / games, float(turns) / games])

View File

@@ -18,25 +18,23 @@ func C(id: String) -> Cards.Card:
return Cards.by_id(id)
func setup(human_hand: Array = [], opp_hand: Array = [], n_opp: int = 1) -> Array:
## Duell mit vollem Mana; der Gegner hat 15 Leben. Liefert [Spiel, Spieler, Gegner].
func setup(human_hand: Array = [], opp_hand: Array = []) -> Array:
var human := Game.Player.new("Spieler", true, 30)
var opps := []
for i in n_opp:
opps.append(Game.Player.new("Gegner %d" % (i + 1), false, 15))
var o := Game.Player.new("Gegner", false, 15)
var r := RandomNumberGenerator.new()
r.seed = 0
var g := Game.new(human, opps, r)
var g := Game.new(human, o, r)
g.turn = 1
human.max_energy = 10
human.energy = 10
for cid in human_hand:
human.hand.append(C(cid))
for o in opps:
o.max_energy = 10
o.energy = 10
for cid in opp_hand:
o.hand.append(C(cid))
return [g, human, opps]
o.max_energy = 10
o.energy = 10
for cid in opp_hand:
o.hand.append(C(cid))
return [g, human, o]
var next_uid := 9000
@@ -60,7 +58,6 @@ func _initialize() -> void:
test_wesen_spielen()
test_flink()
test_waechter()
test_waechter_nur_eigener_besitzer()
test_kampf_beidseitig()
test_schild()
test_doppelschlag()
@@ -76,15 +73,13 @@ func _initialize() -> void:
test_mana_effekt()
test_gleiche_regeln()
test_zugreihenfolge()
test_tote_gegner()
test_jeder_gegen_jeden()
test_gegnerzahl()
test_sieg_niederlage()
test_booster()
test_spielstand()
test_resonanz()
test_elementvorteil()
test_feld()
test_vorschau()
test_verschmelzen()
test_feldkombos()
test_schild_effekt_und_sonne()
@@ -141,7 +136,7 @@ func test_flink() -> void:
var s := setup(["l_falke"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
g.play_card(h, C("l_falke"))
check(g.attack(h.board[0], o) == "", "Flink greift sofort an")
check(o.life == 12, "Falke 3 Schaden")
@@ -151,7 +146,7 @@ func test_waechter() -> void:
var s := setup()
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var a := put(h, "f_wolf")
var w := put(o, "e_wall")
check(g.attack(a, o) != "", "Wächter blockt Angriff auf Held")
@@ -159,20 +154,11 @@ func test_waechter() -> void:
check(w.health == 1 and a.health == 2, "Schaden verrechnet")
func test_waechter_nur_eigener_besitzer() -> void:
var s := setup([], [], 2)
var g: Game = s[0]
var h: Game.Player = s[1]
var a := put(h, "f_wolf")
put(s[2][0], "e_wall")
check(g.attack(a, s[2][1]) == "" and s[2][1].life == 12, "Wächter schützt nur Besitzer")
func test_kampf_beidseitig() -> void:
var s := setup()
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var a := put(h, "f_asche")
var d := put(o, "w_reiter")
g.attack(a, d)
@@ -184,7 +170,7 @@ func test_schild() -> void:
var s := setup()
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var sh := put(o, "ee_bastion")
var a := put(h, "f_asche")
g.attack(a, sh)
@@ -198,7 +184,7 @@ func test_doppelschlag() -> void:
var s := setup()
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var a := put(h, "l_schlange")
g.attack(a, o)
g.attack(a, o)
@@ -210,7 +196,7 @@ func test_einfrieren() -> void:
var s := setup([], ["w_frost"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var a := put(h, "f_wolf")
g.end_turn()
check(g.play_card(o, C("w_frost"), a) == "", "Frostmagier spielen")
@@ -226,30 +212,29 @@ func test_effekt_ziel() -> void:
var s := setup(["w_frost", "f_ritter"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
check(g.play_card(h, C("w_frost")) == "" and h.board.size() == 1, "ohne Ziel kommt das Wesen trotzdem, der Effekt verpufft")
g.play_card(h, C("f_ritter"), o)
check(o.life == 14 and h.board.size() == 2, "Ritter macht 1 Schaden an den Helden")
func test_flaechenschaden() -> void:
var s := setup(["ff_phoenix"], [], 2)
var s := setup(["ff_phoenix"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o1: Game.Player = s[2][0]
var o2: Game.Player = s[2][1]
put(o1, "f_kobold")
put(o2, "e_golem")
var o: Game.Player = s[2]
put(o, "f_kobold")
put(o, "e_golem")
g.play_card(h, C("ff_phoenix"))
check(o1.life == 11 and o2.life == 11, "Helden 4 Schaden")
check(o1.board.is_empty() and o2.board[0].health == 1, "Wesen getroffen")
check(o.life == 11, "Held 4 Schaden")
check(o.board.size() == 1 and o.board[0].health == 1, "Wesen getroffen, Kobold tot")
func test_zurueck_auf_hand() -> void:
var s := setup(["wl_nebel"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var d := put(o, "e_troll")
g.play_card(h, C("wl_nebel"), d)
check(o.board.is_empty() and C("e_troll") in o.hand, "zurück auf Hand")
@@ -285,11 +270,10 @@ func test_limits() -> void:
## Grenzfälle, die schon einmal falsch waren.
func test_grenzfaelle() -> void:
var s := setup(["f_wolf"], [], 2)
var s := setup(["f_wolf"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o1: Game.Player = s[2][0]
var o2: Game.Player = s[2][1]
var o1: Game.Player = s[2]
# Nur Karten von der eigenen Hand
check(not g.can_play(h, C("e_troll"))[0] and g.play_card(h, C("e_troll")) == "Nicht auf der Hand", "fremde Karte lässt sich nicht spielen")
check(g.play_card(h, C("f_wolf")) == "" and h.board.size() == 1, "eigene Handkarte geht")
@@ -298,7 +282,7 @@ func test_grenzfaelle() -> void:
o1.life = 0
g.cleanup()
check(o1.board.is_empty() and not o1.alive() and g.log.any(func(l): return l.find("verschwindet mit") >= 0), "besiegter Spieler räumt sein Feld")
check(not (o1 in g.enemies_of(h)) and o2 in g.enemies_of(h), "Besiegte sind keine Ziele mehr")
check(g.enemies_of(h).is_empty(), "Besiegte sind keine Ziele mehr")
check(dead != null, "Wesen war vorher da")
# Verschmolzene Karte auf der Hand übersteht den Spielstand
var a := put(h, "f_kobold")
@@ -314,10 +298,10 @@ func test_grenzfaelle() -> void:
## Zweite Runde Grenzfälle: Konter ohne Angriff, Chimäre, Ereignisse mit verschmolzener Karte,
## ausgeschiedener Netzwerkspieler mitten im eigenen Zug.
func test_grenzfaelle_2() -> void:
var s := setup([], [], 2)
var s := setup()
var g: Game = s[0]
var h: Game.Player = s[1]
var o1: Game.Player = s[2][0]
var o1: Game.Player = s[2]
# Ein Wesen ohne Angriff schlägt nicht zurück, auch nicht mit Elementvorteil
var wall := put(o1, "e_wall") # Erde 0/4, Erde schlägt Wasser
var reiter := put(h, "w_reiter") # Wasser 3/3
@@ -347,10 +331,10 @@ func test_grenzfaelle_2() -> void:
net.game = g
net.rejoin_seconds = 0.0
net.waiting = {"Spieler": {"seat": g.players.find(h), "since": 0.0}}
o1.is_human = true # noch ein Mensch am Tisch, sonst wäre die Partie schlicht verloren
o1.is_human = true # ein Duell zweier Menschen
check(g.current() == h, "Spieler ist dran")
net.tick()
check(not h.alive() and h.board.is_empty() and g.current() != h, "Ausgeschiedener räumt das Feld und der Zug geht weiter")
check(not h.alive() and h.board.is_empty() and g.winner != "" and g.winner_player == o1, "Ausgeschiedener räumt das Feld, der andere gewinnt")
net.free()
@@ -421,7 +405,7 @@ func test_mana_effekt() -> void:
func test_gleiche_regeln() -> void:
var r := RandomNumberGenerator.new()
r.seed = 0
var g := Game.new_battle(3, r)
var g := Game.new_battle(r)
var first: Game.Player = g.players[0]
check(g.current() == first and first.max_energy == 1 and first.energy == 1, "Startspieler: 1 Mana, kein Bonus")
check(first.shop.size() == Game.SHOP_SIZE and first.hand.is_empty(), "jeder beginnt mit einem Laden und leerer Hand")
@@ -432,9 +416,9 @@ func test_gleiche_regeln() -> void:
g.end_turn()
var second: Game.Player = g.players[1]
check(g.current() == second and second.max_energy == 1 and second.energy == 2, "Nachzieher: 1 Mana plus Ausgleich")
for i in 4:
for i in 2:
g.end_turn()
check(g.current() == second and second.max_energy == 2, "Ausgleich nur einmal")
check(g.current() == second and second.max_energy == 2 and second.energy == 4, "Ausgleich nur einmal: 2 übrig plus 2 Einkommen")
func test_zugreihenfolge() -> void:
@@ -442,9 +426,9 @@ func test_zugreihenfolge() -> void:
for seed in 12:
var r := RandomNumberGenerator.new()
r.seed = seed
var g := Game.new_battle(2, r)
var g := Game.new_battle(r)
starters[g.players[0].name] = true
check(g.players.size() == 3 and g.human in g.players and g.opponents.size() == 2, "alle am Tisch")
check(g.players.size() == 2 and g.human in g.players and g.opponent in g.players, "beide am Tisch")
for i in g.players.size():
check(g.players[i].order == i, "Reihenfolge nummeriert")
check(g.log[0].begins_with("Zugreihenfolge:"), "Reihenfolge im Verlauf")
@@ -453,58 +437,15 @@ func test_zugreihenfolge() -> void:
check(starters.size() > 1, "Startspieler wechselt (%d verschiedene)" % starters.size())
func test_tote_gegner() -> void:
var s := setup([], [], 2)
var g: Game = s[0]
s[2][0].life = 0
g.end_turn()
check(g.current() == s[2][1], "toter Gegner übersprungen")
g.end_turn()
check(g.current() == s[1] and g.turn == 2, "zurück beim Spieler, Zug 2")
func test_jeder_gegen_jeden() -> void:
var s := setup([], [], 2)
var g: Game = s[0]
var h: Game.Player = s[1]
var o1: Game.Player = s[2][0]
var o2: Game.Player = s[2][1]
check(g.enemies_of(o1) == [h, o2], "Gegner bekämpft Spieler und anderen Gegner")
check(g.enemies_of(h) == [o1, o2], "Spieler bekämpft alle Gegner")
g.end_turn()
o1.hand = []
var a := put(o1, "f_wolf")
check(g.can_attack(a, o2)[0], "Gegner darf Gegner angreifen")
check(g.attack(a, o2) == "" and o2.life == 12, "Gegner trifft Gegner")
check(AI.choose_action(g, o1).type == "buy", "KI kauft aus ihrem Laden")
o1.shop = []
check(AI.choose_action(g, o1).type == "end", "ohne Angebot und ohne Angriff endet der Zug")
o2.life = 2
var b := put(o1, "f_kobold")
check(AI.choose_action(g, o1).target == o2, "KI nutzt Lethal gegen anderen Gegner")
func test_gegnerzahl() -> void:
var r := RandomNumberGenerator.new()
r.seed = 2
for n in range(1, Game.MAX_OPPONENTS + 1):
var g := Game.new_battle(n, r)
check(g.opponents.size() == n, "%d Gegner" % n)
check(g.players.all(func(o): return o.life == Game.START_LIFE), "gleiches Leben bei %d" % n)
check(g.players[0].max_energy == 1 and g.current() == g.players[0], "Startspieler bei %d" % n)
check(g.human.shop.size() == Game.SHOP_SIZE, "Laden bei %d" % n)
check(Profile.REWARD_WIN.has(n), "Belohnung bei %d" % n)
func test_sieg_niederlage() -> void:
var s := setup([], [], 2)
var s := setup()
var g: Game = s[0]
g.damage(s[2][0], 99)
g.damage(s[2], 14)
g.check_end()
check(g.winner == "", "noch offen")
g.damage(s[2][1], 99)
g.damage(s[2], 1)
g.check_end()
check(g.winner == "human", "Sieg")
check(g.winner == "human" and g.winner_player == s[1], "Sieg")
var s2 := setup()
var g2: Game = s2[0]
g2.damage(s2[1], 30)
@@ -562,7 +503,7 @@ func test_ki_lethal() -> void:
var s := setup()
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
o.life = 3
var a := put(h, "f_asche")
put(o, "w_reiter")
@@ -573,23 +514,20 @@ func test_ki_lethal() -> void:
func test_ki_spiele_enden() -> void:
var r := RandomNumberGenerator.new()
r.seed = 7
for n in [1, 3, 5]:
for i in 6:
var pool: Array = Cards.ELEMENTS.duplicate()
Game.shuffle(pool, r)
var g := Game.new_battle(n, r)
var guard := 0
while g.winner == "" and guard < 300:
AI.take_turn(g, g.current(), i % 2 == 0)
guard += 1
check(g.winner != "", "Spiel endet (%d Gegner)" % n)
for i in 12:
var g := Game.new_battle(r)
var guard := 0
while g.winner == "" and guard < 300:
AI.take_turn(g, g.current(), i % 2 == 0)
guard += 1
check(g.winner != "", "Spiel endet (Partie %d)" % i)
func test_resonanz() -> void:
var s := setup(["f_wolf", "f_kobold", "e_wall"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
g.play_card(h, C("f_wolf"))
check(h.board[0].power() == 3, "allein keine Resonanz")
g.play_card(h, C("f_kobold"))
@@ -621,7 +559,7 @@ func test_elementvorteil() -> void:
var s := setup(["f_ritter"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var mine := put(h, "w_reiter") # Wasser 3/3
var his := put(o, "f_wolf") # Feuer 3/2
g.attack(mine, his)
@@ -633,7 +571,7 @@ func test_elementvorteil() -> void:
s = setup(["f_ritter"])
g = s[0]
h = s[1]
o = s[2][0]
o = s[2]
var air := put(o, "l_laeufer") # Luft 3/4, Feuer schlägt Luft
g.play_card(h, C("f_ritter"), air)
check(air.health == 2, "Feuereffekt gegen Luft: 1 Schaden mehr")
@@ -644,7 +582,7 @@ func test_feld() -> void:
var s := setup(["f_ritter"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
check(g.field_element == "" and int(g.field_charge["Feuer"]) == 0, "das Feld beginnt neutral")
var wolf := put(h, "f_wolf")
for i in Cards.FIELD_CHARGE - 1:
@@ -669,11 +607,34 @@ func test_feld() -> void:
## Verschmelzen: zwei eigene Wesen werden eins, Werte Durchschnitt plus 1.
## Vorschau fürs Ausspielen: Resonanz, Feldbonus und Feldkombo mit Partner.
func test_vorschau() -> void:
var s := setup(["f_kobold", "e_wall"])
var g: Game = s[0]
var h: Game.Player = s[1]
var f := g.forecast(h, C("f_kobold"))
check(f.resonance == 0 and f.field == 0 and f.combo == "" and f.partner == null, "leeres Feld: keine Vorschau")
var wolf := put(h, "f_wolf")
put(h, "f_asche")
var reiter := put(h, "w_reiter")
f = g.forecast(h, C("f_kobold"))
check(f.resonance == 2 and f.combo == "Dampf" and f.partner == reiter, "zwei Feuerpartner geben +2, das Wasserwesen die Kombo Dampf")
f = g.forecast(h, C("e_wall"))
check(f.resonance == 0 and f.combo == "Lava" and f.partner == wolf, "Erde: kein Partner, Kombo Lava mit dem ersten Feuerwesen")
g.field_element = "Feuer"
check(g.forecast(h, C("f_kobold")).field == 1 and g.forecast(h, C("e_wall")).field == 0, "der Feldbonus zählt nur fürs eigene Element")
h.perks.append("resonanz_doppelt")
check(g.forecast(h, C("f_kobold")).resonance == 4, "doppelte Resonanz verdoppelt die Vorschau")
var real := g.play_card(h, C("f_kobold"))
var kobold: Game.Creature = h.board[-1]
check(real == "" and kobold.power() == 2 + 4 + 1, "das Ausspielen bringt genau den vorhergesagten Angriff")
func test_verschmelzen() -> void:
var s := setup()
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var a := put(h, "f_wolf") # Feuer 3/2, Kosten 2
var b := put(h, "w_reiter") # Wasser 3/3, Kosten 2
check(Cards.fusion_cost(a.card, b.card) == 2 and Cards.fusion_cost(C("f_kobold"), C("f_asche")) == 2, "Kosten: Durchschnitt, aufgerundet")
@@ -719,7 +680,7 @@ func test_feldkombos() -> void:
var s := setup(["w_reiter"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
put(h, "f_wolf")
var weak := put(o, "f_kobold")
var strong := put(o, "e_troll")
@@ -730,20 +691,19 @@ func test_feldkombos() -> void:
s = setup(["e_golem"])
g = s[0]
h = s[1]
o = s[2][0]
o = s[2]
put(h, "f_wolf")
var k := put(o, "f_kobold")
var big := put(o, "e_troll")
g.play_card(h, C("e_golem"))
check(k in o.board and big.health == 5, "Lava trifft nur das stärkste Wesen")
# Blitz: 2 Schaden an den schwächsten Gegner
s = setup(["l_wind"], [], 2)
# Blitz: 2 Schaden an den Gegner
s = setup(["l_wind"])
g = s[0]
h = s[1]
put(h, "f_wolf")
s[2][1].life = 5
g.play_card(h, C("l_wind"))
check(s[2][1].life == 3 and s[2][0].life == 15, "Blitz trifft den schwächsten Gegner")
check(s[2].life == 13, "Blitz trifft den Gegner")
# Schlamm heilt, Sturm gibt Mana, Sand gibt +1/+1
s = setup(["e_wall", "l_wind", "w_reiter"])
g = s[0]
@@ -773,7 +733,7 @@ func test_feldkombos() -> void:
s = setup(["fe_golem"])
g = s[0]
h = s[1]
o = s[2][0]
o = s[2]
put(h, "w_reiter")
var t := put(o, "e_troll")
g.play_card(h, C("fe_golem"))
@@ -784,7 +744,7 @@ func test_schild_effekt_und_sonne() -> void:
var s := setup(["el_duenen", "fff_sonne"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var a := put(h, "f_wolf")
g.play_card(h, C("el_duenen"), a)
check(a.shield, "Dünenläufer gibt Schild")
@@ -792,7 +752,7 @@ func test_schild_effekt_und_sonne() -> void:
s = setup(["fff_sonne"])
g = s[0]
h = s[1]
o = s[2][0]
o = s[2]
check(C("fff_sonne").elements == ["Feuer"] and C("fff_sonne").cost == 9, "Kern der Sonne ist ein teures Feuerwesen")
var troll := put(o, "e_troll")
g.play_card(h, C("fff_sonne"))
@@ -803,7 +763,7 @@ func test_kopie() -> void:
var s := setup(["f_wolf"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
var a := put(h, "f_asche")
var g2 := g.clone()
var a2 := g2.find_creature(a.uid)
@@ -823,7 +783,7 @@ func test_ki_besser_als_gierig() -> void:
for i in games:
var pool: Array = Cards.ELEMENTS.duplicate()
Game.shuffle(pool, r)
var g := Game.new_battle(1, r)
var g := Game.new_battle(r)
# abwechselnd beginnt die schlaue oder die gierige KI
var smart_is_human := i % 2 == 0
var guard := 0
@@ -855,22 +815,22 @@ func test_kampagne() -> void:
var r := RandomNumberGenerator.new()
r.seed = 4
check(Campaign.STAGES.size() == 10, "zehn Stufen")
var opps := Campaign.opponents(4)
check(opps.size() == 3 and opps[0].name == "Vulkar" and opps[0].title == "Herr der Lava", "Stufe 5: Vulkar mit 2 Begleitern")
check(opps[0].elements == ["Feuer", "Erde"] and opps[0].shop.all(func(c): return c.elements.all(func(e): return e in ["Feuer", "Erde"])), "Boss handelt nur mit seinen Elementen")
var g := Game.new_battle_with(opps, r)
check(g.opponents.size() == 3 and g.human.life == Game.START_LIFE, "Kampagnenkampf gestartet")
var boss := Campaign.opponent(4)
check(boss.name == "Vulkar" and boss.title == "Herr der Lava" and boss.life == 32, "Stufe 5: Vulkar mit 32 Leben")
check(boss.elements == ["Feuer", "Erde"] and boss.shop.all(func(c): return c.elements.all(func(e): return e in ["Feuer", "Erde"])), "Boss handelt nur mit seinen Elementen")
var g := Game.new_battle_with(boss, r)
check(g.opponent == boss and g.human.life == Game.START_LIFE, "Kampagnenkampf gestartet")
var p := Profile.starter()
var reward := p.record_battle(true, 1, 0)
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, 1, 0)
check(p.campaign_stage == 1 and reward == Profile.REWARD_WIN[1], "Wiederholung gibt normale Belohnung")
reward = p.record_battle(false, 2, 1)
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")
p.campaign_stage = 9
p.record_battle(true, 5, 9)
p.record_battle(true, 9)
check(p.campaign_stage == 10, "Kampagne abgeschlossen")
var sim := Game.new_battle_with(Campaign.opponents(0), 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)
@@ -916,7 +876,7 @@ func test_merkmale() -> void:
var s := setup(["f_wolf", "l_falke", "e_wall"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
h.perks = ["feuer_angriff", "flink_angriff", "waechter_leben", "schild_wesen", "alle_flink", "resonanz_doppelt"]
g.play_card(h, C("f_wolf"))
var wolf: Game.Creature = h.board[0]
@@ -932,15 +892,15 @@ func test_merkmale() -> void:
check(Campaign.STAGES.any(func(st): return st.perk == perk), "Merkmal vergeben: " + perk)
var r := RandomNumberGenerator.new()
r.seed = 2
var mara: Game.Player = Campaign.opponents(1)[0]
check(mara.life == 35 and mara.perks == ["leben_35"], "Mara hat 35 Leben")
var g3 := Game.new_battle_with(Campaign.opponents(9), r, true)
var archi: Game.Player = g3.opponents[0]
var mara: Game.Player = Game.new_battle_with(Campaign.opponent(1), r).opponent
check(mara.life == 35 and mara.max_life == 35 and mara.perks == ["leben_35"], "Mara hat mit ihrem Merkmal 35 Leben")
var g3 := Game.new_battle_with(Campaign.opponent(9), r, true)
var archi: Game.Player = g3.opponent
check(g3.players[0] == g3.human, "feste Reihenfolge: Mensch beginnt")
g3.end_turn()
check(g3.current() == archi and archi.energy == 3 + 1, "Archimagier beginnt mit 3 Mana (plus Ausgleich)")
var nyx: Game.Player = Campaign.opponents(5)[0]
var g4 := Game.new_battle_with([nyx], r, true)
var nyx: Game.Player = Campaign.opponent(5)
var g4 := Game.new_battle_with(nyx, r, true)
g4.end_turn()
# Einkommen 1, dazu 1 Mana für ihr Merkmal und 1 als Ausgleich für den späteren Start
check(nyx.max_energy == 1 and nyx.energy == 3, "Nyx bekommt 1 Mana mehr als ihr Einkommen")
@@ -952,7 +912,7 @@ func test_ki_zwei_schritte() -> void:
var s := setup(["el_koenig"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
h.turns = 2
o.life = 5
put(h, "f_wolf", false)
@@ -966,10 +926,10 @@ func test_ki_zwei_schritte() -> void:
func test_snapshot() -> void:
var s := setup(["f_wolf"], ["w_frost"], 2)
var s := setup(["f_wolf"], ["w_frost"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var o: Game.Player = s[2]
g.play_card(h, C("f_wolf"))
put(o, "e_troll")
var hi := g.players.find(h)
@@ -979,7 +939,7 @@ func test_snapshot() -> void:
var snap := g.snapshot(hi)
var json: Dictionary = JSON.parse_string(JSON.stringify(snap))
var g2 := Game.from_snapshot(json)
check(g2.players.size() == 3 and g2.turn == g.turn and g2.current_idx == g.current_idx, "Grunddaten")
check(g2.players.size() == 2 and g2.turn == g.turn and g2.current_idx == g.current_idx, "Grunddaten")
check(g2.players[hi].hand.is_empty(), "eigene Hand sichtbar")
check(g2.players[oi].hand.size() == 1 and g2.players[oi].hand[0] != C("w_frost"), "fremde Hand nur als Anzahl")
check(g2.players[oi].shop.is_empty() and g2.players[hi].shop.size() == Game.SHOP_SIZE, "eigener Laden sichtbar, fremder nicht")
@@ -1002,17 +962,17 @@ func test_kapitel_zwei_und_merkmalwahl() -> void:
check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1))
var r := RandomNumberGenerator.new()
r.seed = 8
var opps := Campaign.opponents(19)
check(opps.size() == 5 and opps[0].name == "Archimagier" and opps[0].perks.size() == 3, "letzte Stufe: Archimagier mit drei Merkmalen, voller Tisch")
var archi2 := Campaign.opponent(19)
check(archi2.name == "Archimagier" and archi2.perks.size() == 3 and archi2.life == 34, "letzte Stufe von Kapitel 2: Archimagier mit drei Merkmalen und 34 Leben")
var p := Profile.starter()
check(not p.perk_unlocked(), "Merkmalwahl erst nach Kapitel 1")
p.campaign_stage = 10
check(p.perk_unlocked(), "nach Kapitel 1 freigeschaltet")
p.perk = "feuer_angriff"
var g := Game.new_battle(1, r, {"human_perks": [p.perk]})
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, 5, 19)
p.record_battle(true, 19)
check(p.campaign_stage == 20, "Kapitel 2 abgeschlossen")
var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict())))
check(v.perk == "feuer_angriff" and v.campaign_stage == 20, "Version 5 gelesen")
@@ -1021,7 +981,7 @@ func test_kapitel_zwei_und_merkmalwahl() -> void:
func test_ki_zeitbudget() -> void:
var r := RandomNumberGenerator.new()
r.seed = 12
var g := Game.new_battle(5, r)
var g := Game.new_battle(r)
var h: Game.Player = g.players[0]
h.energy = 10
h.max_energy = 10
@@ -1058,7 +1018,7 @@ func test_englische_karten() -> void:
func test_kampf_speichern() -> void:
var r := RandomNumberGenerator.new()
r.seed = 7
var g := Game.new_battle(2, r)
var g := Game.new_battle(r)
g.start()
AI.take_turn(g, g.current())
var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1)))
@@ -1075,7 +1035,7 @@ func test_kampf_speichern() -> void:
while g2.winner == "" and guard < 300:
AI.take_turn(g2, g2.current())
guard += 1
check(g2.winner != "", "fortgesetzte Partie ist bis zum Ende spielbar")
check(g2.winner != "", "der volle Spielstand ist bis zum Ende spielbar")
var s2: Dictionary = g.snapshot(-2)
check(s2.players.all(func(p): return not (p.hand is Array) and p.shop.is_empty()), "Zuschauer-Sicht ohne Hände und Läden")

View File

@@ -26,6 +26,7 @@ func frames(n: int = 2) -> void:
func shot(name: String) -> void:
if shots == "":
return
await frames(10) # Einblenden und Austeilen abwarten
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png(shots.path_join(name + ".png"))
@@ -142,18 +143,15 @@ func _ready() -> void:
for suffix in ["", ".bak", ".corrupt"]:
if FileAccess.file_exists(SAVE + suffix):
DirAccess.remove_absolute(abs + suffix)
if FileAccess.file_exists(SAVE.get_basename() + "_kampf.json"):
DirAccess.remove_absolute(abs.get_basename() + "_kampf.json")
main = load("res://scenes/main.tscn").instantiate()
main.save_path = SAVE
add_child(main)
await frames(3)
var fast := OS.get_environment("DINGER_FAST") != ""
var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen,
test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_feld_und_verschmelzen, test_kampf_grenzen,
test_kampf_fortsetzen]
test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_feld_und_verschmelzen, test_kampf_grenzen]
if not fast:
suite += [test_kampf.bind(2, 3), test_kampf_abbrechen]
suite += [test_kampf.bind(3), test_kampf_abbrechen]
# Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten.
# Sie laufen nur auf Wunsch: DINGER_NETZ=1 (oder gezielt über DINGER_ONLY).
if OS.get_environment("DINGER_NETZ") != "":
@@ -254,7 +252,7 @@ func human_turn(scr) -> void:
"' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " pending=", scr.pending, " selected=", scr.selected)
check(scr.selected == cr, "Wesen gewählt")
var targets := []
for o in g.opponents:
for o in [g.opponent]:
if o.alive() and g.can_attack(cr, o)[0]:
targets.append(o)
for c in o.board:
@@ -267,11 +265,9 @@ func human_turn(scr) -> void:
await click(scr.b_end)
func start_battle(n_opp: int) -> void:
func start_battle() -> void:
await click(find_button("Einzelspieler"))
check(screen() is OpponentSelectScreen, "Gegnerauswahl")
screen().count_slider.value = n_opp
await frames(1)
await click(find_button("Starten"))
@@ -279,51 +275,33 @@ func test_gegnerauswahl() -> void:
await click(find_button("Einzelspieler"))
var sel = screen()
check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet")
check(find_button("Neu würfeln") == null and find_button("1 Gegner") == null and find_button("Starten") != null, "kein Würfeln und keine Gegnerknöpfe, nur Starten")
check(find_button("Feuer") == null and find_button("Luft") == null, "keine Elementwahl mehr")
check(sel.count_slider.min_value == 1 and sel.count_slider.max_value == Game.MAX_OPPONENTS and sel.count_slider.step == 1, "Gegnerzahl als Schieberegler von 1 bis %d" % Game.MAX_OPPONENTS)
check(sel.opponents() == 1 and sel.count_label.text == "1 Gegner" and "Duell" in sel.info_label.text, "Regler beginnt beim Duell")
var rect: Rect2 = sel.count_slider.get_global_rect()
await click_at(Vector2(rect.end.x - 4, rect.get_center().y)) # ganz rechts anklicken
check(sel.opponents() == Game.MAX_OPPONENTS and sel.count_label.text == "%d Gegner" % Game.MAX_OPPONENTS, "Mausklick am rechten Rand stellt %d Gegner ein" % Game.MAX_OPPONENTS)
sel.count_slider.grab_focus()
await key(KEY_LEFT)
check(sel.opponents() == Game.MAX_OPPONENTS - 1, "Pfeiltaste links nimmt einen Gegner weg")
sel.count_slider.value = 4
await frames(1)
check(sel.opponents() == 4 and sel.count_label.text == "4 Gegner" and "5 am Tisch" in sel.info_label.text
and str(Profile.REWARD_WIN[4]) in sel.info_label.text, "Regler auf 4: fünf am Tisch mit Belohnung")
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")
await shot("07_gegnerauswahl")
sel.count_slider.value = 1
await frames(1)
await click(find_button("Starten"))
var hard = screen()
check(hard is BattleScreen and hard.game.opponents.size() == 1, "Kampf mit einem Gegner gestartet")
var opp: Game.Player = hard.game.opponents[0]
check(hard is BattleScreen and hard.game.players.size() == 2, "Duell gestartet")
var opp: Game.Player = hard.game.opponent
check(opp.max_life == Profile.DIFFICULTIES.schwer.life and hard.smart_ai, "Schwer: Gegner mit %d Leben, Vorausschau-KI" % Profile.DIFFICULTIES.schwer.life)
check(hard.game.human.shop.size() == Game.SHOP_SIZE and hard.game.human.hand.is_empty(),
"Start mit vollem Laden und leerer Hand")
check(hard.hero_panels[opp].position == BattleLayout.OPP_BOX.position, "der Gegner steht oben in der Mitte")
await shot("11_kampf")
await give_up()
await click(find_button("Einzelspieler"))
await click(find_button("Leicht"))
await click(find_button("Starten"))
var easy = screen()
opp = easy.game.opponents[0]
opp = easy.game.opponent
check(opp.max_life == Profile.DIFFICULTIES.leicht.life and not easy.smart_ai, "Leicht: Gegner mit %d Leben, einfache KI" % Profile.DIFFICULTIES.leicht.life)
check(easy.game.players[0].max_energy == 1 and easy.game.human.life == Game.START_LIFE, "gleiche Regeln für beide")
await give_up()
await click(find_button("Einzelspieler"))
await click(find_button("Normal"))
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
for n in [1, 5]:
await start_battle(n)
var scr = screen()
check(scr is BattleScreen and scr.game.opponents.size() == n, "Kampf gegen %d gestartet" % n)
check(scr.game.players[0].max_energy == 1 and scr.game.opponents[0].life == Game.START_LIFE, "gleiche Regeln gegen %d" % n)
await shot("11_kampf_%d" % n)
await give_up()
## Kampf verlassen: nach Spielende über den Ergebnisdialog, sonst per Aufgeben.
@@ -348,32 +326,6 @@ func give_up() -> void:
check(screen() is MenuScreen, "Aufgeben führt ins Menü")
## Kampf speichern, im Menü fortsetzen, Aufgeben löscht die Datei.
func test_kampf_fortsetzen() -> void:
await start_battle(1)
var scr = screen()
scr.ai_delay = 0.0
await wait_for_human(scr)
var kampf: String = main.battle_save_path()
check(FileAccess.file_exists(kampf), "Kampf wird laufend gespeichert")
await click(scr.b_end)
await wait_for_human(scr)
var turn: int = scr.game.turn
var life: int = scr.game.human.life
var hand: int = scr.game.human.hand.size()
await click(find_button("Aufgeben"))
await click(find_button("Speichern und zurück"))
check(screen() is MenuScreen and find_button("Kampf fortsetzen") != null, "Speichern führt ins Menü mit Fortsetzen-Knopf")
await click(find_button("Kampf fortsetzen"))
var scr2 = screen()
check(scr2 is BattleScreen and scr2.game.turn == turn and scr2.game.human.life == life and scr2.game.human.hand.size() == hand
and scr2.game.current() == scr2.game.human, "Kampf fortgesetzt mit gleichem Zug, Leben und Hand")
scr2.ai_delay = 0.0
await shot("30_kampf_fortgesetzt")
await give_up()
check(not FileAccess.file_exists(kampf) and find_button("Kampf fortsetzen") == null, "Aufgeben löscht den gespeicherten Kampf")
func test_einstellungen() -> void:
await click(find_button("Einstellungen"))
var scr = screen()
@@ -414,6 +366,14 @@ func test_sammlung() -> void:
check(scr.selected.id == "ff_phoenix" and p.rarity("ff_phoenix") == "gewöhnlich", "Karte gewählt, noch gewöhnlich")
await click(scr.b_upgrade)
check(p.level("ff_phoenix") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["selten"], "auf selten aufgewertet")
scr.search_box.text = ""
scr.search_box.text_changed.emit("")
await frames(1)
var first: CardView = scr.grid.views()[0]
check(first.card.id == "ff_phoenix" and first.rarity == "selten", "die aufgewertete Karte steht vorn in der Sammlung")
scr.search_box.text = "Phönix"
scr.search_box.text_changed.emit("Phönix")
await frames(1)
check(Profile.load_or_new(SAVE).level("ff_phoenix") == 1, "Sammlung gespeichert")
var dust: int = p.dust
await click(scr.b_dismantle)
@@ -491,7 +451,7 @@ func test_sprache() -> void:
## Steuerung im Kampf: Tastatur, Rückgängig, Gamepad, Überspringen, Esc.
func test_kampf_steuerung() -> void:
await start_battle(1)
await start_battle()
var scr = screen()
check(scr is BattleScreen, "Kampf gestartet")
scr.ai_delay = 0.0
@@ -551,7 +511,7 @@ func test_kampf_steuerung() -> void:
## Grenzen im Kampf: volle Hand, volles Feld, kein Mana.
func test_kampf_grenzen() -> void:
await start_battle(1)
await start_battle()
var scr = screen()
scr.ai_delay = 0.0
await wait_for_human(scr)
@@ -603,26 +563,45 @@ func test_kampf_grenzen() -> void:
## Feldanzeige und Verschmelzen im Kampf.
func test_kampf_feld_und_verschmelzen() -> void:
await start_battle(1)
await start_battle()
var scr = screen()
check(scr is BattleScreen, "Kampf gestartet")
scr.ai_delay = 0.0
await wait_for_human(scr)
var g: Game = scr.game
g.human.board.clear()
for id in ["f_wolf", "w_reiter"]:
var cr := Game.Creature.new(Cards.by_id(id), g.human, g.next_uid)
var put := func(p: Game.Player, id: String) -> Game.Creature:
var cr := Game.Creature.new(Cards.by_id(id), p, g.next_uid)
g.next_uid += 1
g.human.board.append(cr)
cr.ready = true
cr.attacks_left = 1
p.board.append(cr)
return cr
put.call(g.human, "f_wolf")
put.call(g.human, "w_reiter")
var falke: Game.Creature = put.call(scr.others()[0], "l_falke")
g.human.hand = [Cards.by_id("f_kobold")]
g.human.energy = 10
g.field_charge["Wasser"] = 2
scr.refresh()
await frames(2)
check(scr.field_view.text() == "Feuer 0/4 · Wasser 2/4 · Erde 0/4 · Luft 0/4", "neutrales Feld zeigt die Ladung: " + scr.field_view.text())
for i in Cards.FIELD_CHARGE:
g.charge_field(Cards.by_id("f_kobold"))
scr.refresh()
await frames(2)
check(scr.field_label.text.begins_with("Vulkan"), "Feldanzeige nennt das Feld: " + scr.field_label.text)
check(scr.field_view.text().begins_with("Vulkan: +1 Angriff"), "Feldanzeige nennt das Feld: " + scr.field_view.text())
check(g.human.board[0].field_bonus == 1 and g.human.board[0].power() == 4, "das Feuerwesen bekommt +1 Angriff")
check(scr.hand_views[0].bonus == 2 and scr.hand_views[0].tag == "Dampf", "die Handkarte zeigt +2 (Resonanz und Feld) und die Kombo Dampf")
check(scr.b_fuse.visible and scr.b_fuse.text == "Verschmelzen", "Verschmelzen wird angeboten")
await shot("23_feld")
await hover(scr.hand_views[0])
check(scr.tiles[g.human.board[1].uid].mark == "Dampf" and "Dampf" in scr.info_label.text, "beim Zeigen auf die Handkarte ist der Kombo-Partner markiert")
await click(scr.own_views[0])
check(scr.selected == g.human.board[0] and scr.tiles[falke.uid].matchup == 1, "Angreifer gewählt: das Luftwesen zeigt +1 für Feuer")
await shot("23b_angriffsziel")
await right_click() # Auswahl wieder aufheben
check(scr.selected == null and scr.tiles[falke.uid].matchup == 0, "Auswahl aufgehoben, Vorteil verschwindet")
await click(scr.b_fuse)
check(scr.fusing and scr.b_fuse.text == "Abbrechen", "Verschmelzen ausgewählt")
await click(scr.own_views[0])
@@ -834,13 +813,15 @@ func test_kampagne() -> void:
var scr = screen()
check(scr is CampaignScreen, "Kampagne geöffnet")
await shot("15_kampagne")
check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen")
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")
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")
await click(find_button("Kämpfen"))
var battle = screen()
check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet")
check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner")
check(battle.game.opponents[0].perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv")
check(battle.game.opponent.name == "Fenn", "Fenn ist der Gegner")
check(battle.game.opponent.perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv")
await shot("16_kampagne_kampf")
await give_up()
# Kapitel 1 als abgeschlossen setzen: Kapitel 2 und Merkmalwahl
@@ -854,7 +835,7 @@ 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.opponents[0].perks.size() == 2, "Kapitel-2-Boss mit zwei Merkmalen")
check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponent.perks.size() == 2 and b2.game.opponent.life == 30, "Kapitel-2-Boss mit zwei Merkmalen und 30 Leben")
check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf")
await give_up()
main.profile.campaign_stage = 0
@@ -862,13 +843,13 @@ func test_kampagne() -> void:
main.save()
func test_kampf(n_opp: int, shot_turn: int) -> void:
await start_battle(n_opp)
func test_kampf(shot_turn: int) -> void:
await start_battle()
var scr = screen()
check(scr is BattleScreen, "Kampfbildschirm")
scr.ai_delay = 0.0
var g: Game = scr.game
check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost")
check(g.players.size() == 2 and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost")
await wait_for_human(scr)
check(g.current() == g.human, "Spieler ist dran")
check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar")
@@ -898,12 +879,12 @@ func test_kampf(n_opp: int, shot_turn: int) -> void:
func test_kampf_abbrechen() -> void:
await start_battle(5)
await start_battle()
var scr = screen()
scr.ai_delay = 0.0
var g: Game = scr.game
await wait_for_human(scr)
check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner")
check(g.opponent != null and g.human.max_energy == 1, "Duell läuft")
# Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit
g.human.energy = 10
var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000)
@@ -913,7 +894,7 @@ func test_kampf_abbrechen() -> void:
scr.refresh()
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
check(reiter_view.rarity == "episch", "Handkarte trägt die eigene Stufe")
await hover(scr.log_box) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird
await hover(scr.field_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird
await hover(reiter_view)
var previews = scr.preview_holder.get_children().filter(func(c): return c is CardView)
check(previews.size() == 1 and previews[0].rarity == "episch", "Vorschau ebenfalls")
@@ -930,11 +911,10 @@ func test_kampf_abbrechen() -> void:
print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(),
" dran=", g.current().name, " Sieger=", g.winner, " hovered=", get_viewport().gui_get_hovered_control())
check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover")
await click(find_button("Lupe"))
check(scr.zoom_player == scr.others()[0], "Lupe öffnet die erste Spalte")
await shot("12_lupe")
await click(find_button("Schließen"))
check(scr.zoom_player == null, "Lupe geschlossen")
await click(scr.b_log)
check(scr.log_overlay.visible and scr.any_overlay(), "Verlauf öffnet als Überlagerung")
await key(KEY_ESCAPE)
check(not scr.log_overlay.visible, "Esc schließt den Verlauf")
# Runden laufen lassen, damit die Gegner ihre Züge machen
for round in 4:
await click(scr.b_end)
@@ -946,4 +926,4 @@ func test_kampf_abbrechen() -> void:
and g.log.any(func(l): return " greift " in l)
check(ai_attacked, "Gegner spielen Karten und greifen an")
check(scr.tiles.size() == g.players.reduce(func(acc, p): return acc + p.board.size(), 0), "eine Kachel je Wesen")
await shot("10_kampf_fuenf")
await shot("10_kampf_runden")

View File

@@ -1,6 +1,6 @@
keys,en
Dinger,Dinger
Ein Kartenspiel: jeder gegen jeden,A card game: free-for-all
Ein Kartenspiel: Duell um das Feld,A card game: duel for the field
Einzelspieler,Single player
Kampagne,Campaign
Netzwerkspiel,Network game
@@ -62,18 +62,14 @@ Normal,Normal
Schwer,Hard
Profil zurücksetzen,Reset profile
Große Schrift,Large text
Duell,Duel
%d am Tisch,%d at the table
%d Münzen,%d coins
Abbrechen,Cancel
"Ja, zurücksetzen","Yes, reset"
"Speichern und zurück","Save and go back"
"Kampf fortsetzen","Resume battle"
%s Schaden an ein gegnerisches Wesen,%s damage to an enemy creature
%s Schaden an ein Ziel,%s damage to a target
%s Schaden an alle gegnerischen Wesen,%s damage to all enemy creatures
%s Schaden an alle Gegner,%s damage to all opponents
%s Schaden an alle Gegner und ihre Wesen,%s damage to all opponents and their creatures
%s Schaden an den Gegner,%s damage to the opponent
%s Schaden an den Gegner und seine Wesen,%s damage to the opponent and their creatures
Heile dich um %s,Heal yourself for %s
Erhalte 1 Mana,Gain 1 mana
Erhalte %s Mana,Gain %s mana
@@ -117,14 +113,10 @@ Zuschauen,Watch
Zuschauer,Spectator
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden"
Starten,Start
1 Gegner,1 opponent
%d Gegner,%d opponents
KI-Gegner,AI opponents
Sammlung,Collection
Verschmelzen,Fuse
Zwei eigene Wesen wählen,Pick two of your creatures
Zweites Wesen wählen,Pick the second creature
Feld: neutral,Field: neutral
Zwei verschiedene Wesen wählen,Pick two different creatures
Nur eigene Wesen,Only your own creatures
Vulkan,Volcano
@@ -150,10 +142,26 @@ Gewöhnliche Karten lassen sich nicht zerlegen,Common cards cannot be dismantled
Aufgewertet,Upgraded
+%d Staub,+%d dust
+%d je Zug,+%d per turn
Neu würfeln (%d),Reroll (%d)
Neu würfeln ◆ %d,Reroll ◆ %d
Dein Zug,Your turn
Beim Ausspielen,On play
Nicht genug Mana,Not enough mana
Kein Angebot,No offer
Hand voll,Hand full
Profil wirklich zurücksetzen?,Really reset your profile?
"Sammlung, Münzen und Fortschritt gehen verloren.","Collection, coins and progress are lost."
Chimäre,Chimera
Angriff,attack
Eis,Ice
Resonanz +%d,Resonance +%d
Feld +%d,Field +%d
Merkmal +%d,Perk +%d
Feldkombo %s mit %s: %s,Field combo %s with %s: %s
1 Schaden an das stärkste gegnerische Wesen,1 damage to the strongest enemy creature
2 Schaden an den Gegner,2 damage to the opponent
Heile dich um 3,Heal yourself for 3
Das neue Wesen erhält +1/+1,The new creature gets +1/+1
%d Leben,%d life
Gegner mit %d Leben · %d Münzen bei Sieg,Opponent with %d life · %d coins for a win
Start gegen die KI,Start against the AI
Beginnt mit 5 Leben mehr,Starts with 5 extra life
1 keys en
2 Dinger Dinger
3 Ein Kartenspiel: jeder gegen jeden Ein Kartenspiel: Duell um das Feld A card game: free-for-all A card game: duel for the field
4 Einzelspieler Single player
5 Kampagne Campaign
6 Netzwerkspiel Network game
62 Schwer Hard
63 Profil zurücksetzen Reset profile
64 Große Schrift Large text
Duell Duel
%d am Tisch %d at the table
65 %d Münzen %d coins
66 Abbrechen Cancel
67 Ja, zurücksetzen Yes, reset
Speichern und zurück Save and go back
Kampf fortsetzen Resume battle
68 %s Schaden an ein gegnerisches Wesen %s damage to an enemy creature
69 %s Schaden an ein Ziel %s damage to a target
70 %s Schaden an alle gegnerischen Wesen %s damage to all enemy creatures
71 %s Schaden an alle Gegner %s Schaden an den Gegner %s damage to all opponents %s damage to the opponent
72 %s Schaden an alle Gegner und ihre Wesen %s Schaden an den Gegner und seine Wesen %s damage to all opponents and their creatures %s damage to the opponent and their creatures
73 Heile dich um %s Heal yourself for %s
74 Erhalte 1 Mana Gain 1 mana
75 Erhalte %s Mana Gain %s mana
113 Zuschauer Spectator
114 Zuschauer: Hände sind verdeckt Spectator: hands are hidden
115 Starten Start
1 Gegner 1 opponent
%d Gegner %d opponents
KI-Gegner AI opponents
116 Sammlung Collection
117 Verschmelzen Fuse
118 Zwei eigene Wesen wählen Pick two of your creatures
119 Zweites Wesen wählen Pick the second creature
Feld: neutral Field: neutral
120 Zwei verschiedene Wesen wählen Pick two different creatures
121 Nur eigene Wesen Only your own creatures
122 Vulkan Volcano
142 Aufgewertet Upgraded
143 +%d Staub +%d dust
144 +%d je Zug +%d per turn
145 Neu würfeln (%d) Neu würfeln ◆ %d Reroll (%d) Reroll ◆ %d
146 Dein Zug Your turn
147 Beim Ausspielen On play
148 Nicht genug Mana Not enough mana
149 Kein Angebot No offer
150 Hand voll Hand full
151 Profil wirklich zurücksetzen? Really reset your profile?
152 Sammlung, Münzen und Fortschritt gehen verloren. Collection, coins and progress are lost.
153 Chimäre Chimera
154 Angriff attack
155 Eis Ice
156 Resonanz +%d Resonance +%d
157 Feld +%d Field +%d
158 Merkmal +%d Perk +%d
159 Feldkombo %s mit %s: %s Field combo %s with %s: %s
160 1 Schaden an das stärkste gegnerische Wesen 1 damage to the strongest enemy creature
161 2 Schaden an den Gegner 2 damage to the opponent
162 Heile dich um 3 Heal yourself for 3
163 Das neue Wesen erhält +1/+1 The new creature gets +1/+1
164 %d Leben %d life
165 Gegner mit %d Leben · %d Münzen bei Sieg Opponent with %d life · %d coins for a win
166 Start gegen die KI Start against the AI
167 Beginnt mit 5 Leben mehr Starts with 5 extra life

Binary file not shown.