Compare commits

..

10 Commits

Author SHA1 Message Date
Team3
7ed58ed69a Balance in drei Messrunden: Wasser und Feuer zäher, Lava-Karten schwächer, Archetypen neu gemessen
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
Je 200 Partien pro Archetyp; Spanne 40–64 % statt 36–78 %.
Feuer+Erde bleibt mit 64 % die Spitze, Feuer mit 40 % das Schlusslicht.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 20:07:59 +02:00
Team3
4c6bf96eeb Kampf fortsetzen, Gamepad im Deckbau, Deck-Codes, Kapitel 3, Elementbooster, englische Karten, Host-Wechsel und Zuschauer
- KI-Bewertung mit Bedrohung und Führenden-Bremse (62:38 gegen die alte im Duell, gleichauf zu viert)
- Balance: Sand +1/+1 statt Schild, Erde+Luft und Erde geschwächt, Wasser angehoben, Messlauf mit N=200 möglich
- Einzelspieler-Kämpfe werden laufend gespeichert (voller Spielstand, viewer -1) und im Menü fortgesetzt
- Karten, Deckzeilen und Kartenrücken fokussierbar: Gamepad in Deckbau, Werkstatt und Booster
- Kartennamen (name_en) und Kartentexte übersetzbar, Übersetzungstest deckt cards.gd mit ab
- Deck-Code DINGER1:id*anzahl mit Zwischenablage; acht neue Karten; Kampagne Kapitel 3; Elementbooster 120 Münzen
- Netzwerk: Nachfolge-Host mit vollem Spielstand, Neuverbindung der anderen, Zuschauer (Sicht -2); Bot kann hosten
- Große Schrift: Kartennamen, Kartentext, Knopfleisten, Gegnervorschau, Dialogtext
- Anleitung 1 garantiert ein 1-Energie-Wesen; Tests: DINGER_ONLY für einzelne Oberflächentests
- Oberflächentest auch auf Xephyr mit llvmpipe (wie Xvfb in der CI) grün

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 18:50:09 +02:00
Team3
b6d5916a8d Spiel untersucht und optimiert: KI-Suche, Hintergrund-Threads, Dialog-Helfer
- KI-Vorausschau in zwei Stufen mit Zielvorauswahl (höchstens 4 Ziele je Karte
  und Angreifer, die 8 besten Erstschritte vertieft): 5 Gegner 2063 ms -> 101 ms,
  1 Gegner 160 ms -> 36 ms, alle Kandidaten im Budget; 52:48 gegen die alte Suche
- KI rechnet im WorkerThreadPool auf einer Spielkopie, schon während der Wartezeit;
  längste Bildzeit im Kampf mit 5 Gegnern 108 ms -> 38 ms
- Musik wird im Hintergrund erzeugt (Start 185 ms schneller)
- Style.overlay und Style.confirm ersetzen vier kopierte Dialoge
- Übersetzungen für Abbrechen und Zurücksetzen, Archetypen neu gemessen
- tests/perf_probe.gd (Laufzeiten) und tests/frame_probe.tscn (Bildzeiten)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 17:53:27 +02:00
Team3
e1e6211ef8 Gamepad A und B auf ui_accept und ui_cancel gemappt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 17:04:46 +02:00
Team3
77d1b318d6 Lava abgeschwächt, Archetypen aus Datei mit CI-Messlauf, Kartentext folgt Schriftgröße, Gamepad in Menüs, Anleitung 2 mit sicherem Angriff, Übersetzungsprüfung, Lint und gestaffelte CI
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 17:02:59 +02:00
Team3
f68ac07550 Archetypen mit 60 Partien gemessen, Test tabellenunabhängig
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 16:32:41 +02:00
Team3
4246d7e970 Anleitung 2, Gamepad, große Schrift, Profil-Reset, Würfel-Limit, Tipp nach Spielverlauf, KI-Zeitbudget, Deck-Warnung, Gischtläufer, Kampfbildschirm aufgeteilt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 16:27:59 +02:00
Team3
0dbc98b456 KI bewertet Kontrolle, Deckstärke mit Kurve und Statistik, Gegnervorschau, Deck-Vorschlag, Schwierigkeitstipp, DeckGen/NetState ausgelagert, Wasser gestärkt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 15:16:49 +02:00
Team3
7ae6017f12 Deckstärke, Schwierigkeitsgrade mit Archetypen, Gegnerdecks entlang der Energiekurve, Starter mit zwei seltenen Karten
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 14:04:13 +02:00
Team3
46c3fae233 Anleitung mit Hervorhebungen, Rückgängig, Kapitel 2, Herausforderungen, Fallen, Wiederverbinden, CI
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 13:27:41 +02:00
55 changed files with 3876 additions and 592 deletions

View File

@@ -1,6 +1,11 @@
name: Tests name: Tests
on: [push, pull_request] on:
push:
pull_request:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs: jobs:
headless: headless:
@@ -13,10 +18,11 @@ jobs:
unzip -q godot.zip unzip -q godot.zip
mv Godot_v4.7.2-stable_linux.x86_64 godot mv Godot_v4.7.2-stable_linux.x86_64 godot
chmod +x godot chmod +x godot
- name: Tests - name: Statische Prüfung und Tests
run: ./tests/run_headless.sh ./godot run: ./tests/run_headless.sh ./godot
fenster: fenster-schnell:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -29,7 +35,47 @@ jobs:
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1 sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
- name: Import - name: Import
run: ./godot --headless --path . --import || true run: ./godot --headless --path . --import || true
- name: Fenster-Test (virtueller Bildschirm) - name: Fenster-Test (schnell)
run: DINGER_FAST=1 xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
- name: Ergebnis
run: grep -q " 0 fehlgeschlagen" fenster.log
fenster-voll:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Godot und Xvfb laden
run: |
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
unzip -q godot.zip
mv Godot_v4.7.2-stable_linux.x86_64 godot
chmod +x godot
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
- name: Import
run: ./godot --headless --path . --import || true
- name: Fenster-Test (voll, mit Netzwerk und Hotseat)
run: xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true run: xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
- name: Ergebnis - name: Ergebnis
run: grep -q " 0 fehlgeschlagen" fenster.log run: grep -q " 0 fehlgeschlagen" fenster.log
balance:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Godot laden
run: |
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
unzip -q godot.zip
mv Godot_v4.7.2-stable_linux.x86_64 godot
chmod +x godot
- name: Import
run: ./godot --headless --path . --import || true
- name: Archetypen messen
run: ./godot --headless --path . -s tests/strength_probe.gd 2>&1 | grep -E "%|geschrieben"
- name: Ergebnis sichern
uses: actions/upload-artifact@v4
with:
name: archetypen
path: data/archetypen.json

View File

@@ -26,18 +26,50 @@ Der Spielstand liegt unter `user://spielstand.json`
- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten - Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten
- Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand) - Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand)
- Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch - Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch
- Geführte Anleitung mit Hervorhebungen, Rückgängig für Fehlklicks, Überspringen von Gegnerzügen
- Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1
- Wöchentliche Herausforderung mit Regelvarianten, Bestenliste (Serie, schnellster Sieg), Statistik-Export als CSV
- Zehn Fallenkarten, Netzwerk mit Wiederverbinden (Platz bleibt 60 s frei)
- Deckstärke aus Seltenheit, Schlüsselwörtern und Effekten; Gegnerdecks werden auf die eigene Stärke gebaut
- Schwierigkeit Leicht/Normal/Schwer: Zielstärke 0,9/1,0/1,2-fach, schwache oder starke Archetypen, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach
- Gegnervorschau vor dem Kampf (Element und Stärke, neu würfelbar), Vorschlag zur Schwierigkeit nach drei Siegen oder Niederlagen
- Deckstärke mit Kurvenabzug und Gewichtung durch die eigene Kartenstatistik; Booster schlagen passende Karten fürs Deck vor
- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben, Gischtläufer für Sturm
- Anleitung Teil 2 (Mulligan, Fallen, Feldkombo Dampf), Gamepad-Steuerung, große Schrift, Profil zurücksetzen
- Gegnervorschau mit höchstens drei Neuwürfen, Schwierigkeitstipp nach Spielverlauf (deutliche Siege oder drei Niederlagen)
- KI mit Zeitbudget je Entscheidung (150 ms), Warnung bei ungespeichertem Deck
- Lava trifft nur noch das stärkste gegnerische Wesen; Archetypen-Werte kommen aus `data/archetypen.json` (Messlauf schreibt sie)
- Kartentext passt sich der Schriftgröße an, Gamepad auch in Menüs (Fokusrahmen), Anleitung 2 erzwingt Maras Angriff in die Falle
- KI-Vorausschau in zwei Stufen (erst alle Züge mit den besten Zielen, dann die acht besten vertieft): bei 5 Gegnern 0,1 s statt 2 s, und sie rechnet im Hintergrund-Thread, Musik ebenso
- KI-Bewertung mit Bedrohung (gegnerische Angriffskraft ohne eigenen Wächter, tödliche besonders) und Führenden-Bremse; im Duell 62:38 gegen die alte Bewertung
- Unterbrochene Einzelspieler-Kämpfe (auch Kampagne und Herausforderung) werden laufend gespeichert und lassen sich im Menü fortsetzen
- Gamepad im Deckbau, in der Werkstatt und beim Booster: Karten, Deckzeilen und Kartenrücken sind fokussierbar, A wählt, B geht zurück
- Englisch auch für Kartennamen und Kartentexte (`name_en` in `data/karten.json`, Textbausteine über `tr()`)
- Deck-Codes zum Teilen (`DINGER1:id*anzahl,…`) mit Zwischenablage im Deckbau
- Kapitel 3 der Kampagne (drei Merkmale, volle Tische), acht neue Karten (je Elementpaar eine, zwei Fallen), Elementbooster für 120 Münzen
- Netzwerk: fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung (er bekommt laufend den vollen Spielstand), die anderen verbinden neu; Zuschauer sehen die Partie ohne Hände
- Balance in drei Messrunden (je 200 Partien pro Archetyp): Sand gibt +1/+1 statt Schild, Wasser und Feuer zäher, Lava-Karten leicht schwächer; Spanne jetzt 40–64 % statt 36–78 % (Feuer+Erde bleibt mit 64 % die Spitze)
- Große Schrift: Kartennamen neben dem Kostenkreis, Kartentext läuft nicht mehr über, breitere Knöpfe in Booster, Deckbau und Werkstatt
- Übersetzungsprüfung: jeder tr()-Text und jeder Anleitungsschritt braucht einen englischen Eintrag
## Tests ## Tests
`tests/run_headless.sh` führt Import und Headless-Prüfungen aus (auch in `.github/workflows/tests.yml`). `tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`:
Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test und Archetypen-Messung nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft).
`DINGER_FAST=1` lässt den Fenster-Test ohne Hotseat, Netzwerk und lange Kämpfe laufen (etwa ein Drittel der Zeit).
Statistiken aus Spieltests lassen sich unter Erfolge als CSV exportieren.
``` ```
godot --headless --path . --import # einmalig: Ressourcen importieren godot --headless --path . --import # einmalig: Ressourcen importieren
godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar
godot --headless --path . -s tests/dump_cards.gd # Kartenpool aus Code nach JSON schreiben (nur bei Bedarf) godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH)
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
DINGER_ONLY=test_host_wechsel,test_zuschauer godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
N=200 godot --headless --path . -s tests/strength_probe.gd # Messlauf mit 200 statt 60 Partien je Archetyp
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Deckerzeugung, Musik
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern
``` ```
## Export ## Export
@@ -52,11 +84,14 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
## Aufbau ## Aufbau
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json` - `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Gegner, Hotseat - `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Hotseat
- `scripts/deckgen.gd` – Deckstärke, Gegnerdecks entlang der Energiekurve, Archetypen
- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen - `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen
- `scripts/campaign.gd` – Kampagnenstufen - `scripts/campaign.gd` – Kampagnenstufen
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton - `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte, Anleitung, Mulligan, Gamepad), Erfolge, Einstellungen, Regeln, Ton
- `scenes/main.tscn` – Startszene - `scenes/main.tscn` – Startszene
- `scripts/net.gd` – Netzwerkspiel (ENet) - `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden
- `scripts/challenge.gd` – wöchentliche Herausforderungen
- `translations/ui.csv` – englische Oberflächentexte - `translations/ui.csv` – englische Oberflächentexte

View File

@@ -6,13 +6,15 @@
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln - Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl - Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
- Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt - Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt
- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich - Lokal zu mehreren am selben Rechner und im lokalen Netz möglich, auch als Zuschauer
- Ein unterbrochener Kampf lässt sich später fortsetzen
- Geführte erste Partie als Einstieg - Geführte erste Partie als Einstieg
- Wöchentliche Herausforderungen und Bestenliste als Langzeitziel
## Sammeln ## Sammeln
- Booster öffnen - Booster öffnen
- Karten haben Seltenheiten - Karten haben Seltenheiten
- Aus der Sammlung werden Decks gebaut (mehrere Slots) - Aus der Sammlung werden Decks gebaut (mehrere Slots) und als Code geteilt
- Überschüssige Karten lassen sich zerlegen und neue herstellen - Überschüssige Karten lassen sich zerlegen und neue herstellen
## Elemente ## Elemente

12
data/archetypen.json Normal file
View File

@@ -0,0 +1,12 @@
{
"Erde": 50,
"Erde+Luft": 56,
"Feuer": 40,
"Feuer+Erde": 64,
"Feuer+Luft": 42,
"Feuer+Wasser": 57,
"Luft": 45,
"Wasser": 43,
"Wasser+Erde": 50,
"Wasser+Luft": 41
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
class_name AI class_name AI
## KI mit einfacher Vorausschau: jede mögliche Aktion wird auf einer Kopie des ## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels
## Spiels ausgeführt und die Stellung danach bewertet. Die gierige Variante ## ausgeführt und die Stellung danach bewertet; die besten Erstschritte werden um
## (choose_action_greedy) dient als Vergleich. ## einen Folgeschritt vertieft. Die gierige Variante (choose_action_greedy) dient
## als Vergleich und für die Schwierigkeit Leicht. Alles hier ist zustandslos und
## darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
# --- Bewertung ------------------------------------------------------------------- # --- Bewertung -------------------------------------------------------------------
@@ -15,7 +17,9 @@ static func creature_value(c: Game.Creature) -> float:
if c.attacks_per_turn() == 2: if c.attacks_per_turn() == 2:
v += c.power() v += c.power()
if c.frozen: if c.frozen:
v -= c.power() * 0.5 v -= c.power() # setzt den nächsten Angriff aus: der Angriffswert zählt nicht
if not c.ready:
v -= c.power() * 0.25
return v return v
@@ -26,13 +30,16 @@ static func board_value(p: Game.Player) -> float:
return v return v
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, ## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, die der Gegner
## die der Gegner negativ. Ein besiegter Gegner ist viel wert. ## 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.
static func evaluate(g: Game, p: Game.Player) -> float: static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive(): if not p.alive():
return -10000.0 return -10000.0
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5 var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
var alive_enemies := 0 var alive_enemies := 0
var threat := 0.0
var leader := 0.0
for e in g.players: for e in g.players:
if e == p: if e == p:
continue continue
@@ -40,36 +47,114 @@ static func evaluate(g: Game, p: Game.Player) -> float:
score += 40.0 score += 40.0
continue continue
alive_enemies += 1 alive_enemies += 1
score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2 var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
score -= ev
leader = maxf(leader, ev)
if e.life <= 6: if e.life <= 6:
score += 3.0 # fast besiegt: dranbleiben score += 3.0 # fast besiegt: dranbleiben
for c in e.board:
if not c.frozen:
threat += c.power() * c.attacks_per_turn()
if alive_enemies == 0: if alive_enemies == 0:
return 10000.0 return 10000.0
score -= leader * 0.25
var guarded := false
for c in p.board:
if c.taunt():
guarded = true
break
if guarded:
score -= threat * 0.1
elif threat >= p.life:
score -= 25.0
else:
score -= threat * 0.3
return score return score
# --- Vorausschau --------------------------------------------------------------- # --- Vorausschau ---------------------------------------------------------------
static func candidate_actions(game: Game, p: Game.Player) -> Array: const MAX_TARGETS := 4 # je Karte bzw. Angreifer nur die aussichtsreichsten Ziele prüfen
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
## Grobe Rangfolge eines Ziels für einen Karteneffekt (nur zum Vorsortieren).
static func _target_rank(kind: String, amount: int, t) -> float:
if t is Game.Player:
return 50.0 - t.life if kind == "damage" else 0.0
match kind:
"damage":
return 100.0 + _value(t) if not t.shield and t.health <= amount else float(_value(t))
"freeze", "bounce":
return t.card.cost * 10.0 + t.power()
"buff", "shield":
return (100.0 if t.can_attack() else 0.0) + t.power()
return float(_value(t))
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann Helden, dann der Rest.
static func _attack_rank(cr: Game.Creature, t) -> float:
if t is Game.Player:
return 50.0 - t.life
if _kills(cr, t):
return (100.0 if not _kills(t, cr) else 60.0) + _value(t)
return -float(t.health)
static func _top(list: Array, rank: Callable, n: int) -> Array:
if list.size() <= n:
return list
var ranked := []
for t in list:
ranked.append([rank.call(t), t])
ranked.sort_custom(func(a, b): return a[0] > b[0])
return ranked.slice(0, n).map(func(pair): return pair[1])
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Karte und Angreifer.
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
var out := [] var out := []
var valid_cache := {}
for card in p.hand: for card in p.hand:
if not game.can_play(p, card)[0]: if not game.can_play(p, card)[0]:
continue continue
var tt: String = card.target_type() var tt: String = card.target_type()
if tt == "": if tt == "":
out.append({"type": "play", "card": card, "target": null}) out.append({"type": "play", "card": card, "target": null})
else: continue
var valid := game.valid_targets(p, tt) if not valid_cache.has(tt):
valid_cache[tt] = game.valid_targets(p, tt)
var valid: Array = valid_cache[tt]
if valid.is_empty(): if valid.is_empty():
out.append({"type": "play", "card": card, "target": null}) out.append({"type": "play", "card": card, "target": null})
continue
if prune:
var fx: Cards.Effect = null
for f in card.effects:
if f.target == tt:
fx = f
break
var kind: String = fx.kind if fx != null else ""
var amount: int = fx.amount if fx != null else 0
var key := "%s/%s/%d" % [tt, kind, amount]
if not valid_cache.has(key):
valid_cache[key] = _top(valid, func(t): return _target_rank(kind, amount, t), MAX_TARGETS)
valid = valid_cache[key]
for t in valid: for t in valid:
out.append({"type": "play", "card": card, "target": t}) out.append({"type": "play", "card": card, "target": t})
for cr in p.board: for cr in p.board:
if not cr.can_attack(): if not cr.can_attack():
continue continue
var targets := []
for e in game.enemies_of(p): for e in game.enemies_of(p):
for t in e.board + [e]: for t in e.board + [e]:
if game.can_attack(cr, t)[0]: if game.can_attack(cr, t)[0]:
targets.append(t)
if prune:
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
for t in targets:
out.append({"type": "attack", "creature": cr, "target": t}) out.append({"type": "attack", "creature": cr, "target": t})
return out return out
@@ -92,7 +177,14 @@ static func map_target(g2: Game, game: Game, t):
return g2.players[game.players.find(t)] return g2.players[game.players.find(t)]
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe ## Überträgt eine auf der Kopie gewählte Aktion zurück auf das Original.
static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary:
var out := action.duplicate()
if action.has("creature"):
out["creature"] = game.find_creature(action.creature.uid)
if action.get("target") != null:
out["target"] = map_target(game, copy, action.target)
return out
## Mulligan der KI: teure Karten im ersten Zug zurücklegen. ## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
@@ -100,32 +192,48 @@ static func mulligan_cards(p: Game.Player) -> Array:
return p.hand.filter(func(c): return c.cost >= 5) return p.hand.filter(func(c): return c.cost >= 5)
## Beste Aktion nach einer Vorausschau von zwei Schritten: jede Aktion wird auf einer ## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer
## Kopie ausgeführt und mit ihrem besten Folgeschritt bewertet (z. B. verstärken, dann angreifen). ## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug.
static func choose_action(game: Game, p: Game.Player) -> Dictionary: static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty(): if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"} return {"type": "end"}
if not p.mulligan_done and p.turns == 1 and p == game.current(): if not p.mulligan_done and p.turns == 1 and p == game.current():
return {"type": "mulligan", "cards": mulligan_cards(p)} return {"type": "mulligan", "cards": mulligan_cards(p)}
var best := {"type": "end"} var t0 := Time.get_ticks_msec()
var best_score := evaluate(game, p) + 0.01 var base := evaluate(game, p) + 0.01
for action in candidate_actions(game, p): var firsts := [] # {action, game, score}
for action in candidate_actions(game, p, true):
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
break
var g2 := game.clone_for(p) var g2 := game.clone_for(p)
apply_on_clone(g2, game, action) apply_on_clone(g2, game, action)
var p2: Game.Player = g2.players[game.current_idx] firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])})
var score := evaluate(g2, p2) firsts.sort_custom(func(a, b): return a.score > b.score)
var best := {"type": "end"}
var best_score := base
for i in mini(firsts.size(), DEEPEN):
var f: Dictionary = firsts[i]
var g2: Game = f.game
var score: float = f.score
if g2.winner == "": if g2.winner == "":
var p2: Game.Player = g2.players[game.current_idx]
var count := 0 var count := 0
for f in candidate_actions(g2, p2): for nxt in candidate_actions(g2, p2, true):
if count >= FOLLOW_UPS: if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
break break
count += 1 count += 1
var g3 := g2.clone_for(p2) var g3 := g2.clone_for(p2)
apply_on_clone(g3, g2, f) apply_on_clone(g3, g2, nxt)
score = maxf(score, evaluate(g3, g3.players[g2.current_idx]) - 0.001) score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001)
if score > best_score: if score > best_score:
best_score = score best_score = score
best = action best = f.action
# Erstschritte ohne Vertiefung zählen mit ihrer Einzelbewertung
for i in range(DEEPEN, firsts.size()):
if firsts[i].score > best_score:
best_score = firsts[i].score
best = firsts[i].action
return best return best

View File

@@ -1,41 +1,69 @@
class_name Campaign class_name Campaign
## Kampagne: zehn benannte Magier, jede Stufe mit mehr Gegnern am Tisch. ## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Gegnern am Tisch.
const STAGES := [ const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "feuer_angriff"}, {"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "leben_35"}, {"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "waechter_leben"}, {"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "reward": 150, "perk": "waechter_leben"},
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "flink_angriff"}, {"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, "bias": 1.5, "reward": 200, "perk": "zauber_schaden"}, {"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "zauber_schaden"},
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "extra_karten"}, {"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_karten"},
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "resonanz_doppelt"}, {"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "reward": 250, "perk": "resonanz_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "schild_wesen"}, {"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "reward": 250, "perk": "schild_wesen"},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "bias": 2.5, "reward": 300, "perk": "alle_flink"}, {"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, "bias": 3.0, "reward": 500, "perk": "start_energie"}, {"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "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 Gefolge zurück.
const STAGES2 := [ const STAGES2 := [
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, {"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, "bias": 2.0, "reward": 300, "perks": ["leben_35", "extra_karten"]}, {"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "reward": 300, "perks": ["leben_35", "extra_karten"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]}, {"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, "bias": 2.2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]}, {"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, "bias": 2.5, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]}, {"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["extra_karten", "flink_angriff"]}, {"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_karten", "flink_angriff"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]}, {"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, {"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": 5, "bias": 3.5, "reward": 500, "perks": ["alle_flink", "start_energie"]}, {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 500, "perks": ["alle_flink", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "bias": 4.0, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]}, {"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]},
] ]
## Kapitel 3: drei Merkmale, volle Tische, die stärksten Decks.
const STAGES3 := [
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "zauber_schaden"]},
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_karten", "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_karten"]},
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["zauber_schaden", "feuer_angriff", "waechter_leben"]},
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_karten", "flink_angriff", "resonanz_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 5, "reward": 900, "perks": ["resonanz_doppelt", "zauber_schaden", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 5, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 1000, "perks": ["alle_flink", "start_energie", "zauber_schaden"]},
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 5, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_karten", "leben_35"]},
]
const CHAPTERS := [STAGES, STAGES2, STAGES3]
# Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks.
const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6]
const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4]
const MIN_RATING3 := [3.2, 3.3, 3.5, 3.6, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4]
const MIN_RATINGS := [MIN_RATING, MIN_RATING2, MIN_RATING3]
static func target_rating(stage_index: int, player_rating: float) -> float:
var base: float = MIN_RATINGS[chapter(stage_index) - 1][stage_index % STAGES.size()]
return maxf(base, player_rating)
static func total() -> int: static func total() -> int:
return STAGES.size() + STAGES2.size() return STAGES.size() * CHAPTERS.size()
static func chapter(stage: int) -> int: static func chapter(stage: int) -> int:
return 1 if stage < STAGES.size() else 2 return stage / STAGES.size() + 1
## Stufe mit einheitlichem Feld "perks". ## Stufe mit einheitlichem Feld "perks".
@@ -44,16 +72,18 @@ static func stage(i: int) -> Dictionary:
var st: Dictionary = STAGES[i].duplicate() var st: Dictionary = STAGES[i].duplicate()
st["perks"] = [st.perk] st["perks"] = [st.perk]
return st return st
return STAGES2[i - STAGES.size()] return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
static func opponents(stage_index: int, r: RandomNumberGenerator) -> Array: ## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist).
static func opponents(stage_index: int, r: RandomNumberGenerator, player_rating: float = 0.0) -> Array:
var st := stage(stage_index) var st := stage(stage_index)
var target := target_rating(stage_index, player_rating)
var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE
var boss := Game.Player.new(st.name, Game.build_opponent_deck(r, st.elements, st.bias), false, life, st.title) var boss := Game.Player.new(st.name, Game.build_opponent_deck_rated(r, st.elements, target), false, life, st.title)
boss.perks = Array(st.perks) boss.perks = Array(st.perks)
var out := [boss] var out := [boss]
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
for k in extra: for k in extra:
out.append(Game.random_opponent(r, k + 1)) out.append(Game.random_opponent(r, k + 1, Game.START_LIFE, target))
return out return out

View File

@@ -29,13 +29,13 @@ const COMBOS := {
# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird. # Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird.
const FIELD_COMBO_TEXT := { const FIELD_COMBO_TEXT := {
"Dampf": "Friere das stärkste gegnerische Wesen ein", "Dampf": "Friere das stärkste gegnerische Wesen ein",
"Lava": "1 Schaden an alle gegnerischen Wesen", "Lava": "1 Schaden an das stärkste gegnerische Wesen",
"Blitz": "2 Schaden an den schwächsten Gegner", "Blitz": "2 Schaden an den schwächsten Gegner",
"Schlamm": "Heile dich um 3", "Schlamm": "Heile dich um 3",
"Sturm": "Ziehe 1 Karte", "Sturm": "Ziehe 1 Karte",
"Sand": "Das neue Wesen erhält Schild", "Sand": "Das neue Wesen erhält +1/+1",
} }
const RESONANCE_TEXT := "Resonanz: Ein Wesen hat +1 Angriff je anderem eigenen Wesen, das ein Element mit ihm teilt (höchstens +4)." const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern."
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"] const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
const RARITY_COLORS := { const RARITY_COLORS := {
"gewöhnlich": Color("aaaaaa"), "gewöhnlich": Color("aaaaaa"),
@@ -52,6 +52,8 @@ const KEYWORD_HELP := {
"Doppelschlag": "Greift zweimal pro Zug an.", "Doppelschlag": "Greift zweimal pro Zug an.",
} }
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"] const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"]
# Näherung der Kartenstärke: Seltenheit plus Zuschläge für Schlüsselwörter und Effekte.
const RARITY_POINTS := {"gewöhnlich": 1.0, "selten": 2.0, "episch": 3.0, "legendär": 4.0, "mythisch": 5.0}
const DATA_PATH := "res://data/karten.json" const DATA_PATH := "res://data/karten.json"
@@ -72,7 +74,7 @@ static func field_combo_effect(name: String) -> Effect:
"Dampf": "Dampf":
return Effect.new("freeze", "strongest_enemy_creature") return Effect.new("freeze", "strongest_enemy_creature")
"Lava": "Lava":
return Effect.new("damage", "all_enemy_creatures", 1) return Effect.new("damage", "strongest_enemy_creature", 1)
"Blitz": "Blitz":
return Effect.new("damage", "weakest_enemy_hero", 2) return Effect.new("damage", "weakest_enemy_hero", 2)
"Schlamm": "Schlamm":
@@ -80,7 +82,7 @@ static func field_combo_effect(name: String) -> Effect:
"Sturm": "Sturm":
return Effect.new("draw", "none", 1) return Effect.new("draw", "none", 1)
"Sand": "Sand":
return Effect.new("shield", "self_creature") return Effect.new("buff", "self_creature", 1, 1)
return null return null
@@ -117,62 +119,73 @@ class Effect extends RefCounted:
static func from_dict(d: Dictionary) -> Effect: static func from_dict(d: Dictionary) -> Effect:
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", ""))) return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
func text() -> String: ## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl).
func text(trigger: String = "") -> String:
var n := str(amount) var n := str(amount)
if target == "trigger_source": if target == "trigger_source":
var played := trigger == "creature_played"
match kind: match kind:
"damage": "damage":
return n + " Schaden an den Angreifer" if played:
return tr("%s Schaden an dieses Wesen") % n
return tr("%s Schaden an den Angreifer") % n
"freeze": "freeze":
return "Der Angreifer wird eingefroren" if played:
return tr("Es wird eingefroren")
return tr("Der Angreifer wird eingefroren")
"bounce": "bounce":
return "Es geht zurück auf die Hand" return tr("Es geht zurück auf die Hand")
match kind: match kind:
"cancel": "cancel":
return "Der Angriff verpufft" return tr("Der Angriff verpufft")
"summon": "summon":
return "Beschwöre einen " + Cards.by_id(card_id).name return tr("Beschwöre einen %s") % Cards.by_id(card_id).title()
"damage": "damage":
match target: match target:
"enemy_creature": "enemy_creature":
return n + " Schaden an ein gegnerisches Wesen" return tr("%s Schaden an ein gegnerisches Wesen") % n
"enemy_any": "enemy_any":
return n + " Schaden an ein Ziel" return tr("%s Schaden an ein Ziel") % n
"all_enemy_creatures": "all_enemy_creatures":
return n + " Schaden an alle gegnerischen Wesen" return tr("%s Schaden an alle gegnerischen Wesen") % n
"all_enemy_heroes": "all_enemy_heroes":
return n + " Schaden an alle Gegner" return tr("%s Schaden an alle Gegner") % n
"all_enemies": "all_enemies":
return n + " Schaden an alle Gegner und ihre Wesen" return tr("%s Schaden an alle Gegner und ihre Wesen") % n
"heal": "heal":
return "Heile dich um " + n return tr("Heile dich um %s") % n
"draw": "draw":
return "Ziehe 1 Karte" if amount == 1 else "Ziehe " + n + " Karten" if amount == 1:
return tr("Ziehe 1 Karte")
return tr("Ziehe %s Karten") % n
"freeze": "freeze":
if target == "enemy_creature": if target == "enemy_creature":
return "Friere ein gegnerisches Wesen ein" return tr("Friere ein gegnerisches Wesen ein")
return "Friere alle gegnerischen Wesen ein" if target == "strongest_enemy_creature":
return tr("Friere das stärkste gegnerische Wesen ein")
return tr("Friere alle gegnerischen Wesen ein")
"bounce": "bounce":
if target == "enemy_creature": if target == "enemy_creature":
return "Ein gegnerisches Wesen zurück auf die Hand" return tr("Ein gegnerisches Wesen zurück auf die Hand")
return "Alle gegnerischen Wesen zurück auf die Hand" return tr("Alle gegnerischen Wesen zurück auf die Hand")
"buff": "buff":
var b := "+%d/+%d" % [amount, amount2] var b := "+%d/+%d" % [amount, amount2]
if target == "friendly_creature": if target == "friendly_creature":
return "Ein befreundetes Wesen erhält " + b return tr("Ein befreundetes Wesen erhält %s") % b
return "Alle befreundeten Wesen erhalten " + b return tr("Alle befreundeten Wesen erhalten %s") % b
"shield": "shield":
if target == "friendly_creature": if target == "friendly_creature":
return "Ein befreundetes Wesen erhält Schild" return tr("Ein befreundetes Wesen erhält Schild")
if target == "self_creature": if target == "self_creature":
return "Erhält Schild" return tr("Erhält Schild")
return "Alle befreundeten Wesen erhalten Schild" return tr("Alle befreundeten Wesen erhalten Schild")
return "" return ""
class Card extends RefCounted: class Card extends RefCounted:
var id: String var id: String
var name: String var name: String # deutscher Name (auch im Verlauf und in Tests)
var name_en := "" # englischer Name
var elements: Array var elements: Array
var cost: int var cost: int
var rarity: String var rarity: String
@@ -190,7 +203,7 @@ class Card extends RefCounted:
return kind == "Falle" return kind == "Falle"
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
var d := {"id": id, "name": name, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind} var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind}
if is_creature(): if is_creature():
d["attack"] = attack d["attack"] = attack
d["health"] = health d["health"] = health
@@ -206,6 +219,7 @@ class Card extends RefCounted:
var c := Card.new() var c := Card.new()
c.id = d.id c.id = d.id
c.name = d.name c.name = d.name
c.name_en = str(d.get("name_en", ""))
c.elements = Array(d.elements) c.elements = Array(d.elements)
c.cost = int(d.cost) c.cost = int(d.cost)
c.rarity = d.rarity c.rarity = d.rarity
@@ -218,6 +232,12 @@ class Card extends RefCounted:
c.trigger = str(d.get("trigger", "")) c.trigger = str(d.get("trigger", ""))
return c return c
## Anzeigename in der aktuellen Sprache.
func title() -> String:
if name_en != "" and TranslationServer.get_locale().begins_with("en"):
return name_en
return name
func is_combo() -> bool: func is_combo() -> bool:
return elements.size() >= 2 return elements.size() >= 2
@@ -236,19 +256,19 @@ class Card extends RefCounted:
var kws: Array[String] = [] var kws: Array[String] = []
for k in Cards.KEYWORDS: for k in Cards.KEYWORDS:
if k in keywords: if k in keywords:
kws.append(k) kws.append(tr(k))
parts.append(", ".join(kws) + ".") parts.append(", ".join(kws) + ".")
if not effects.is_empty(): if not effects.is_empty():
var fx: Array[String] = [] var fx: Array[String] = []
for f in effects: for f in effects:
fx.append(f.text()) fx.append(f.text(trigger))
var s := ", ".join(fx) + "." var s := ", ".join(fx) + "."
if is_trap(): if is_trap():
if trigger == "creature_played": parts.append(tr("Falle. %s: %s") % [tr(Cards.TRIGGER_TEXT[trigger]), s])
s = s.replace("an den Angreifer", "an dieses Wesen").replace("Der Angreifer wird", "Es wird") elif is_creature():
parts.append("Falle. %s: %s" % [Cards.TRIGGER_TEXT[trigger], s]) parts.append(tr("Beim Ausspielen: %s") % s)
else: else:
parts.append(("Beim Ausspielen: " + s) if is_creature() else s) parts.append(s)
return " ".join(parts) return " ".join(parts)
@@ -274,6 +294,10 @@ static func has_id(id) -> bool:
return _by_id.has(id) return _by_id.has(id)
static func card_value(c: Card) -> float:
return RARITY_POINTS[c.rarity] + 0.3 * c.keywords.size() + 0.3 * c.effects.size()
static func of_rarity(r: String) -> Array: static func of_rarity(r: String) -> Array:
return all().filter(func(c): return c.rarity == r) return all().filter(func(c): return c.rarity == r)

142
scripts/deckgen.gd Normal file
View File

@@ -0,0 +1,142 @@
class_name DeckGen
## Deckstärke, Gegnerdecks und Archetypen (ausgelagert aus den Spielregeln).
const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3}
# Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner).
const RESONANCE_STEPS := [0, 1, 2, 3, 3]
# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, 60 Partien je Archetyp, tests/strength_probe.gd).
const ARCHETYPE_STRENGTH := {"Erde": 50, "Erde+Luft": 56, "Feuer": 40, "Feuer+Erde": 64, "Feuer+Luft": 42, "Feuer+Wasser": 57, "Luft": 45, "Wasser": 43, "Wasser+Erde": 50, "Wasser+Luft": 41}
const ARCHETYPE_PATH := "res://data/archetypen.json"
static var _strength: Dictionary = {}
## Gemessene Werte aus data/archetypen.json (vom Messlauf geschrieben), sonst die eingebaute Tabelle.
static func strength() -> Dictionary:
if _strength.is_empty():
_strength = ARCHETYPE_STRENGTH.duplicate()
if FileAccess.file_exists(ARCHETYPE_PATH):
var json := JSON.new()
if json.parse(FileAccess.get_file_as_string(ARCHETYPE_PATH)) == OK and json.data is Dictionary:
for k in json.data:
if _strength.has(k):
_strength[k] = int(json.data[k])
return _strength
## Elementkombinationen einer Stufe: "leicht" die schwächere Hälfte, "schwer" die stärkere, sonst alle.
static func archetypes(tier: String = "") -> Array:
var table := strength()
var keys: Array = table.keys()
keys.sort_custom(func(a, b): return table[a] < table[b])
var half: int = keys.size() / 2
match tier:
"leicht":
return keys.slice(0, half)
"schwer":
return keys.slice(half)
return keys
# Energiekurve der Gegnerdecks: Anzahl Karten je Kosten (6 = sechs und mehr).
const CURVE := {1: 3, 2: 5, 3: 4, 4: 3, 5: 2, 6: 3}
## Gegnerdeck aus den Elementen entlang der Energiekurve. bias > 1 bevorzugt seltene Karten, bias < 1 gewöhnliche.
static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array:
var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements))
var deck := []
var counts := {}
for bucket in CURVE:
for i in CURVE[bucket]:
var c = _pick_for_cost(r, pool, counts, bucket, bias)
if c != null:
counts[c.id] = counts.get(c.id, 0) + 1
deck.append(c)
while deck.size() < Game.DECK_SIZE:
var c = _pick_for_cost(r, pool, counts, r.randi_range(1, 6), bias)
if c == null:
break
counts[c.id] = counts.get(c.id, 0) + 1
deck.append(c)
return deck
## Karte nahe den gewünschten Kosten, gewichtet nach Seltenheit (bias) und Kostenabstand.
## Bei extremer Seltenheitsvorgabe darf die Kurve nachgeben, sonst wäre das Ziel unerreichbar.
static func _pick_for_cost(r: RandomNumberGenerator, pool: Array, counts: Dictionary, cost: int, bias: float):
var candidates := []
var weights := PackedFloat32Array()
for c in pool:
if counts.get(c.id, 0) >= Game.MAX_COPIES:
continue
var spread: int = absi(mini(c.cost, 6) - cost)
var w: float = OPPONENT_RARITY_WEIGHT[c.rarity]
w = w / bias if c.rarity == "gewöhnlich" else w * bias
w *= pow(0.3, spread)
candidates.append(c)
weights.append(w)
if candidates.is_empty():
return null
return candidates[r.rand_weighted(weights)]
## Gegnerdeck auf eine Zielstärke: die Gewichtung wird angepasst, bis das Deck nahe am Ziel liegt.
static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array:
var bias := 1.0
var best := []
var best_diff := 99.0
for attempt in 24:
var deck := build_opponent_deck(r, elements, bias)
var diff := deck_rating(deck) - target
if absf(diff) < best_diff:
best = deck
best_diff = absf(diff)
if absf(diff) <= tolerance:
break
bias = clampf(bias * (1.35 if diff < 0 else 1.0 / 1.35), 0.05, 20.0)
return best
## Zufälliger Gegner. target > 0: Deck auf diese Stärke gebaut. tier: Archetypen der Schwierigkeit.
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = Game.START_LIFE, target: float = 0.0, tier: String = "") -> Game.Player:
var options := archetypes(tier)
var elements: Array = options[r.randi_range(0, options.size() - 1)].split("+")
var deck := build_opponent_deck_rated(r, elements, target) if target > 0.0 else build_opponent_deck(r, elements)
return Game.Player.new("Gegner %d" % index, deck, false, life, Cards.combo_name(elements) + "-Magier")
## Kartenwert, bei genug Partien mit der Siegquote der Karte gewichtet (Statistik aus Spieltests).
static func card_value(c: Cards.Card, stats: Dictionary = {}) -> float:
var v := Cards.card_value(c)
if stats.has(c.id):
var st: Dictionary = stats[c.id]
if int(st.games) >= 10:
v *= 0.5 + float(st.wins) / float(st.games)
return v
## Abzug für schlechte Energiekurven: zu wenig günstige oder zu viele teure Karten.
static func curve_penalty(cards: Array) -> float:
var cheap := 0
var expensive := 0
for c in cards:
if c.cost <= 2:
cheap += 1
if c.cost >= 5:
expensive += 1
return minf(0.05 * maxi(0, 6 - cheap) + 0.05 * maxi(0, expensive - 5), 0.4)
## Deckstärke: Durchschnitt der Kartenwerte minus Kurvenabzug (1 = nur gewöhnliche Karten, bis etwa 3,5).
static func deck_rating(cards: Array, stats: Dictionary = {}) -> float:
if cards.is_empty():
return 0.0
var sum := 0.0
for c in cards:
sum += card_value(c, stats)
return sum / cards.size() - curve_penalty(cards)

1
scripts/deckgen.gd.uid Normal file
View File

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

View File

@@ -43,7 +43,6 @@ static func make_rng() -> RandomNumberGenerator:
const DECK_SIZE := 20 const DECK_SIZE := 20
const MAX_COPIES := 3 const MAX_COPIES := 3
const MAX_DECK_ELEMENTS := 2 const MAX_DECK_ELEMENTS := 2
const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3}
class Creature extends RefCounted: class Creature extends RefCounted:
@@ -316,7 +315,7 @@ func mulligan(p: Player, cards: Array) -> String:
if n > 0: if n > 0:
shuffle(p.deck, rng) shuffle(p.deck, rng)
draw(p, n) draw(p, n)
say("%s tauscht %d Karten." % [p.name, n] if n > 0 else "%s behält die Hand." % p.name) say(("%s tauscht 1 Karte." % p.name if n == 1 else "%s tauscht %d Karten." % [p.name, n]) if n > 0 else "%s behält die Hand." % p.name)
emit({"type": "mulligan", "player": players.find(p), "count": n}) emit({"type": "mulligan", "player": players.find(p), "count": n})
return "" return ""
@@ -475,7 +474,12 @@ func summon(p: Player, card: Cards.Card) -> void:
update_resonance() update_resonance()
## Resonanz: +1 Angriff je anderem eigenen Wesen, das ein Element teilt (höchstens +4). ## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3.
static func resonance_for(partners: int) -> int:
return DeckGen.RESONANCE_STEPS[mini(partners, DeckGen.RESONANCE_STEPS.size() - 1)]
## Resonanz: abnehmender Angriffsbonus je Partner mit gleichem Element (siehe resonance_for).
func update_resonance() -> void: func update_resonance() -> void:
for p in players: for p in players:
for c in p.board: for c in p.board:
@@ -483,7 +487,7 @@ func update_resonance() -> void:
for o in p.board: for o in p.board:
if o != c and o.card.elements.any(func(e): return e in c.card.elements): if o != c and o.card.elements.any(func(e): return e in c.card.elements):
bonus += 1 bonus += 1
c.resonance = mini(bonus, RESONANCE_MAX) * (2 if "resonanz_doppelt" in p.perks else 1) c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1)
func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void: func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void:
@@ -643,103 +647,14 @@ func check_end() -> void:
say("Alle Gegner sind besiegt!" if humans.size() == 1 else "%s gewinnt!" % winner_player.name) say("Alle Gegner sind besiegt!" if humans.size() == 1 else "%s gewinnt!" % winner_player.name)
# --- Zustand für das Netzwerk ------------------------------------------------- # --- Zustand für das Netzwerk (siehe NetState) ------------------------------------
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl.
func snapshot(viewer: int) -> Dictionary: func snapshot(viewer: int) -> Dictionary:
var ps := [] return NetState.snapshot(self, viewer)
for i in players.size():
var p: Player = players[i]
var mine := i == viewer
var board := []
for c in p.board:
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
"ready": c.ready, "attacks_left": c.attacks_left})
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
"deck": p.deck.size(), "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
"board": board})
var evs := []
for ev in events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = e.card.id
evs.append(e)
return {"players": ps, "human": players.find(human), "turn": turn, "current_idx": current_idx,
"winner": winner, "winner_player": players.find(winner_player) if winner_player != null else -1,
"next_uid": next_uid, "log": log.slice(maxi(0, log.size() - 80)), "events": evs}
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
static func from_snapshot(d: Dictionary) -> Game: static func from_snapshot(d: Dictionary) -> Game:
var placeholder: Cards.Card = Cards.all()[0] return NetState.from_snapshot(d)
var list := []
for pd in d.players:
var p := Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title)
p.max_life = int(pd.max_life)
p.energy = int(pd.energy)
p.max_energy = int(pd.max_energy)
p.fatigue = int(pd.fatigue)
p.start_bonus = int(pd.start_bonus)
p.order = int(pd.order)
p.perks = Array(pd.perks)
p.turns = int(pd.turns)
p.mulligan_done = bool(pd.mulligan_done)
if pd.hand is Array:
for cid in pd.hand:
p.hand.append(Cards.by_id(cid))
else:
for i in int(pd.hand):
p.hand.append(placeholder)
for i in int(pd.deck):
p.deck.append(placeholder)
if pd.traps is Array:
for cid in pd.traps:
p.traps.append(Cards.by_id(cid))
else:
for i in int(pd.traps):
p.traps.append(placeholder)
for cd in pd.board:
var c := Creature.new(Cards.by_id(cd.card), p, int(cd.uid))
c.attack = int(cd.attack)
c.resonance = int(cd.resonance)
c.health = int(cd.health)
c.max_health = int(cd.max_health)
c.shield = bool(cd.shield)
c.frozen = bool(cd.frozen)
c.ready = bool(cd.ready)
c.attacks_left = int(cd.attacks_left)
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())
g.players = list
g.turn = int(d.turn)
g.current_idx = int(d.current_idx)
g.winner = d.winner
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
g.next_uid = int(d.next_uid)
for line in d.log:
g.log.append(str(line))
for ev in d.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = Cards.by_id(e.card)
for key in ["player", "uid", "count", "amount"]:
if e.has(key):
e[key] = int(e[key])
for key in ["target", "attacker", "source"]:
if e.has(key):
var r: Dictionary = e[key]
if r.has("uid"):
r["uid"] = int(r.uid)
if r.has("index"):
r["index"] = int(r.index)
g.events.append(e)
return g
# --- Deckregeln und Gegner --------------------------------------------------- # --- Deckregeln und Gegner ---------------------------------------------------
@@ -778,43 +693,38 @@ static func validate_deck(cards: Array, owned = null) -> Array:
return [true, ""] return [true, ""]
## Gegnerdeck aus den Elementen. bias > 1 bevorzugt seltene Karten (Kampagne). ## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int, "target_rating": float, "tier": String}
# --- Deckstärke und Gegner (siehe DeckGen) --------------------------------------
static func deck_rating(cards: Array, stats: Dictionary = {}) -> float:
return DeckGen.deck_rating(cards, stats)
static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array: static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array:
var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements)) return DeckGen.build_opponent_deck(r, elements, bias)
var weights := PackedFloat32Array()
for c in pool:
var w: float = OPPONENT_RARITY_WEIGHT[c.rarity]
weights.append(w / bias if c.rarity == "gewöhnlich" else w * bias)
var deck := []
var counts := {}
while deck.size() < DECK_SIZE:
var c = pool[r.rand_weighted(weights)]
if counts.get(c.id, 0) < MAX_COPIES:
counts[c.id] = counts.get(c.id, 0) + 1
deck.append(c)
return deck
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE) -> Player: static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array:
var elements: Array return DeckGen.build_opponent_deck_rated(r, elements, target, tolerance)
if r.randf() < 0.4:
elements = [Cards.ELEMENTS[r.randi_range(0, Cards.ELEMENTS.size() - 1)]]
else: static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE, target: float = 0.0, tier: String = "") -> Player:
var pool: Array = Cards.ELEMENTS.duplicate() return DeckGen.random_opponent(r, index, life, target, tier)
shuffle(pool, r)
elements = [pool[0], pool[1]]
return Player.new("Gegner %d" % index, build_opponent_deck(r, elements), false, life, static func archetypes(tier: String = "") -> Array:
Cards.combo_name(elements) + "-Magier") return DeckGen.archetypes(tier)
## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int}
static func new_battle(deck: Array, n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game: static func new_battle(deck: Array, n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner") assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner")
if r == null: if r == null:
r = make_rng() r = make_rng()
var target: float = float(rules.get("target_rating", 0.0))
var tier: String = str(rules.get("tier", ""))
var opps := [] var opps := []
for i in n_opp: for i in n_opp:
opps.append(random_opponent(r, i + 1)) opps.append(random_opponent(r, i + 1, START_LIFE, target, tier))
return new_battle_with(deck, opps, r, false, rules) return new_battle_with(deck, opps, r, false, rules)

View File

@@ -8,6 +8,8 @@ signal started(seat: int)
signal snapshot_received(snap: Dictionary) signal snapshot_received(snap: Dictionary)
signal action_applied signal action_applied
signal ended(reason: String) signal ended(reason: String)
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
signal status_changed
const PORT := 24455 const PORT := 24455
@@ -23,15 +25,25 @@ var my_seat := -1
var game: Game # Host var game: Game # Host
var status := "" var status := ""
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
const REJOIN_SECONDS := 60.0 var rejoin_seconds := 60.0
var spectator := false # eigene Rolle: nur zuschauen
var spectators := {} # Host: peer_id -> Name
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
var i_am_successor := false
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
var reconnecting := false
func _ready() -> void: func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected) multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected) multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(func(): _fail("Verbindung fehlgeschlagen")) multiplayer.connection_failed.connect(func():
multiplayer.server_disconnected.connect(func(): _fail("Host hat das Spiel beendet")) if not reconnecting:
_fail("Verbindung fehlgeschlagen"))
multiplayer.server_disconnected.connect(_on_server_lost)
func host(port: int = PORT) -> Error: func host(port: int = PORT) -> Error:
@@ -45,6 +57,7 @@ func host(port: int = PORT) -> Error:
connected = true connected = true
names = {1: my_name} names = {1: my_name}
decks = {1: my_deck} decks = {1: my_deck}
spectators = {}
status = "Warte auf Mitspieler …" status = "Warte auf Mitspieler …"
lobby_changed.emit() lobby_changed.emit()
return OK return OK
@@ -76,6 +89,13 @@ func leave() -> void:
my_seat = -1 my_seat = -1
game = null game = null
status = "" status = ""
spectator = false
spectators = {}
successor_ip = ""
i_am_successor = false
backup = {}
backup_names = {}
last_winner = ""
func local_addresses() -> Array: func local_addresses() -> Array:
@@ -91,7 +111,114 @@ func _fail(reason: String) -> void:
func _on_connected() -> void: func _on_connected() -> void:
connected = true connected = true
status = "Verbunden" status = "Verbunden"
register.rpc_id(1, my_name, my_deck) register.rpc_id(1, my_name, my_deck, spectator)
# --- Host-Wechsel ---------------------------------------------------------------
# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen
# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der
# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück.
func _successor_id() -> int:
var ids: Array = seats.keys().filter(func(id): return id != 1)
ids.sort()
return ids[0] if not ids.is_empty() else -1
func _update_successor() -> void:
if not is_host or game == null:
return
var succ := _successor_id()
var ip := ""
if succ > 0 and peer != null and peer.get_peer(succ) != null:
ip = peer.get_peer(succ).get_remote_address()
for id in seats:
if id != 1:
set_successor.rpc_id(id, ip, id == succ)
## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel.
func _seat_names() -> Dictionary:
var out := {}
for id in seats:
out[seats[id]] = names.get(id, "")
for pname in waiting:
out[waiting[pname].seat] = pname
return out
@rpc("authority", "reliable")
func set_successor(ip: String, me: bool) -> void:
successor_ip = ip
i_am_successor = me
@rpc("authority", "reliable")
func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void:
backup = snap
backup_names = seat_names
func _on_server_lost() -> void:
if is_host:
return
if last_winner != "":
_fail("Host hat das Spiel beendet")
elif i_am_successor and not backup.is_empty():
_become_host()
elif successor_ip != "":
status = "Host weg, verbinde mit dem Nachfolger …"
status_changed.emit()
_reconnect(successor_ip)
else:
_fail("Host hat das Spiel beendet")
func _become_host() -> void:
var snap := backup
var seat_names := backup_names
var seat := my_seat
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
var err := FAILED
for attempt in 5:
err = host()
if err == OK:
break
await get_tree().create_timer(1.0).timeout
if err != OK:
_fail("Host weg, Übernahme fehlgeschlagen")
return
game = Game.from_snapshot(snap)
seats = {1: seat}
my_seat = seat
waiting = {}
for s in seat_names:
if int(s) != seat:
waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()}
game.say("%s übernimmt als Host." % my_name)
status = "Du bist jetzt Host, die anderen verbinden neu …"
successor_ip = ""
i_am_successor = false
backup = {}
migrated.emit()
func _reconnect(ip: String) -> void:
reconnecting = true
await get_tree().create_timer(2.0).timeout
for attempt in 5:
if join(ip) == OK:
var t := 0.0
while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING:
await get_tree().process_frame
t += get_process_delta_time()
if connected:
reconnecting = false
return
await get_tree().create_timer(1.0).timeout
reconnecting = false
_fail("Nachfolge-Host nicht erreichbar")
func _on_peer_connected(_id: int) -> void: func _on_peer_connected(_id: int) -> void:
@@ -104,14 +231,16 @@ func _on_peer_disconnected(id: int) -> void:
var pname: String = names.get(id, "") var pname: String = names.get(id, "")
names.erase(id) names.erase(id)
decks.erase(id) decks.erase(id)
spectators.erase(id)
if game != null and seats.has(id): if game != null and seats.has(id):
var p: Game.Player = game.players[seats[id]] var p: Game.Player = game.players[seats[id]]
if game.winner == "": if game.winner == "":
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()} waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(REJOIN_SECONDS)]) game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)])
seats.erase(id) seats.erase(id)
_update_successor()
action_applied.emit() action_applied.emit()
lobby_update.rpc(names) lobby_update.rpc(names, spectators)
lobby_changed.emit() lobby_changed.emit()
@@ -123,7 +252,7 @@ func tick() -> void:
for pname in waiting.keys(): for pname in waiting.keys():
var w: Dictionary = waiting[pname] var w: Dictionary = waiting[pname]
var p: Game.Player = game.players[w.seat] var p: Game.Player = game.players[w.seat]
if now - float(w.since) > REJOIN_SECONDS: if now - float(w.since) > rejoin_seconds:
waiting.erase(pname) waiting.erase(pname)
game.say("%s kehrt nicht zurück und scheidet aus." % p.name) game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
game.damage(p, 999) game.damage(p, 999)
@@ -137,10 +266,18 @@ func tick() -> void:
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func register(name: String, deck_ids: Array) -> void: func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void:
if not is_host: if not is_host:
return return
var id := multiplayer.get_remote_sender_id() var id := multiplayer.get_remote_sender_id()
if as_spectator:
spectators[id] = name
lobby_update.rpc(names, spectators)
lobby_changed.emit()
if game != null:
start_game.rpc_id(id, -1)
receive_snapshot.rpc_id(id, game.snapshot(-2))
return
if game != null: if game != null:
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz # Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
if waiting.has(name): if waiting.has(name):
@@ -150,19 +287,21 @@ func register(name: String, deck_ids: Array) -> void:
game.say("%s ist zurück." % name) game.say("%s ist zurück." % name)
start_game.rpc_id(id, seats[id]) start_game.rpc_id(id, seats[id])
receive_snapshot.rpc_id(id, game.snapshot(seats[id])) receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
_update_successor()
action_applied.emit() action_applied.emit()
else: else:
status = "%s wollte beitreten, das Spiel läuft schon" % name status = "%s wollte beitreten, das Spiel läuft schon" % name
return return
names[id] = name names[id] = name
decks[id] = deck_ids decks[id] = deck_ids
lobby_update.rpc(names) lobby_update.rpc(names, spectators)
lobby_changed.emit() lobby_changed.emit()
@rpc("authority", "reliable") @rpc("authority", "reliable")
func lobby_update(n: Dictionary) -> void: func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
names = n names = n
spectators = sp
lobby_changed.emit() lobby_changed.emit()
@@ -194,6 +333,9 @@ func start_battle(n_ai: int) -> void:
for id in ids: for id in ids:
if id != 1: if id != 1:
start_game.rpc_id(id, seats[id]) start_game.rpc_id(id, seats[id])
for id in spectators:
start_game.rpc_id(id, -1)
_update_successor()
started.emit(my_seat) started.emit(my_seat)
@@ -203,17 +345,24 @@ func start_game(seat: int) -> void:
started.emit(seat) started.emit(seat)
## Host: Zustand an alle Mitspieler senden (jeder aus seiner Sicht). ## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes,
## der Nachfolger zusätzlich den vollen Spielstand).
func broadcast() -> void: func broadcast() -> void:
if not is_host or game == null: if not is_host or game == null:
return return
for id in seats: for id in seats:
if id != 1: if id != 1:
receive_snapshot.rpc_id(id, game.snapshot(seats[id])) receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
for id in spectators:
receive_snapshot.rpc_id(id, game.snapshot(-2))
var succ := _successor_id()
if succ > 0:
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
@rpc("authority", "reliable") @rpc("authority", "reliable")
func receive_snapshot(snap: Dictionary) -> void: func receive_snapshot(snap: Dictionary) -> void:
last_winner = str(snap.get("winner", ""))
snapshot_received.emit(snap) snapshot_received.emit(snap)

105
scripts/netstate.gd Normal file
View File

@@ -0,0 +1,105 @@
class_name NetState
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln).
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl.
## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer).
static func snapshot(g: Game, viewer: int) -> Dictionary:
var ps := []
for i in g.players.size():
var p: Game.Player = g.players[i]
var mine := i == viewer or viewer == -1
var board := []
for c in p.board:
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
"ready": c.ready, "attacks_left": c.attacks_left})
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
"deck": p.deck.map(func(c): return c.id) if viewer == -1 else p.deck.size(),
"traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
"board": board})
var evs := []
for ev in g.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = e.card.id
evs.append(e)
return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx,
"winner": g.winner, "winner_player": g.players.find(g.winner_player) if g.winner_player != null else -1,
"next_uid": g.next_uid, "log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs}
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
static func from_snapshot(d: Dictionary) -> Game:
var placeholder: Cards.Card = Cards.all()[0]
var list := []
for pd in d.players:
var p := Game.Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title)
p.max_life = int(pd.max_life)
p.energy = int(pd.energy)
p.max_energy = int(pd.max_energy)
p.fatigue = int(pd.fatigue)
p.start_bonus = int(pd.start_bonus)
p.order = int(pd.order)
p.perks = Array(pd.perks)
p.turns = int(pd.turns)
p.mulligan_done = bool(pd.mulligan_done)
if pd.hand is Array:
for cid in pd.hand:
p.hand.append(Cards.by_id(cid))
else:
for i in int(pd.hand):
p.hand.append(placeholder)
if pd.deck is Array:
for cid in pd.deck:
p.deck.append(Cards.by_id(cid))
else:
for i in int(pd.deck):
p.deck.append(placeholder)
if pd.traps is Array:
for cid in pd.traps:
p.traps.append(Cards.by_id(cid))
else:
for i in int(pd.traps):
p.traps.append(placeholder)
for cd in pd.board:
var c := Game.Creature.new(Cards.by_id(cd.card), p, int(cd.uid))
c.attack = int(cd.attack)
c.resonance = int(cd.resonance)
c.health = int(cd.health)
c.max_health = int(cd.max_health)
c.shield = bool(cd.shield)
c.frozen = bool(cd.frozen)
c.ready = bool(cd.ready)
c.attacks_left = int(cd.attacks_left)
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())
g.players = list
g.turn = int(d.turn)
g.current_idx = int(d.current_idx)
g.winner = d.winner
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
g.next_uid = int(d.next_uid)
for line in d.log:
g.log.append(str(line))
for ev in d.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = Cards.by_id(e.card)
for key in ["player", "uid", "count", "amount"]:
if e.has(key):
e[key] = int(e[key])
for key in ["target", "attacker", "source"]:
if e.has(key):
var r: Dictionary = e[key]
if r.has("uid"):
r["uid"] = int(r.uid)
if r.has("index"):
r["index"] = int(r.index)
g.events.append(e)
return g

1
scripts/netstate.gd.uid Normal file
View File

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

View File

@@ -6,12 +6,20 @@ const SAVE_VERSION := 5
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
const SAVE_PATH := "user://spielstand.json" const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100 const BOOSTER_COST := 100
const ELEMENT_BOOSTER_COST := 120 # nur Karten eines Elements (auch seine Kombos)
const CODE_PREFIX := "DINGER1:" # Deck-Code zum Teilen
const BOOSTER_SIZE := 5 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 RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
const START_COINS := 150 const START_COINS := 150
const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300} const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300}
const REWARD_LOSS := 50 const REWARD_LOSS := 50
const DECK_SLOTS := 5 const DECK_SLOTS := 5
# Schwierigkeit: Faktor auf die Zielstärke der Gegnerdecks, Belohnung und KI-Tiefe.
const DIFFICULTIES := {
"leicht": {"name": "Leicht", "factor": 0.9, "reward": 0.75, "smart": false, "tier": "leicht"},
"normal": {"name": "Normal", "factor": 1.0, "reward": 1.0, "smart": true, "tier": ""},
"schwer": {"name": "Schwer", "factor": 1.2, "reward": 1.5, "smart": true, "tier": "schwer"},
}
# Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub. # Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub.
const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600} const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600}
const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200} const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200}
@@ -21,13 +29,14 @@ const ACHIEVEMENTS := [
{"id": "duellant", "name": "Duellant", "text": "Gewinne ein Duell.", "reward": 50}, {"id": "duellant", "name": "Duellant", "text": "Gewinne ein Duell.", "reward": 50},
{"id": "tischherr", "name": "Herr des Tisches", "text": "Gewinne gegen 3 oder mehr Gegner.", "reward": 100}, {"id": "tischherr", "name": "Herr des Tisches", "text": "Gewinne gegen 3 oder mehr Gegner.", "reward": 100},
{"id": "chaosmeister", "name": "Chaosmeister", "text": "Gewinne gegen 5 Gegner.", "reward": 200}, {"id": "chaosmeister", "name": "Chaosmeister", "text": "Gewinne gegen 5 Gegner.", "reward": 200},
{"id": "sammler", "name": "Sammler", "text": "Besitze 40 verschiedene Karten.", "reward": 100}, {"id": "sammler", "name": "Sammler", "text": "Besitze 50 verschiedene Karten.", "reward": 100},
{"id": "vollstaendig", "name": "Vollständig", "text": "Besitze jede Karte.", "reward": 500}, {"id": "vollstaendig", "name": "Vollständig", "text": "Besitze jede Karte.", "reward": 500},
{"id": "boosterfreund", "name": "Boosterfreund", "text": "Öffne 10 Booster.", "reward": 100}, {"id": "boosterfreund", "name": "Boosterfreund", "text": "Öffne 10 Booster.", "reward": 100},
{"id": "elementarist", "name": "Elementarist", "text": "Gewinne mit einem Deck aus nur einem Element.", "reward": 100}, {"id": "elementarist", "name": "Elementarist", "text": "Gewinne mit einem Deck aus nur einem Element.", "reward": 100},
{"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100}, {"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100},
{"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300}, {"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300},
{"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500}, {"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500},
{"id": "archimagier", "name": "Archimagier", "text": "Schließe Kapitel 3 der Kampagne ab.", "reward": 800},
{"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150}, {"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150},
{"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100}, {"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100},
] ]
@@ -58,13 +67,14 @@ var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}}
var achievements: Array = [] var achievements: Array = []
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
var daily := {"date": "", "task": "", "progress": 0, "done": false} var daily := {"date": "", "task": "", "progress": 0, "done": false}
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de"} var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
var notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe) var notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe)
var card_stats := {} # id -> {"played", "games", "wins"} var card_stats := {} # id -> {"played", "games", "wins"}
var booster_since_mythic := 0 var booster_since_mythic := 0
var tutorial_done := false var tutorial_done := false
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1) var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0} var tutorial2_done := false
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0, "margins": []}
var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung
## Das aktive Deck (Kartenliste des gewählten Slots). ## Das aktive Deck (Kartenliste des gewählten Slots).
@@ -86,11 +96,16 @@ static func starter() -> Profile:
for c in Cards.of_rarity("gewöhnlich"): for c in Cards.of_rarity("gewöhnlich"):
if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"): if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"):
p.collection[c.id] = 2 p.collection[c.id] = 2
# Zwei seltene Karten zum Start, damit es etwas zum Aufbauen gibt
p.collection["f_drache"] = 1
p.collection["e_baum"] = 1
var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche", var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche",
"e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"] "e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"]
for cid in starter_ids: for cid in starter_ids:
p.deck.append(cid) p.deck.append(cid)
p.deck.append(cid) p.deck.append(cid)
p.deck[p.deck.find("f_asche")] = "f_drache"
p.deck[p.deck.find("e_troll")] = "e_baum"
return p return p
@@ -147,7 +162,10 @@ static func from_dict(d: Dictionary) -> Profile:
var se = d.get("settings", {}) var se = d.get("settings", {})
p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)), p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)),
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)), "ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
"language": str(se.get("language", "de"))} "language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
"big_font": bool(se.get("big_font", false))}
if not DIFFICULTIES.has(p.settings.difficulty):
p.settings.difficulty = "normal"
if version >= 4: if version >= 4:
for cid in d.get("card_stats", {}): for cid in d.get("card_stats", {}):
if Cards.has_id(cid): if Cards.has_id(cid):
@@ -161,8 +179,10 @@ static func from_dict(d: Dictionary) -> Profile:
p.perk = "" p.perk = ""
var rc = d.get("records", {}) var rc = d.get("records", {})
p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)), p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)),
"fastest_win": int(rc.get("fastest_win", 0))} "fastest_win": int(rc.get("fastest_win", 0)), "loss_streak": int(rc.get("loss_streak", 0)),
"margins": Array(rc.get("margins", [])).map(func(m): return int(m))}
p.challenge_week = int(d.get("challenge_week", -1)) p.challenge_week = int(d.get("challenge_week", -1))
p.tutorial2_done = bool(d.get("tutorial2_done", false))
return p return p
@@ -172,7 +192,7 @@ func to_dict() -> Dictionary:
"stats": stats, "achievements": achievements, "campaign_stage": campaign_stage, "stats": stats, "achievements": achievements, "campaign_stage": campaign_stage,
"daily": daily, "settings": settings, "card_stats": card_stats, "daily": daily, "settings": settings, "card_stats": card_stats,
"booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done, "booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done,
"perk": perk, "records": records, "challenge_week": challenge_week} "perk": perk, "records": records, "challenge_week": challenge_week, "tutorial2_done": tutorial2_done}
## Speichert und sichert den vorherigen Stand als .bak. ## Speichert und sichert den vorherigen Stand als .bak.
@@ -319,7 +339,15 @@ func dismantle_extras() -> int:
# --- Booster ---------------------------------------------------------------------- # --- Booster ----------------------------------------------------------------------
static func roll_booster(r: RandomNumberGenerator) -> Array: ## Kartenpool einer Seltenheit, bei Elementboostern nur Karten mit diesem Element.
static func booster_pool(rarity: String, element: String) -> Array:
var pool := Cards.of_rarity(rarity)
if element != "":
pool = pool.filter(func(c): return element in c.elements)
return pool
static func roll_booster(r: RandomNumberGenerator, element: String = "") -> Array:
var rarities := RARITY_WEIGHTS.keys() var rarities := RARITY_WEIGHTS.keys()
var weights := PackedFloat32Array(RARITY_WEIGHTS.values()) var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
var cards := [] var cards := []
@@ -328,23 +356,49 @@ static func roll_booster(r: RandomNumberGenerator) -> Array:
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \ if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
and cards.all(func(c): return c.rarity == "gewöhnlich"): and cards.all(func(c): return c.rarity == "gewöhnlich"):
rarity = "selten" # mindestens eine seltene Karte pro Booster rarity = "selten" # mindestens eine seltene Karte pro Booster
var pool := Cards.of_rarity(rarity) var pool := booster_pool(rarity, element)
cards.append(pool[r.randi_range(0, pool.size() - 1)]) cards.append(pool[r.randi_range(0, pool.size() - 1)])
return cards return cards
## Öffnet einen Booster. Gibt die Karten zurück, leer wenn die Münzen nicht reichen. ## Deck-Code zum Teilen: "DINGER1:id*anzahl,…" (sortiert, daher vergleichbar).
func open_booster(r: RandomNumberGenerator = null) -> Array: static func deck_code(card_ids: Array) -> String:
if coins < BOOSTER_COST: var counts := {}
for cid in card_ids:
counts[cid] = counts.get(cid, 0) + 1
var keys := counts.keys()
keys.sort()
return CODE_PREFIX + ",".join(keys.map(func(k): return "%s*%d" % [k, counts[k]]))
## Karten-IDs aus einem Deck-Code; leer bei ungültigem Code.
static func parse_deck_code(code: String) -> Array:
var s := code.strip_edges()
if not s.begins_with(CODE_PREFIX):
return []
var out := []
for part in s.substr(CODE_PREFIX.length()).split(",", false):
var kv: PackedStringArray = part.strip_edges().split("*")
if kv.size() != 2 or not Cards.has_id(kv[0]) or not kv[1].is_valid_int():
return []
for i in clampi(int(kv[1]), 0, Game.MAX_COPIES):
out.append(kv[0])
return out
## Öffnet einen Booster (element: Elementbooster). Gibt die Karten zurück, leer wenn die Münzen nicht reichen.
func open_booster(r: RandomNumberGenerator = null, element: String = "") -> Array:
var cost := ELEMENT_BOOSTER_COST if element != "" else BOOSTER_COST
if coins < cost:
return [] return []
if r == null: if r == null:
r = Game.make_rng() r = Game.make_rng()
coins -= BOOSTER_COST coins -= cost
var cards := roll_booster(r) var cards := roll_booster(r, element)
booster_since_mythic += 1 booster_since_mythic += 1
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch") var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch")
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic: if booster_since_mythic >= PITY_BOOSTERS and not has_mythic:
var pool := Cards.of_rarity("mythisch") var pool := booster_pool("mythisch", element)
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)] cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)]
has_mythic = true has_mythic = true
if has_mythic: if has_mythic:
@@ -363,15 +417,24 @@ func open_booster(r: RandomNumberGenerator = null) -> Array:
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei. ## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche. ## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche.
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [], func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
turns: int = 0, challenge: Dictionary = {}) -> int: turns: int = 0, challenge: Dictionary = {}, reward_factor: float = 1.0, remaining_life: int = -1) -> int:
stats.games += 1 stats.games += 1
records.margins.append(maxi(remaining_life, 0) if won else 0)
while records.margins.size() > 5:
records.margins.pop_front()
if won: if won:
records.streak += 1 records.streak += 1
records.loss_streak = 0
records.best_streak = maxi(records.best_streak, records.streak) records.best_streak = maxi(records.best_streak, records.streak)
if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win): if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win):
records.fastest_win = turns records.fastest_win = turns
else: else:
records.streak = 0 records.streak = 0
records.loss_streak += 1
var tip := suggest_difficulty()
if tip != "" and stage < 0 and challenge.is_empty():
notices.append("Tipp: %s. Schwierigkeit %s?" % [
"%d Siege in Folge" % records.streak if won else "%d Niederlagen in Folge" % records.loss_streak, DIFFICULTIES[tip].name])
for cid in played_ids: for cid in played_ids:
_stat(cid).played += 1 _stat(cid).played += 1
var seen := {} var seen := {}
@@ -386,7 +449,7 @@ func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, pla
stats.wins += 1 stats.wins += 1
var key := str(n_opp) var key := str(n_opp)
stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1 stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1
reward = REWARD_WIN[n_opp] reward = int(round(REWARD_WIN[n_opp] * reward_factor))
if stage >= 0 and stage == campaign_stage: if stage >= 0 and stage == campaign_stage:
campaign_stage += 1 campaign_stage += 1
reward = Campaign.stage(stage).reward reward = Campaign.stage(stage).reward
@@ -440,7 +503,7 @@ func _achieved(id: String, ctx: Dictionary) -> bool:
"chaosmeister": "chaosmeister":
return won and n == 5 return won and n == 5
"sammler": "sammler":
return distinct_owned() >= 40 return distinct_owned() >= 50
"vollstaendig": "vollstaendig":
return distinct_owned() >= Cards.all().size() return distinct_owned() >= Cards.all().size()
"boosterfreund": "boosterfreund":
@@ -452,6 +515,8 @@ func _achieved(id: String, ctx: Dictionary) -> bool:
"kampagnenheld": "kampagnenheld":
return campaign_stage >= Campaign.STAGES.size() return campaign_stage >= Campaign.STAGES.size()
"elementmeister": "elementmeister":
return campaign_stage >= 2 * Campaign.STAGES.size()
"archimagier":
return campaign_stage >= Campaign.total() return campaign_stage >= Campaign.total()
"serie": "serie":
return records.best_streak >= 5 return records.best_streak >= 5
@@ -460,6 +525,83 @@ func _achieved(id: String, ctx: Dictionary) -> bool:
return false return false
func difficulty() -> Dictionary:
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
## Vorschlag: rauf nach drei deutlichen Siegen in Folge (im Schnitt mindestens 12 Leben übrig) oder fünf
## Siegen egal wie knapp; runter nach drei Niederlagen in Folge. "" = kein Vorschlag.
func suggest_difficulty() -> String:
var order := ["leicht", "normal", "schwer"]
var i := order.find(str(settings.get("difficulty", "normal")))
if i < order.size() - 1 and records.streak >= 3:
var last: Array = records.margins.slice(maxi(0, records.margins.size() - 3))
var avg := 0.0
for m in last:
avg += float(m) / last.size()
if records.streak >= 5 or (records.margins.size() >= 3 and avg >= 12.0):
return order[i + 1]
if records.loss_streak >= 3 and i > 0:
return order[i - 1]
return ""
## Eigene Deckstärke, mit Kartenstatistik gewichtet.
func deck_rating(cards: Array = []) -> float:
var deck_used: Array = cards if not cards.is_empty() else deck_cards()
return Game.deck_rating(deck_used, card_stats)
## Zielstärke der Gegnerdecks: eigene Deckstärke mal Schwierigkeitsfaktor.
func target_rating(cards: Array = []) -> float:
return deck_rating(cards) * float(difficulty().factor)
## Tauscht die schwächste Karte des aktiven Decks gegen cid, wenn das Deck dadurch stärker und gültig bleibt.
## dry_run: nur prüfen. Gibt "" zurück oder den Grund, warum nicht.
func improve_deck_with(cid: String, dry_run: bool = false) -> String:
if collection.get(cid, 0) <= 0:
return "Karte nicht in der Sammlung"
var card := Cards.by_id(cid)
if deck.count(cid) >= mini(collection[cid], Game.MAX_COPIES):
return "Schon so oft im Deck wie möglich"
var elems := Game.deck_elements(deck_cards())
for e in card.elements:
if not (e in elems):
elems.append(e)
if elems.size() > Game.MAX_DECK_ELEMENTS:
return "Passt nicht zu den Elementen des Decks"
var weakest := ""
var weakest_value := Cards.card_value(card)
for other in deck:
if other == cid:
continue
var v := Cards.card_value(Cards.by_id(other))
if v < weakest_value:
weakest_value = v
weakest = other
if weakest == "":
return "Keine schwächere Karte im Deck"
if dry_run:
return ""
var trial: Array = deck.duplicate()
trial[trial.find(weakest)] = cid
var ok := Game.validate_deck(trial.map(func(id): return Cards.by_id(id)), collection)
if not ok[0]:
return ok[1]
deck = trial
return ""
## Karten aus new_ids, die das aktive Deck verbessern würden.
func deck_suggestions(new_ids: Array) -> Array:
var out := []
for cid in new_ids:
if not (cid in out) and improve_deck_with(cid, true) == "":
out.append(cid)
return out
func perk_unlocked() -> bool: func perk_unlocked() -> bool:
return campaign_stage >= Campaign.STAGES.size() return campaign_stage >= Campaign.STAGES.size()

View File

@@ -2,27 +2,30 @@ class_name AchievementsScreen
extends Control extends Control
var app var app
var export_label: Label
func _ready() -> void: func _ready() -> void:
var p: Profile = app.profile var p: Profile = app.profile
var col := Style.column(self, 900, 20, 5) var col := Style.column(self, 900, 12, 4)
Style.clabel(col, "Erfolge", 36, Style.GOLD, "center", true) Style.clabel(col, "Erfolge", 32, Style.GOLD, "center", true)
var st: Dictionary = p.stats var st: Dictionary = p.stats
Style.clabel(col, "Kämpfe %d · Siege %d · Booster %d · Kampagne %d/%d" % [ Style.clabel(col, "Kämpfe %d · Siege %d · Booster %d · Kampagne %d/%d" % [
st.games, st.wins, st.boosters, p.campaign_stage, Campaign.STAGES.size()], 16, Style.MUTED) st.games, st.wins, st.boosters, p.campaign_stage, Campaign.total()], 16, Style.MUTED)
var task := p.daily_task() var task := p.daily_task()
var daily_text: String = ("Tagesaufgabe erledigt: %s ✓" % task.text) if p.daily.done else \ var daily_text: String = ("Tagesaufgabe erledigt: %s ✓" % task.text) if p.daily.done else \
"Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward] "Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward]
Style.clabel(col, daily_text, 16, Style.BLUE) Style.clabel(col, daily_text, 16, Style.BLUE)
Style.spacer(col, 8) Style.clabel(col, "Serie: %d (beste %d) · Schnellster Sieg: %s" % [p.records.streak, p.records.best_streak,
("%d Züge" % p.records.fastest_win) if p.records.fastest_win > 0 else "–"], 15, Style.MUTED)
Style.spacer(col, 4)
for a in Profile.ACHIEVEMENTS: for a in Profile.ACHIEVEMENTS:
var done: bool = a.id in p.achievements var done: bool = a.id in p.achievements
var panel := PanelContainer.new() var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6)) panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6))
col.add_child(panel) col.add_child(panel)
var r := Style.row(panel, 12) var r := Style.row(panel, 12)
r.custom_minimum_size = Vector2(0, 42) r.custom_minimum_size = Vector2(0, 36)
Style.clabel(r, "✓" if done else "·", 22, Style.GREEN if done else Style.MUTED, "center", true, 40) Style.clabel(r, "✓" if done else "·", 22, Style.GREEN if done else Style.MUTED, "center", true, 40)
Style.clabel(r, a.name, 18, Style.TEXT if done else Style.MUTED, "left", true, 250) Style.clabel(r, a.name, 18, Style.TEXT if done else Style.MUTED, "left", true, 250)
Style.clabel(r, a.text, 15, Style.MUTED, "left", false, 380) Style.clabel(r, a.text, 15, Style.MUTED, "left", false, 380)
@@ -34,10 +37,18 @@ func _ready() -> void:
for row in top: for row in top:
parts.append("%s %d/%d" % [row.card.name, row.wins, row.games]) parts.append("%s %d/%d" % [row.card.name, row.wins, row.games])
Style.clabel(col, "Beste Karten (Siege/Partien): " + " · ".join(parts), 14, Style.MUTED) Style.clabel(col, "Beste Karten (Siege/Partien): " + " · ".join(parts), 14, Style.MUTED)
Style.spacer(col, 6) export_label = Style.clabel(col, "", 13, Style.GREEN)
var br := Style.row(col) Style.spacer(col, 2)
var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) Style.cbutton(br, "Statistik exportieren", Vector2(220, 40), export_stats)
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
func export_stats() -> void:
var path := "user://statistik.csv"
var err: String = app.profile.export_stats(path)
export_label.text = ("Gespeichert: " + ProjectSettings.globalize_path(path)) if err == "" else err
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:

View File

@@ -63,7 +63,7 @@ func play_events(evs: Array, centers: Dictionary) -> void:
screen.preview_timer = 1.8 screen.preview_timer = 1.8
"mulligan": "mulligan":
if ev.count > 0: if ev.count > 0:
popup("tauscht %d Karten" % ev.count, Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0) popup(("tauscht 1 Karte" if int(ev.count) == 1 else "tauscht %d Karten" % ev.count), Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2: func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:

View File

@@ -0,0 +1,139 @@
class_name BattleGamepad
extends RefCounted
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Hand, eigene Wesen und Ziele,
## A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, X macht rückgängig.
var screen # BattleScreen
var active := false
var zone := "hand"
var index := 0
var targets: Array = []
func _init(s) -> void:
screen = s
func button(button: int) -> void:
active = true
if screen.game == null:
return
match button:
JOY_BUTTON_START:
if screen.my_turn() and not screen.any_overlay():
screen.end_turn()
JOY_BUTTON_B:
if screen.confirm.visible:
screen.confirm.visible = false
screen.cancel_all()
zone = "hand"
JOY_BUTTON_Y:
if screen.b_skip.visible:
screen.skip_ai = true
JOY_BUTTON_X:
if screen.b_undo.visible:
screen.undo()
JOY_BUTTON_A:
confirm()
JOY_BUTTON_DPAD_LEFT:
move(-1)
JOY_BUTTON_DPAD_RIGHT:
move(1)
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
if zone != "target":
zone = "own" if zone == "hand" else "hand"
index = 0
screen.refresh()
func items() -> Array:
match zone:
"hand":
return screen.hand_views
"own":
return screen.own_views.filter(func(v): return v.creature.can_attack())
"target":
return targets
return []
func move(dir: int) -> void:
var n := items().size()
if n > 0:
index = posmod(index + dir, n)
func confirm() -> void:
if screen.handover.visible:
screen.take_over()
return
if screen.mulligan.visible:
screen.do_mulligan([])
return
if screen.overlay.visible:
screen.leave()
return
if screen.confirm.visible:
return
var items := items()
if items.is_empty():
return
index = clampi(index, 0, items.size() - 1)
var item = items[index]
match zone:
"hand":
screen.on_click("hand", item.card)
if screen.pending != null:
targets = []
var valid: Array = screen.game.valid_targets(screen.perspective, screen.pending.target_type())
for v in screen.enemy_views:
if v.creature in valid:
targets.append(v)
for v in screen.own_views:
if v.creature in valid:
targets.append(v)
for o in screen.others():
if o in valid:
targets.append(screen.hero_panels[o])
zone = "target"
index = 0
"own":
screen.on_click("own", item.creature)
if screen.selected != null:
targets = []
for v in screen.enemy_views:
if screen.game.can_attack(screen.selected, v.creature)[0]:
targets.append(v)
for o in screen.others():
if o.alive() and screen.game.can_attack(screen.selected, o)[0]:
targets.append(screen.hero_panels[o])
zone = "target"
index = 0
"target":
if item is CreatureView:
screen.on_tile_click(item.creature)
else:
for o in screen.hero_panels:
if screen.hero_panels[o] == item:
screen.on_click("hero", o)
zone = "hand"
index = 0
func draw_cursor() -> void:
Style.clear(screen.cursor_layer)
if not active or screen.game == null or not screen.my_turn():
return
var items := items()
if items.is_empty():
return
index = clampi(index, 0, items.size() - 1)
var item = items[index]
var r: Rect2 = Rect2(item.slot, item.size) if item is CreatureView else Rect2(item.position, item.size)
var hl := BattleScreen.Highlight.new()
hl.position = r.position - Vector2(6, 6)
hl.size = r.size + Vector2(12, 12)
hl.mouse_filter = Control.MOUSE_FILTER_IGNORE
screen.cursor_layer.add_child(hl)

View File

@@ -0,0 +1 @@
uid://8wth8k7f7uvk

View File

@@ -0,0 +1,63 @@
class_name BattleMulligan
extends Control
## Mulligan-Maske: Karten anklicken, behalten oder tauschen.
signal decided(indexes: Array)
var layer: Control
var b_keep: Button
var b_swap: Button
var selected: Array = []
func _init() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(dark)
Style.panel(self, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
Style.label(self, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
Style.label(self, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
layer = Control.new()
layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(layer)
b_keep = Style.button(self, "Behalten", Rect2(430, 515, 200, 44), func(): finish(false), Color("285a3c"))
b_swap = Style.button(self, "Tauschen", Rect2(650, 515, 200, 44), func(): finish(true))
func open(hand: Array) -> void:
selected = []
visible = true
Style.clear(layer)
for i in hand.size():
var r := BattleLayout.hand_rect(hand.size(), i)
var v := CardView.new(hand[i], r.size)
v.position = Vector2(r.position.x, 322)
var idx := i
v.clicked.connect(func(_view): toggle(idx))
layer.add_child(v)
_borders()
func toggle(idx: int) -> void:
if idx in selected:
selected.erase(idx)
else:
selected.append(idx)
_borders()
func _borders() -> void:
var views := layer.get_children()
for i in views.size():
views[i].border = Style.YELLOW if i in selected else null
views[i].queue_redraw()
b_swap.text = ("Tauschen (%d)" % selected.size()) if not selected.is_empty() else "Tauschen"
func finish(swap: bool) -> void:
visible = false
decided.emit(selected.duplicate() if swap else [])

View File

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

View File

@@ -9,7 +9,8 @@ var stage := -1 # Kampagnenstufe oder -1
var tutorial := false var tutorial := false
var hotseat_decks: Array = [] var hotseat_decks: Array = []
var hotseat_ai := 0 var hotseat_ai := 0
var online_seat := -1 # Netzwerk: eigener Platz, sonst -1 var net_play := false # Netzwerkspiel (auch als Zuschauer)
var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1
var net: Net var net: Net
var game: Game var game: Game
var perspective: Game.Player # der Mensch, dessen Hand unten liegt var perspective: Game.Player # der Mensch, dessen Hand unten liegt
@@ -19,13 +20,34 @@ var pending = null # Cards.Card, wartet auf Ziel
var msg_t := 0.0 var msg_t := 0.0
var ai_delay := 0.75 var ai_delay := 0.75
var ai_timer := 0.0 var ai_timer := 0.0
var ai_task := -1 # WorkerThreadPool-Aufgabe der laufenden KI-Berechnung
var ai_copy: Game # Spielkopie, auf der die KI rechnet
var ai_box := {} # Ergebnis der Berechnung (vom Thread befüllt)
var reward := -1 var reward := -1
var ended := false var ended := false
var zoom_player = null var zoom_player = null
var preview_timer := 0.0 var preview_timer := 0.0
var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik) var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik)
var mulligan_selected: Array = [] var guide: BattleTutorial
var tutorial_step := 0 var tutorial_part := 1
var tutorial_step: int:
get:
return guide.step if guide != null else 0
var pad: BattleGamepad
var gp_zone: String:
get:
return pad.zone
var gp_index: int:
get:
return pad.index
var challenge := {} # wöchentliche Herausforderung oder leer
var preset_opponents: Array = [] # aus der Gegnervorschau übernommene Gegner
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
# Ansichten (auch für Tests) # Ansichten (auch für Tests)
var tiles := {} var tiles := {}
@@ -60,27 +82,46 @@ var result_label: Label
var reward_label: Label var reward_label: Label
var notice_label: Label var notice_label: Label
var confirm: Control var confirm: Control
var b_save_leave: Button
var handover: Control var handover: Control
var handover_label: Label var handover_label: Label
var mulligan_box: Control var mulligan: BattleMulligan
var mulligan_layer: Control var mulligan_box: Control:
var b_keep: Button get:
var b_swap: Button return mulligan
var mulligan_layer: Control:
get:
return mulligan.layer
var b_keep: Button:
get:
return mulligan.b_keep
var b_swap: Button:
get:
return mulligan.b_swap
var mulligan_selected: Array:
get:
return mulligan.selected
var cursor_layer: Control
var tutorial_panel: Panel var tutorial_panel: Panel
var tutorial_text: RichTextLabel var tutorial_text: RichTextLabel
var b_tut_next: Button var b_tut_next: Button
var wait_label: Label var wait_label: Label
var b_skip: Button
var b_undo: Button
var highlight_layer: Control
const TUTORIAL_STEPS := [
["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.", ## Pulsierender Rahmen für die Anleitung.
"played_creature"], class Highlight extends Control:
["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"], var t := 0.0
["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"],
["Resonanz: Wesen mit gleichem Element geben sich gegenseitig +1 Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"], func _process(delta: float) -> void:
["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an alle gegnerischen Wesen.", "combo"], t += delta
["Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.", "spell"], queue_redraw()
["Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!", ""],
] func _draw() -> void:
var a := 0.55 + 0.45 * sin(t * 6.0)
Style.border(self, Rect2(Vector2.ZERO, size), Color(Style.GOLD, a), 4, 8)
func _init(n: int = 2, campaign_stage: int = -1) -> void: func _init(n: int = 2, campaign_stage: int = -1) -> void:
@@ -97,22 +138,50 @@ static func hotseat(decks: Array, n_ai: int) -> BattleScreen:
static func online(seat: int) -> BattleScreen: static func online(seat: int) -> BattleScreen:
var b := BattleScreen.new(0, -1) var b := BattleScreen.new(0, -1)
b.net_play = true
b.online_seat = seat b.online_seat = seat
return b return b
static func guided() -> BattleScreen: static func guided(part: int = 1) -> BattleScreen:
var b := BattleScreen.new(1, -1) var b := BattleScreen.new(1, -1)
b.tutorial = true b.tutorial = true
b.tutorial_part = part
return b
static func against(opps: Array) -> BattleScreen:
var b := BattleScreen.new(opps.size(), -1)
b.preset_opponents = opps
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.challenge = data.get("challenge", {})
s.played_ids = Array(data.get("played_ids", []))
s.saved_game = Game.from_snapshot(data.game)
return s
static func weekly(ch: Dictionary) -> BattleScreen:
var b := BattleScreen.new(int(ch.n_opp), -1)
b.challenge = ch
return b return b
func is_hotseat() -> bool: func is_hotseat() -> bool:
return game != null and game.humans.size() > 1 and online_seat < 0 return game != null and game.humans.size() > 1 and not net_play
func is_online() -> bool: func is_online() -> bool:
return online_seat >= 0 return net_play
## Zuschauer im Netzwerk: kein eigener Platz, keine Hand.
func spectating() -> bool:
return is_online() and online_seat < 0
func is_client() -> bool: func is_client() -> bool:
@@ -127,11 +196,14 @@ func _ready() -> void:
var s: Dictionary = app.profile.settings var s: Dictionary = app.profile.settings
fx = BattleFx.new(self) fx = BattleFx.new(self)
fx.enabled = s.animations fx.enabled = s.animations
pad = BattleGamepad.new(self)
ai_delay = 0.75 / float(s.ai_speed) ai_delay = 0.75 / float(s.ai_speed)
_build_static() _build_static()
if is_online(): if is_online():
net = app.net net = app.net
net.ended.connect(_on_net_ended) net.ended.connect(_on_net_ended)
net.migrated.connect(_on_migrated)
net.status_changed.connect(_on_net_status)
if net.is_host: if net.is_host:
game = net.game game = net.game
net.action_applied.connect(after_action) net.action_applied.connect(after_action)
@@ -139,25 +211,45 @@ func _ready() -> void:
net.snapshot_received.connect(_on_snapshot) net.snapshot_received.connect(_on_snapshot)
wait_label.visible = true wait_label.visible = true
return # der erste Zustand kommt vom Host return # der erste Zustand kommt vom Host
elif saved_game != null:
game = saved_game
elif not hotseat_decks.is_empty(): elif not hotseat_decks.is_empty():
game = Game.new_hotseat(hotseat_decks, hotseat_ai) game = Game.new_hotseat(hotseat_decks, hotseat_ai)
elif tutorial: elif tutorial:
guide = BattleTutorial.new(self, tutorial_part)
var r := Game.make_rng() var r := Game.make_rng()
var opps := Campaign.opponents(0, r) var opps := Campaign.opponents(guide.stage_index(), r, Game.deck_rating(app.profile.deck_cards()))
opps[0].perks = [] opps[0].perks = []
game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true) game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true)
guide.inject_cards(game)
elif not challenge.is_empty():
var rules: Dictionary = challenge.rules.duplicate()
rules["target_rating"] = Game.deck_rating(app.profile.deck_cards())
game = Game.new_battle(app.profile.deck_cards(), n_opp, null, rules)
elif stage >= 0: elif stage >= 0:
var r := Game.make_rng() var r := Game.make_rng()
game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r), r) game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r, app.profile.target_rating()), r, false, own_rules())
elif not preset_opponents.is_empty():
game = Game.new_battle_with(app.profile.deck_cards(), preset_opponents, null, false, own_rules())
else: else:
game = Game.new_battle(app.profile.deck_cards(), n_opp) game = Game.new_battle(app.profile.deck_cards(), n_opp, null, own_rules())
smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile()
_begin() _begin()
## Eigenes Merkmal des Spielers (nach Kapitel 1 wählbar) und Zielstärke der Gegner.
func own_rules() -> Dictionary:
var p: Profile = app.profile
var rules := {"target_rating": p.target_rating(), "tier": str(p.difficulty().tier)}
if p.perk != "" and p.perk_unlocked():
rules["human_perks"] = [p.perk]
return rules
func _begin() -> void: func _begin() -> void:
n_opp = game.players.size() - 1 n_opp = game.players.size() - 1
if is_online(): if is_online():
perspective = game.players[online_seat] perspective = game.players[maxi(online_seat, 0)]
else: else:
game.events.clear() game.events.clear()
perspective = game.current() if game.current().is_human else game.humans[0] perspective = game.current() if game.current().is_human else game.humans[0]
@@ -165,6 +257,8 @@ func _begin() -> void:
if is_online() and net.is_host: if is_online() and net.is_host:
net.broadcast() net.broadcast()
game.events.clear() game.events.clear()
b_save_leave.visible = resumable()
save_battle()
refresh() refresh()
@@ -207,6 +301,11 @@ func _build_static() -> void:
status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24)) 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) 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_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)
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
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center") wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false wait_label.visible = false
hero_layer = _layer() hero_layer = _layer()
@@ -214,18 +313,14 @@ func _build_static() -> void:
trap_layer = _layer() trap_layer = _layer()
hand_layer = _layer() hand_layer = _layer()
preview_holder = _layer() preview_holder = _layer()
highlight_layer = _layer()
zoom_layer = _layer() zoom_layer = _layer()
fx_layer = _layer() fx_layer = _layer()
cursor_layer = _layer()
# Mulligan # Mulligan
mulligan_box = _overlay() mulligan = BattleMulligan.new()
Style.panel(mulligan_box, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2) mulligan.decided.connect(do_mulligan)
Style.label(mulligan_box, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true) add_child(mulligan)
Style.label(mulligan_box, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
mulligan_layer = Control.new()
mulligan_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
mulligan_box.add_child(mulligan_layer)
b_keep = Style.button(mulligan_box, "Behalten", Rect2(430, 515, 200, 44), func(): do_mulligan(false), Color("285a3c"))
b_swap = Style.button(mulligan_box, "Tauschen", Rect2(650, 515, 200, 44), func(): do_mulligan(true))
# Spielende # Spielende
overlay = _overlay() overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true) result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
@@ -233,12 +328,14 @@ func _build_static() -> void:
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "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")) Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
# Aufgeben bestätigen # Aufgeben bestätigen
confirm = _overlay() confirm = Style.overlay(self)
Style.panel(confirm, Rect2(390, 300, 500, 180), Style.PANEL, 10, Style.MUTED, 2) Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.MUTED, 2)
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(390, 320, 500, 40), "center", true) Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
Style.label(confirm, "Der Kampf endet ohne Münzen.", 16, Style.MUTED, Rect2(390, 365, 500, 24), "center") Style.label(confirm, tr("Aufgeben beendet den Kampf ohne Münzen.") + "\n" + tr("Speichern hebt ihn für später auf."), 15, Style.MUTED, Rect2(340, 356, 600, 48), "center")
Style.button(confirm, "Ja, aufgeben", Rect2(420, 410, 200, 46), leave, Color("5a2828")) Style.button(confirm, "Ja, aufgeben", Rect2(355, 420, 180, 46), leave, Color("5a2828"))
Style.button(confirm, "Weiterspielen", Rect2(660, 410, 200, 46), func(): confirm.visible = false) 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)
# Übergabe zwischen Menschen (Hotseat) # Übergabe zwischen Menschen (Hotseat)
handover = _overlay() handover = _overlay()
Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2) Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2)
@@ -255,23 +352,39 @@ func _layer() -> Control:
func _overlay() -> Control: func _overlay() -> Control:
var o := Control.new() return Style.overlay(self)
o.set_anchors_preset(Control.PRESET_FULL_RECT)
o.visible = false
var dark := ColorRect.new() ## Einzelspieler-Kämpfe (auch Kampagne und Herausforderung) lassen sich unterbrechen und fortsetzen.
dark.color = Color(0, 0, 0, 0.67) func resumable() -> bool:
dark.set_anchors_preset(Control.PRESET_FULL_RECT) return counts_for_profile() and not tutorial
dark.mouse_filter = Control.MOUSE_FILTER_STOP
o.add_child(dark)
add_child(o) func save_battle() -> void:
return o if not resumable() or game == null or game.winner != "":
return
var d := {"version": 1, "n_opp": n_opp, "stage": stage, "challenge": challenge, "played_ids": played_ids, "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: func leave() -> void:
if is_online(): if is_online():
net.leave() net.leave()
if tutorial and not app.profile.tutorial_done: if resumable() and game != null and game.winner == "":
app.clear_battle_save() # aufgegeben
if tutorial:
if tutorial_part == 1:
app.profile.tutorial_done = true app.profile.tutorial_done = true
else:
app.profile.tutorial2_done = true
app.save() app.save()
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())
@@ -338,7 +451,9 @@ func build_hero_boxes() -> void:
if not o.perks.is_empty(): if not o.perks.is_empty():
title += " ★" title += " ★"
labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT, 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 - (44 if c else 20), 26), "left", true) 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, 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) 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, labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED,
@@ -354,11 +469,13 @@ func build_hero_boxes() -> void:
# --- Ablauf und Eingabe ------------------------------------------------------------------ # --- Ablauf und Eingabe ------------------------------------------------------------------
func my_turn() -> bool: func my_turn() -> bool:
if spectating():
return false
return game != null and game.winner == "" and game.current() == perspective return game != null and game.winner == "" and game.current() == perspective
func any_overlay() -> bool: func any_overlay() -> bool:
return confirm.visible or handover.visible or mulligan_box.visible or overlay.visible return confirm.visible or handover.visible or mulligan.visible or overlay.visible
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
@@ -367,6 +484,9 @@ func _input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
cancel_all() cancel_all()
return return
if event is InputEventJoypadButton and event.pressed:
pad.button(event.button_index)
return
if not (event is InputEventKey and event.pressed and not event.echo): if not (event is InputEventKey and event.pressed and not event.echo):
return return
match event.keycode: match event.keycode:
@@ -380,11 +500,16 @@ func _input(event: InputEvent) -> void:
KEY_SPACE: KEY_SPACE:
if my_turn() and not any_overlay(): if my_turn() and not any_overlay():
end_turn() end_turn()
elif b_skip.visible:
skip_ai = true
KEY_U:
if b_undo.visible:
undo()
KEY_ENTER, KEY_KP_ENTER: KEY_ENTER, KEY_KP_ENTER:
if handover.visible: if handover.visible:
take_over() take_over()
elif mulligan_box.visible: elif mulligan.visible:
do_mulligan(false) do_mulligan([])
elif overlay.visible: elif overlay.visible:
leave() leave()
elif tutorial and b_tut_next.visible: elif tutorial and b_tut_next.visible:
@@ -414,6 +539,12 @@ func _process(delta: float) -> void:
preview_timer -= delta preview_timer -= delta
if preview_timer <= 0.0: if preview_timer <= 0.0:
show_preview(null, false) show_preview(null, false)
if undo_t > 0.0:
undo_t -= delta
if undo_t <= 0.0:
clear_undo()
if is_online() and net.is_host:
net.tick()
if game.winner != "": if game.winner != "":
if not ended: if not ended:
ended = true ended = true
@@ -426,28 +557,65 @@ func _process(delta: float) -> void:
handover.visible = true handover.visible = true
hand_layer.visible = false hand_layer.visible = false
trap_layer.visible = false trap_layer.visible = false
elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan_box.visible and not handover.visible: elif cur == perspective and not spectating() and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible and not handover.visible:
show_mulligan() show_mulligan()
return return
if is_client(): if is_client():
return return
if skip_ai:
ai_timer = 0.0
else:
ai_timer -= delta ai_timer -= delta
var action: Dictionary
if smart_ai:
# Die Vorausschau rechnet im Hintergrund auf einer Kopie, schon während der Wartezeit,
# damit Animationen nicht stocken.
if ai_task < 0:
_start_ai(cur)
if ai_timer > 0 or not WorkerThreadPool.is_task_completed(ai_task):
return
WorkerThreadPool.wait_for_task_completion(ai_task)
ai_task = -1
action = AI.map_action(game, ai_copy, ai_box.action)
ai_copy = null
else:
if ai_timer > 0: if ai_timer > 0:
return return
var action := AI.choose_action(game, cur) action = AI.choose_action_greedy(game, cur)
AI.apply_action(game, cur, action) AI.apply_action(game, cur, action)
ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0) ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0)
if game.current().is_human:
skip_ai = false
after_action() after_action()
func _start_ai(cur: Game.Player) -> void:
ai_copy = game.clone_for(cur)
var copy := ai_copy
var idx := game.current_idx
var box := ai_box
ai_task = WorkerThreadPool.add_task(func(): box["action"] = AI.choose_action(copy, copy.players[idx]))
func _exit_tree() -> void:
if ai_task >= 0:
WorkerThreadPool.wait_for_task_completion(ai_task)
ai_task = -1
func _finish() -> void: func _finish() -> void:
var won := game.winner == "human" var won := game.winner == "human"
if counts_for_profile(): if counts_for_profile():
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids) var factor: float = float(app.profile.difficulty().reward) if stage < 0 and challenge.is_empty() and not tutorial else 1.0
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, challenge, factor, game.human.life)
if tutorial: if tutorial:
if tutorial_part == 1:
app.profile.tutorial_done = true app.profile.tutorial_done = true
else:
app.profile.tutorial2_done = true
app.save() app.save()
app.sfx.play("win" if won else "lose") app.sfx.play("win" if won else "lose")
app.clear_battle_save()
else: else:
var wp := game.winner_player var wp := game.winner_player
var mine: bool = wp != null and wp == perspective var mine: bool = wp != null and wp == perspective
@@ -480,15 +648,52 @@ func play(card: Cards.Card, target) -> void:
if is_client(): if is_client():
net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null}) net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null})
return return
var before: Game = game.clone() if card.is_creature() and not is_online() else null
var log_len := game.log.size()
var err := game.play_card(perspective, card, target) var err := game.play_card(perspective, card, target)
if err != "": if err != "":
say(err) say(tr(err))
return
# Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte, Kombos, Fallen)
if before != null and game.events.size() == 1 and game.events[0].type == "play":
undo_state = before
undo_log_len = log_len
undo_t = 6.0
else:
clear_undo()
func clear_undo() -> void:
undo_state = null
undo_t = 0.0
b_undo.visible = false
## Nimmt das letzte Ausspielen eines Wesens zurück (nur solange nichts Verdecktes offenbart wurde).
func undo() -> void:
if undo_state == null:
return
var idx := game.players.find(perspective)
var restored := undo_state
restored.quiet = false
restored.rng = game.rng
restored.log = game.log.slice(0, undo_log_len)
restored.say("Rückgängig.")
game = restored
perspective = game.players[idx]
if is_online() and net.is_host:
net.game = game
clear_undo()
cancel()
build_hero_boxes()
after_action()
func end_turn() -> void: func end_turn() -> void:
if not my_turn(): if not my_turn():
return return
cancel() cancel()
clear_undo()
zoom_player = null zoom_player = null
if is_client(): if is_client():
net.send_action({"type": "end"}) net.send_action({"type": "end"})
@@ -499,39 +704,11 @@ func end_turn() -> void:
func show_mulligan() -> void: func show_mulligan() -> void:
mulligan_selected = [] mulligan.open(perspective.hand)
mulligan_box.visible = true
Style.clear(mulligan_layer)
var hand: Array = perspective.hand
for i in hand.size():
var r := BattleLayout.hand_rect(hand.size(), i)
var v := CardView.new(hand[i], r.size)
v.position = Vector2(r.position.x, 322)
var idx := i
v.clicked.connect(func(_view): toggle_mulligan(idx))
mulligan_layer.add_child(v)
_mulligan_borders()
func toggle_mulligan(idx: int) -> void: func do_mulligan(idxs: Array) -> void:
if idx in mulligan_selected: mulligan.visible = false
mulligan_selected.erase(idx)
else:
mulligan_selected.append(idx)
_mulligan_borders()
func _mulligan_borders() -> void:
var views := mulligan_layer.get_children()
for i in views.size():
views[i].border = Style.YELLOW if i in mulligan_selected else null
views[i].queue_redraw()
b_swap.text = "Tauschen (%d)" % mulligan_selected.size() if not mulligan_selected.is_empty() else "Tauschen"
func do_mulligan(swap: bool) -> void:
mulligan_box.visible = false
var idxs: Array = mulligan_selected.duplicate() if swap else []
if is_client(): if is_client():
net.send_action({"type": "mulligan", "hand": idxs}) net.send_action({"type": "mulligan", "hand": idxs})
return return
@@ -552,7 +729,7 @@ func on_click(kind: String, obj) -> void:
if obj in g.valid_targets(perspective, card.target_type()): if obj in g.valid_targets(perspective, card.target_type()):
play(card, obj) play(card, obj)
else: else:
say("Abgebrochen") say(tr("Abgebrochen"))
zoom_player = null zoom_player = null
after_action() after_action()
return return
@@ -560,12 +737,13 @@ func on_click(kind: String, obj) -> void:
var cr = selected var cr = selected
selected = null selected = null
if kind == "enemy" or kind == "hero": if kind == "enemy" or kind == "hero":
clear_undo()
if is_client(): if is_client():
net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)}) net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)})
else: else:
var err := g.attack(cr, obj) var err := g.attack(cr, obj)
if err != "": if err != "":
say(err) say(tr(err))
zoom_player = null zoom_player = null
after_action() after_action()
return return
@@ -573,18 +751,18 @@ func on_click(kind: String, obj) -> void:
"hand": "hand":
var ok := g.can_play(perspective, obj) var ok := g.can_play(perspective, obj)
if not ok[0]: if not ok[0]:
say(ok[1]) say(tr(ok[1]))
elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty(): elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty():
pending = obj pending = obj
say("Wähle ein Ziel") say(tr("Wähle ein Ziel"))
else: else:
play(obj, null) play(obj, null)
"own": "own":
if obj.can_attack(): if obj.can_attack():
selected = obj selected = obj
say("Wähle ein Angriffsziel") say(tr("Wähle ein Angriffsziel"))
else: else:
say("%s kann nicht angreifen" % obj.name) say(tr("%s kann nicht angreifen") % obj.card.title())
after_action() after_action()
@@ -641,13 +819,13 @@ func combo_hints(card: Cards.Card) -> Array[String]:
var combos := {} var combos := {}
for other in perspective.board: for other in perspective.board:
if other.card.elements.any(func(e): return e in card.elements): if other.card.elements.any(func(e): return e in card.elements):
partners.append(other.name) partners.append(other.card.title())
for e1 in card.elements: for e1 in card.elements:
for e2 in other.card.elements: for e2 in other.card.elements:
if e1 != e2: if e1 != e2:
var name := Cards.combo_name([e1, e2]) var name := Cards.combo_name([e1, e2])
if not combos.has(name): if not combos.has(name):
combos[name] = other.name combos[name] = other.card.title()
if not partners.is_empty(): if not partners.is_empty():
out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)]) out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)])
for name in combos: for name in combos:
@@ -664,11 +842,28 @@ func _on_snapshot(snap: Dictionary) -> void:
if first: if first:
_begin() _begin()
return return
perspective = game.players[online_seat] perspective = game.players[maxi(online_seat, 0)]
build_hero_boxes() build_hero_boxes()
after_action() after_action()
## Host-Wechsel: das Spiel läuft jetzt auf diesem Rechner weiter.
func _on_migrated() -> void:
game = net.game
if not net.action_applied.is_connected(after_action):
net.action_applied.connect(after_action)
wait_label.visible = false
perspective = game.players[online_seat]
build_hero_boxes()
say("Du bist jetzt Host.")
after_action()
func _on_net_status() -> void:
wait_label.text = net.status
wait_label.visible = true
func _on_net_ended(reason: String) -> void: func _on_net_ended(reason: String) -> void:
if game == null or game.winner == "": if game == null or game.winner == "":
say(reason) say(reason)
@@ -694,6 +889,7 @@ func after_action() -> void:
centers[ref_key(ev[key])] = target_center(ev[key]) centers[ref_key(ev[key])] = target_center(ev[key])
refresh() refresh()
fx.play_events(evs, centers) fx.play_events(evs, centers)
save_battle()
func _note_events(evs: Array) -> void: func _note_events(evs: Array) -> void:
@@ -714,36 +910,26 @@ func _note_events(evs: Array) -> void:
# --- Anleitung ------------------------------------------------------------------------------ # --- Anleitung ------------------------------------------------------------------------------
func tutorial_condition(kind: String) -> bool:
var h := game.human
match kind:
"played_creature":
return game.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0
"turn_passed":
return h.turns >= 2
"attacked":
return game.log.any(func(l): return l.ends_with("greift Fenn an.") or (" greift " in l and l.begins_with(h.board[0].name) if not h.board.is_empty() else false))
"resonance":
return h.board.any(func(c): return c.resonance > 0)
"combo":
return game.log.any(func(l): return l.begins_with("Feldkombo"))
"spell":
return game.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.")
return false
func tutorial_next() -> void: func tutorial_next() -> void:
if tutorial_step < TUTORIAL_STEPS.size() - 1: if guide != null and guide.next():
tutorial_step += 1
app.sfx.play("click") app.sfx.play("click")
refresh() refresh()
func _tutorial_refresh() -> void: func _tutorial_refresh() -> void:
while tutorial_step < TUTORIAL_STEPS.size() - 1 and tutorial_condition(TUTORIAL_STEPS[tutorial_step][1]): guide.advance()
tutorial_step += 1 tutorial_text.text = guide.text()
tutorial_text.text = "%d/%d %s" % [tutorial_step + 1, TUTORIAL_STEPS.size(), TUTORIAL_STEPS[tutorial_step][0]] b_tut_next.visible = not guide.finished()
b_tut_next.visible = tutorial_step < TUTORIAL_STEPS.size() - 1
func _tutorial_highlights(mine: bool) -> void:
Style.clear(highlight_layer)
for r in guide.highlight_rects(mine):
var hl := Highlight.new()
hl.position = r.position - Vector2(4, 4)
hl.size = r.size + Vector2(8, 8)
hl.mouse_filter = Control.MOUSE_FILTER_IGNORE
highlight_layer.add_child(hl)
# --- Ansicht angleichen -------------------------------------------------------------------- # --- Ansicht angleichen --------------------------------------------------------------------
@@ -852,6 +1038,9 @@ func refresh() -> void:
trap_views.append(tv) trap_views.append(tv)
Style.clear(hand_layer) Style.clear(hand_layer)
if spectating():
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(0, 690, 1280, 30), "center")
else:
for i in h.hand.size(): for i in h.hand.size():
var card: Cards.Card = h.hand[i] var card: Cards.Card = h.hand[i]
var playable: bool = mine and g.can_play(h, card)[0] var playable: bool = mine and g.can_play(h, card)[0]
@@ -876,8 +1065,14 @@ func refresh() -> void:
life_label.text = "♥ %d" % maxi(h.life, 0) life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy] energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy]
deck_label.text = "Deck %d" % h.deck.size() deck_label.text = "Deck %d" % h.deck.size()
status_label.text = "Zug %d · %s" % [g.turn, "Du bist dran" if mine else g.current().name + " ist dran …"] status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name])
if spectating():
status_label.text = tr("Zuschauer") + " · " + status_label.text
b_end.disabled = not mine b_end.disabled = not mine
var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client()
b_skip.visible = ai_running
b_end.visible = not ai_running and not spectating()
b_undo.visible = undo_state != null and mine
var lines: Array[String] = [] var lines: Array[String] = []
var start := maxi(0, g.log.size() - 60) var start := maxi(0, g.log.size() - 60)
for i in range(start, g.log.size()): for i in range(start, g.log.size()):
@@ -891,6 +1086,8 @@ func refresh() -> void:
log_box.text = "\n".join(lines) log_box.text = "\n".join(lines)
if tutorial: if tutorial:
_tutorial_refresh() _tutorial_refresh()
_tutorial_highlights(mine)
pad.draw_cursor()
Style.clear(zoom_layer) Style.clear(zoom_layer)
if zoom_player != null and zoom_player.alive(): if zoom_player != null and zoom_player.alive():
@@ -914,7 +1111,7 @@ func refresh() -> void:
overlay.visible = true overlay.visible = true
confirm.visible = false confirm.visible = false
handover.visible = false handover.visible = false
mulligan_box.visible = false mulligan.visible = false
func build_zoom(valid: Array) -> void: func build_zoom(valid: Array) -> void:

View File

@@ -0,0 +1,149 @@
class_name BattleTutorial
extends RefCounted
## Geführte Partien: Schritte, Bedingungen und Hervorhebungen (ausgelagert aus dem Kampfbildschirm).
const STEPS1 := [
["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",
"played_creature"],
["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"],
["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"],
["Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"],
["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an das stärkste gegnerische Wesen.", "combo"],
["Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.", "spell"],
["Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!", ""],
]
const STEPS2 := [
["Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst.", "mulligan"],
["Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast.", "trap_set"],
["Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein.", "trap_fired"],
["Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander.", "combo_dampf"],
["Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara!", ""],
]
var screen # BattleScreen
var part := 1
var step := 0
func _init(s, p: int = 1) -> void:
screen = s
part = p
func steps() -> Array:
return STEPS1 if part == 1 else STEPS2
func stage_index() -> int:
return 0 if part == 1 else 1
## Teil 1: ein Wesen für 1 Energie auf der Hand, damit der erste Schritt immer möglich ist.
## Teil 2: Lehrkarten auf die eigene Hand; Mara bekommt Wesen, damit sie sicher in die Falle läuft.
func inject_cards(g: Game) -> void:
if part == 1 and not g.human.hand.any(func(c): return c.is_creature() and c.cost == 1):
g.human.hand.append(Cards.by_id("f_kobold"))
if part == 2:
g.human.hand.append(Cards.by_id("w_falle"))
g.human.hand.append(Cards.by_id("w_reiter"))
for i in 2:
g.opponents[0].hand.append(Cards.by_id("w_reiter"))
func condition(kind: String) -> bool:
var g: Game = screen.game
var h: Game.Player = g.human
match kind:
"played_creature":
return g.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0
"turn_passed":
return h.turns >= 2
"attacked":
return g.log.any(func(l): return l.begins_with(h.board[0].name + " greift") if not h.board.is_empty() else false) \
or g.log.any(func(l): return l.ends_with("greift Fenn an."))
"resonance":
return h.board.any(func(c): return c.resonance > 0)
"combo":
return g.log.any(func(l): return l.begins_with("Feldkombo"))
"spell":
return g.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.")
"mulligan":
return h.mulligan_done
"trap_set":
return g.log.any(func(l): return l == "Du legt eine Falle.")
"trap_fired":
return g.log.any(func(l): return l.begins_with("Falle von Du"))
"combo_dampf":
return g.log.any(func(l): return l.begins_with("Feldkombo Dampf"))
return false
func advance() -> void:
while step < steps().size() - 1 and condition(steps()[step][1]):
step += 1
func next() -> bool:
if step < steps().size() - 1:
step += 1
return true
return false
func text() -> String:
return "%d/%d %s" % [step + 1, steps().size(), screen.tr(steps()[step][0])]
func finished() -> bool:
return step >= steps().size() - 1
## Rechtecke, die der aktuelle Schritt hervorhebt.
func highlight_rects(mine: bool) -> Array[Rect2]:
var rects: Array[Rect2] = []
if not mine or screen.any_overlay():
return rects
var g: Game = screen.game
var h: Game.Player = screen.perspective
match steps()[step][1]:
"played_creature":
for v in screen.hand_views:
if v.card.is_creature() and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"turn_passed", "trap_fired":
rects.append(screen.b_end.get_rect())
"attacked":
for v in screen.own_views:
if v.creature.can_attack():
rects.append(Rect2(v.slot, v.size))
if screen.own_views.any(func(v): return v.creature.can_attack()):
for o in screen.others():
if o.alive() and screen.hero_panels.has(o):
rects.append(screen.hero_panels[o].get_rect())
"resonance":
for v in screen.hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return e in c.elements)):
rects.append(Rect2(v.position, v.size))
"combo":
for v in screen.hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return not (e in c.elements))):
rects.append(Rect2(v.position, v.size))
"spell":
for v in screen.hand_views:
if v.card.kind == "Zauber" and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"trap_set":
for v in screen.hand_views:
if v.card.is_trap() and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"combo_dampf":
var has_fire: bool = h.board.any(func(b): return "Feuer" in b.card.elements)
for v in screen.hand_views:
var c: Cards.Card = v.card
if not c.is_creature() or not g.can_play(h, c)[0]:
continue
if (has_fire and "Wasser" in c.elements) or (not has_fire and "Feuer" in c.elements):
rects.append(Rect2(v.position, v.size))
return rects

View File

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

View File

@@ -12,7 +12,10 @@ var coins_label: Label
var msg_label: Label var msg_label: Label
var b_buy: Button var b_buy: Button
var b_all: Button var b_all: Button
var b_improve: Button
var suggestion := ""
var slots: Control var slots: Control
var element_buttons: Array = []
class CardBack extends Control: class CardBack extends Control:
@@ -22,15 +25,21 @@ class CardBack extends Control:
size = sz size = sz
custom_minimum_size = sz custom_minimum_size = sz
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
focus_entered.connect(queue_redraw)
focus_exited.connect(queue_redraw)
func _gui_input(event: InputEvent) -> void: func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event() accept_event()
clicked.emit() clicked.emit()
elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event()
clicked.emit()
func _draw() -> void: func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size) var rect := Rect2(Vector2.ZERO, size)
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 3), rect) draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect)
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6) Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true) Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
@@ -46,13 +55,20 @@ func _ready() -> void:
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center") coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.", Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.",
16, Style.MUTED, Rect2(0, 115, 1280, 24), "center") 16, Style.MUTED, Rect2(0, 115, 1280, 24), "center")
Style.label(self, "Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos" % Profile.ELEMENT_BOOSTER_COST,
14, Style.MUTED, Rect2(0, 586, 1280, 22), "center")
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
element_buttons.append(Style.button(self, e, Rect2(322 + i * 162, 612, 150, 38), buy.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)))
slots = Control.new() slots = Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(slots) add_child(slots)
msg_label = Style.label(self, "", 18, Style.RED, Rect2(0, 610, 1280, 30), "center") 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(360, 720, 260, 48), buy, Color("5a4628")) 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, 140, 48), reveal_all) b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all)
Style.button(self, "Zurück", Rect2(800, 720, 120, 48), func(): app.show_screen(MenuScreen.new())) b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c"))
b_improve.visible = false
Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new()))
refresh() refresh()
@@ -61,10 +77,10 @@ func _input(event: InputEvent) -> void:
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())
func buy() -> void: func buy(element: String = "") -> void:
var p: Profile = app.profile var p: Profile = app.profile
var before: Dictionary = p.collection.duplicate() var before: Dictionary = p.collection.duplicate()
var got: Array = p.open_booster() var got: Array = p.open_booster(null, element)
if got.is_empty(): if got.is_empty():
msg_label.text = "Nicht genug Münzen" msg_label.text = "Nicht genug Münzen"
msg_label.add_theme_color_override("font_color", Style.RED) msg_label.add_theme_color_override("font_color", Style.RED)
@@ -95,11 +111,39 @@ func reveal_all() -> void:
refresh() refresh()
## Nimmt die vorgeschlagene Karte ins aktive Deck (ersetzt die schwächste).
func improve() -> void:
if suggestion == "":
return
var err: String = app.profile.improve_deck_with(suggestion)
if err == "":
app.save()
msg_label.text = tr("%s ist jetzt im Deck %s") % [Cards.by_id(suggestion).title(), app.profile.deck_name()]
msg_label.add_theme_color_override("font_color", Style.GREEN)
suggestion = ""
else:
msg_label.text = err
msg_label.add_theme_color_override("font_color", Style.RED)
refresh()
func refresh() -> void: func refresh() -> void:
var p: Profile = app.profile var p: Profile = app.profile
coins_label.text = "Münzen: %d" % p.coins coins_label.text = "Münzen: %d" % p.coins
b_buy.disabled = p.coins < Profile.BOOSTER_COST b_buy.disabled = p.coins < Profile.BOOSTER_COST
for b in element_buttons:
b.disabled = p.coins < Profile.ELEMENT_BOOSTER_COST
b_all.disabled = cards.is_empty() or revealed.size() >= 5 b_all.disabled = cards.is_empty() or revealed.size() >= 5
# Deck-Vorschlag, sobald alle Karten offen liegen
suggestion = ""
if not cards.is_empty() and revealed.size() >= 5:
var ids: Array = cards.map(func(c): return c.id)
var better: Array = p.deck_suggestions(ids)
if not better.is_empty():
suggestion = better[0]
b_improve.visible = suggestion != ""
if suggestion != "":
b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).title(), p.deck_name()]
Style.clear(slots) Style.clear(slots)
for i in 5: for i in 5:
var r := slot_rect(i) var r := slot_rect(i)
@@ -110,7 +154,7 @@ func refresh() -> void:
var v := CardView.new(c, r.size) var v := CardView.new(c, r.size)
v.position = r.position v.position = r.position
slots.add_child(v) slots.add_child(v)
Style.label(slots, c.rarity, 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true) Style.label(slots, tr(c.rarity), 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if new_ids.has(c.id): if new_ids.has(c.id):
Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true) Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
else: else:

View File

@@ -2,52 +2,91 @@ class_name CampaignScreen
extends Control extends Control
var app var app
var chapter := 1
var perk_box: OptionButton
func _ready() -> void: func _ready() -> void:
var p: Profile = app.profile var p: Profile = app.profile
var col := Style.column(self, 1000, 24, 6) chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size())
Style.clabel(col, "Kampagne", 36, Style.GOLD, "center", true) build()
Style.clabel(col, "Zehn Magier warten. Jede Stufe bringt mehr Gegner an den Tisch, alle nach denselben Regeln.", 16, Style.MUTED)
Style.spacer(col, 6)
for i in Campaign.STAGES.size(): func build() -> void:
var st: Dictionary = Campaign.STAGES[i] Style.clear(self)
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
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()
if p.perk_unlocked():
var pr := Style.row(tabs, 8)
Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110)
perk_box = OptionButton.new()
perk_box.custom_minimum_size = Vector2(300, 36)
perk_box.add_item("keins")
var keys: Array = Game.PERKS.keys()
for k in keys:
perk_box.add_item(Game.PERKS[k])
perk_box.select(keys.find(p.perk) + 1 if p.perk != "" else 0)
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 intro := ["Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★).",
"Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.",
"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks."]
Style.clabel(col, intro[chapter - 1], 14, Style.MUTED)
var offset := (chapter - 1) * Campaign.STAGES.size()
for k in Campaign.STAGES.size():
var i := offset + k
var st := Campaign.stage(i)
var done := i < p.campaign_stage var done := i < p.campaign_stage
var next := i == p.campaign_stage var next := i == p.campaign_stage
var panel := PanelContainer.new() 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, 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)) Style.GOLD if next else null, 2 if next else 0))
col.add_child(panel) col.add_child(panel)
var r := Style.row(panel, 12) var r := Style.row(panel, 10)
r.custom_minimum_size = Vector2(0, 50) r.custom_minimum_size = Vector2(0, 50)
Style.clabel(r, str(i + 1), 20, Style.GOLD if next else Style.MUTED, "center", true, 40) Style.clabel(r, str(i + 1), 18, Style.GOLD if next else Style.MUTED, "center", true, 36)
Style.clabel(r, "%s · %s" % [st.name, st.title], 17, Style.TEXT if not done else Style.MUTED, "left", true, 300) Style.clabel(r, "%s · %s" % [st.name, st.title], 16, Style.TEXT if not done else Style.MUTED, "left", true, 290)
var dots := Control.new() var dots := Control.new()
dots.custom_minimum_size = Vector2(60, 0) dots.custom_minimum_size = Vector2(50, 0)
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
var elems: Array = st.elements var elems: Array = st.elements
dots.draw.connect(func(): dots.draw.connect(func():
for k in elems.size(): for m in elems.size():
dots.draw_circle(Vector2(10 + k * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[k]])) dots.draw_circle(Vector2(10 + m * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[m]]))
r.add_child(dots) r.add_child(dots)
var table: String = "Duell" if st.extra == 0 else "%d am Tisch" % (st.extra + 2) var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, Game.PERKS[st.perk]], 13, Style.MUTED, "left", false, 300) var table: String = "Duell" if extra == 0 else "%d am Tisch" % (extra + 2)
var perks_text: String = ", ".join(st.perks.map(func(pk): return Game.PERKS[pk]))
var target := Campaign.target_rating(i, p.target_rating())
Style.clabel(r, "%s · Stärke %.1f · %d Münzen\n★ %s" % [table, target, st.reward, perks_text], 12, Style.MUTED, "left", false, 360)
if done: if done:
Style.clabel(r, "abgeschlossen", 14, Style.GREEN, "right", false, 110) Style.clabel(r, "abgeschlossen", 13, Style.GREEN, "right", false, 100)
Style.cbutton(r, "Wiederholen", Vector2(140, 38), _start.bind(i)) Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i))
elif next: elif next:
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110) Style.clabel(r, "", 13, Style.GREEN, "right", false, 100)
Style.cbutton(r, "Kämpfen", Vector2(140, 38), _start.bind(i), Color("285a3c")) Style.cbutton(r, "Kämpfen", Vector2(130, 36), _start.bind(i), Color("285a3c"))
else: else:
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110) Style.clabel(r, "", 13, Style.GREEN, "right", false, 100)
var b := Style.cbutton(r, "Gesperrt", Vector2(140, 38), func(): pass) var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
b.disabled = true b.disabled = true
if p.campaign_stage >= Campaign.STAGES.size(): if p.campaign_stage >= Campaign.total():
Style.clabel(col, "Kampagne abgeschlossen. Der Archimagier ist besiegt!", 18, Style.GREEN, "center", true) Style.clabel(col, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true)
Style.spacer(col, 4)
var br := Style.row(col) var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
func set_chapter(c: int) -> void:
chapter = c
build()
func _start(stage: int) -> void: func _start(stage: int) -> void:

View File

@@ -17,6 +17,7 @@ func _init(rect: Rect2) -> void:
position = rect.position position = rect.position
size = rect.size size = rect.size
horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
follow_focus = true # Gamepad: der fokussierten Karte hinterherscrollen
grid = GridContainer.new() grid = GridContainer.new()
grid.columns = COLS grid.columns = COLS
grid.add_theme_constant_override("h_separation", GAP) grid.add_theme_constant_override("h_separation", GAP)
@@ -31,7 +32,7 @@ func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
var cell := Control.new() var cell := Control.new()
cell.custom_minimum_size = Vector2(CW, CH + 16) cell.custom_minimum_size = Vector2(CW, CH + 16)
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
var v := CardView.new(c, Vector2(CW, CH)) var v := CardView.new(c, Vector2(CW, CH), true)
v.dim = dim_fn.call(c) v.dim = dim_fn.call(c)
v.clicked.connect(func(_view): card_clicked.emit(c)) v.clicked.connect(func(_view): card_clicked.emit(c))
v.hover.connect(func(card, entered): card_hover.emit(card, entered)) v.hover.connect(func(card, entered): card_hover.emit(card, entered))

View File

@@ -10,19 +10,31 @@ var border = null # Color oder null (dann Seltenheitsfarbe)
var dim := false var dim := false
func _init(c: Cards.Card, sz: Vector2) -> void: ## 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) -> void:
card = c card = c
custom_minimum_size = sz custom_minimum_size = sz
size = sz size = sz
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(card, true)) mouse_entered.connect(func(): hover.emit(card, true))
mouse_exited.connect(func(): hover.emit(card, false)) mouse_exited.connect(func(): hover.emit(card, false))
if focusable:
focus_mode = Control.FOCUS_ALL
focus_entered.connect(func():
hover.emit(card, true)
queue_redraw())
focus_exited.connect(func():
hover.emit(card, false)
queue_redraw())
func _gui_input(event: InputEvent) -> void: func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event() accept_event()
clicked.emit(self) clicked.emit(self)
elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event()
clicked.emit(self)
func _draw() -> void: func _draw() -> void:
@@ -41,25 +53,34 @@ func _draw() -> void:
draw_circle(cc, r, Color("14141c")) draw_circle(cc, r, Color("14141c"))
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true) Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true)
# Name und Kombo # Name und Kombo
var nm := Style.fit(card.name, true, f_name, size.x - 2 * r - 12) var name_left := 2 * r + 10
Style.text(self, nm, f_name, Style.TEXT, Vector2(size.x / 2 + r / 2.0, 4), "midtop", true) 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: if mid:
var sub: String = (card.combo() + " · " + card.kind) if card.is_combo() else card.kind var sub: String = (tr(card.combo()) + " · " + tr(card.kind)) if card.is_combo() else tr(card.kind)
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop") 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 box_top: float = maxf(size.y * 0.30, box_bottom - maxf(needed, size.y * 0.42))
var box := Rect2(6, box_top, size.x - 12, box_bottom - box_top)
# Bild # Bild
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0) var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0)
var band_bottom: float = size.y * 0.42 - 4 var band_bottom: float = box_top - 4
if band_bottom - band_top > 14: if band_bottom - band_top > 14:
var gsize: float = minf(band_bottom - band_top, size.x * 0.42) 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) Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize)
# Textfeld
var box := Rect2(6, size.y * 0.42, size.x - 12, size.y * 0.42)
draw_rect(box, Color(0.06, 0.06, 0.09, 0.67)) draw_rect(box, Color(0.06, 0.06, 0.09, 0.67))
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(f_text)), var max_lines := maxi(1, int((box.size.y - 6) / Style.FONT.get_height(tsize))) # große Schrift: lieber kürzen als überlaufen
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, f_text, 6 if big else 4, Style.TEXT) 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 # Werte
if card.is_creature(): if card.is_creature():
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true) 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) Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
if dim: if dim:
draw_rect(rect, Color(0, 0, 0, 0.47)) draw_rect(rect, Color(0, 0, 0, 0.47))
if has_focus():
Style.border(self, rect, Style.GOLD, 4, 8)

View File

@@ -0,0 +1,42 @@
class_name ChallengeScreen
extends Control
## Wöchentliche Herausforderung: feste Regelvariante mit einmaliger Belohnung.
var app
var msg: Label
func _ready() -> void:
var p: Profile = app.profile
var ch := Challenge.current()
var done: bool = p.challenge_week == Challenge.week()
var col := Style.column(self, 760, 60, 14)
Style.clabel(col, "Herausforderung der Woche", 36, Style.GOLD, "center", true)
Style.clabel(col, "Jede Woche eine andere Regel. Die Belohnung gibt es einmal pro Woche, danach die normale.", 16, Style.MUTED)
Style.spacer(col, 20)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 10, Style.GREEN if done else Style.GOLD, 2))
col.add_child(panel)
var inner := VBoxContainer.new()
inner.add_theme_constant_override("separation", 10)
panel.add_child(inner)
Style.spacer(inner, 6)
Style.clabel(inner, ch.name, 30, Style.TEXT, "center", true)
Style.clabel(inner, ch.text, 18, Style.MUTED)
Style.clabel(inner, "Belohnung: %d Münzen%s" % [ch.reward, " · diese Woche schon bestanden ✓" if done else ""], 16, Style.GOLD)
Style.spacer(inner, 6)
Style.spacer(col, 10)
msg = Style.clabel(col, "", 16, Style.ORANGE)
var deck_err := Challenge.deck_allowed(ch, p.deck_cards())
if deck_err != "":
msg.text = deck_err + " (aktives Deck: %s)" % p.deck_name()
var br := Style.row(col, 20)
br.alignment = BoxContainer.ALIGNMENT_CENTER
var b := Style.cbutton(br, "Antreten", Vector2(200, 50), func(): app.show_screen(BattleScreen.weekly(ch)), Color("285a3c"))
b.disabled = deck_err != "" or not p.deck_valid()[0]
Style.cbutton(br, "Zurück", Vector2(160, 50), func(): app.show_screen(MenuScreen.new()))
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())

View File

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

View File

@@ -44,7 +44,7 @@ func _draw() -> void:
_draw_mini() _draw_mini()
return return
var fs := 11 if small else 13 var fs := 11 if small else 13
var nm := Style.fit(creature.name, true, fs, size.x - 6) 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) Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true)
var badges: Array[String] = [] var badges: Array[String] = []
if creature.taunt(): if creature.taunt():

View File

@@ -27,6 +27,9 @@ var slot_buttons: Array = []
var b_activate: Button var b_activate: Button
var b_autofill: Button var b_autofill: Button
var name_box: LineEdit var name_box: LineEdit
var confirm: Control
var code_dialog: Control
var code_box: LineEdit
class DeckRow extends Control: class DeckRow extends Control:
@@ -42,26 +45,30 @@ class DeckRow extends Control:
size = sz size = sz
custom_minimum_size = sz custom_minimum_size = sz
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): focus_mode = Control.FOCUS_ALL
lit = true var light := func(on: bool):
lit = on
queue_redraw() queue_redraw()
hover.emit(card, true)) hover.emit(card, on)
mouse_exited.connect(func(): mouse_entered.connect(light.bind(true))
lit = false mouse_exited.connect(light.bind(false))
queue_redraw() focus_entered.connect(light.bind(true))
hover.emit(card, false)) focus_exited.connect(light.bind(false))
func _gui_input(event: InputEvent) -> void: func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event() accept_event()
clicked.emit(card) clicked.emit(card)
elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event()
clicked.emit(card)
func _draw() -> void: func _draw() -> void:
if lit: if lit:
draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size)) draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size))
for k in card.elements.size(): for k in card.elements.size():
draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]]) draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]])
Style.text(self, "%dx %s" % [count, card.name], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft") Style.text(self, "%dx %s" % [count, card.title()], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true) Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true)
@@ -92,10 +99,11 @@ func _ready() -> void:
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size(): for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i] var e: String = Cards.ELEMENTS[i]
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) 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))
fb.add_theme_font_size_override("font_size", 14)
search_box = LineEdit.new() search_box = LineEdit.new()
search_box.position = Vector2(400, 55) search_box.position = Vector2(424, 55)
search_box.size = Vector2(160, 34) search_box.size = Vector2(136, 34)
search_box.placeholder_text = "Suche" search_box.placeholder_text = "Suche"
search_box.text_changed.connect(func(t: String): search_box.text_changed.connect(func(t: String):
search = t search = t
@@ -149,9 +157,25 @@ func _ready() -> void:
curve.mouse_filter = Control.MOUSE_FILTER_IGNORE curve.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(curve) add_child(curve)
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24)) msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c")) Style.button(self, "Speichern", Rect2(860, 740, 140, 44), save_deck, Color("285a3c"))
Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck) Style.button(self, "Leeren", Rect2(1008, 740, 70, 44), clear_deck)
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new())) Style.button(self, "Code", Rect2(1086, 740, 84, 44), open_code)
Style.button(self, "Zurück", Rect2(1178, 740, 82, 44), leave)
# Deck-Code zum Teilen: kopieren, einfügen, übernehmen
code_dialog = Style.overlay(self)
Style.panel(code_dialog, Rect2(240, 270, 800, 240), Style.PANEL, 10, Style.GOLD, 2)
Style.label(code_dialog, "Deck-Code", 26, Style.TEXT, Rect2(240, 290, 800, 40), "center", true)
Style.label(code_dialog, "Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.", 15, Style.MUTED, Rect2(240, 334, 800, 24), "center")
code_box = LineEdit.new()
code_box.position = Vector2(270, 368)
code_box.size = Vector2(740, 36)
code_dialog.add_child(code_box)
Style.button(code_dialog, "In Zwischenablage", Rect2(270, 430, 180, 44), func(): DisplayServer.clipboard_set(code_box.text))
Style.button(code_dialog, "Aus Zwischenablage", Rect2(460, 430, 180, 44), func(): code_box.text = DisplayServer.clipboard_get())
Style.button(code_dialog, "Übernehmen", Rect2(650, 430, 170, 44), func(): apply_code(code_box.text), Color("285a3c"))
Style.button(code_dialog, "Schließen", Rect2(830, 430, 180, 44), func(): code_dialog.visible = false)
confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.",
"Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD)
preview = CardView.new(Cards.all()[0], Vector2(200, 280)) preview = CardView.new(Cards.all()[0], Vector2(200, 280))
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview.visible = false preview.visible = false
@@ -159,11 +183,27 @@ func _ready() -> void:
refresh() refresh()
func _input(event: InputEvent) -> void: func dirty() -> bool:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus() and not name_box.has_focus(): return deck != app.profile.decks[slot].cards
func leave() -> void:
if dirty():
confirm.visible = true
else:
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel") and not search_box.has_focus() and not name_box.has_focus() and not code_box.has_focus():
if confirm.visible:
confirm.visible = false
elif code_dialog.visible:
code_dialog.visible = false
else:
leave()
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if preview.visible: if preview.visible:
var pos := get_local_mouse_position() + Vector2(16, 16) var pos := get_local_mouse_position() + Vector2(16, 16)
@@ -227,7 +267,7 @@ func cards() -> Array:
out = out.filter(func(c): return filter in c.elements) out = out.filter(func(c): return filter in c.elements)
if search != "": if search != "":
var q := search.to_lower() var q := search.to_lower()
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().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())
match sort_mode: match sort_mode:
0: 0:
out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name)) out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
@@ -291,6 +331,34 @@ func save_deck() -> void:
refresh() refresh()
func open_code() -> void:
code_box.text = Profile.deck_code(deck)
code_dialog.visible = true
## Übernimmt einen Deck-Code; Karten, die in der Sammlung fehlen, werden gemeldet und weggelassen.
func apply_code(code: String) -> void:
var ids: Array = Profile.parse_deck_code(code)
if ids.is_empty():
say("Ungültiger Code", false)
return
var p: Profile = app.profile
var missing := {}
deck = []
for cid in ids:
var owned: int = p.collection.get(cid, 0)
if deck.count(cid) < owned and deck.size() < Game.DECK_SIZE:
deck.append(cid)
else:
missing[cid] = true
code_dialog.visible = false
if missing.is_empty():
say("Code übernommen", true)
else:
say(tr("Code übernommen, es fehlen: ") + ", ".join(missing.keys().map(func(cid): return Cards.by_id(cid).title())), false)
refresh()
func deck_cards() -> Array: func deck_cards() -> Array:
var out := [] var out := []
for cid in deck: for cid in deck:
@@ -340,10 +408,13 @@ func refresh() -> void:
if not name_box.has_focus(): if not name_box.has_focus():
name_box.text = p.deck_name(slot) name_box.text = p.deck_name(slot)
deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE] deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE]
deck_label.tooltip_text = tr("Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten")
deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT) deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT)
var elems := Game.deck_elements(dc) var elems := Game.deck_elements(dc)
elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b)) elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b))
elems_label.text = "Elemente: " + (", ".join(elems) if not elems.is_empty() else "–") elems_label.text = tr("Elemente: ") + (", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else "–")
if not dc.is_empty():
elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc)
var ok: Array = Game.validate_deck(dc, p.collection) var ok: Array = Game.validate_deck(dc, p.collection)
valid_label.text = "spielbar" if ok[0] else ok[1] valid_label.text = "spielbar" if ok[0] else ok[1]
valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE) valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE)

View File

@@ -9,6 +9,7 @@ var list_label: Label
var ip_box: LineEdit var ip_box: LineEdit
var b_host: Button var b_host: Button
var b_join: Button var b_join: Button
var b_watch: Button
var b_start: Button var b_start: Button
var ai_buttons: Array = [] var ai_buttons: Array = []
var n_ai := 0 var n_ai := 0
@@ -32,6 +33,7 @@ func _ready() -> void:
ip_box.text = "127.0.0.1" ip_box.text = "127.0.0.1"
hr.add_child(ip_box) hr.add_child(ip_box)
b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join) b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join)
b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch)
addr_label = Style.clabel(col, "", 14, Style.MUTED) addr_label = Style.clabel(col, "", 14, Style.MUTED)
status_label = Style.clabel(col, "", 16, Style.BLUE) status_label = Style.clabel(col, "", 16, Style.BLUE)
Style.spacer(col, 6) Style.spacer(col, 6)
@@ -71,6 +73,13 @@ func _join() -> void:
refresh() refresh()
## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit.
func _watch() -> void:
net.spectator = true
net.join(ip_box.text.strip_edges())
refresh()
func _set_ai(n: int) -> void: func _set_ai(n: int) -> void:
n_ai = n n_ai = n
refresh() refresh()
@@ -96,9 +105,12 @@ func refresh() -> void:
ids.sort() ids.sort()
for id in ids: for id in ids:
lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""]) lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""])
for id in net.spectators:
lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")])
list_label.text = "\n".join(lines) if not lines.is_empty() else "–" list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
b_host.disabled = net.connected b_host.disabled = net.connected
b_join.disabled = net.connected b_join.disabled = net.connected
b_watch.disabled = net.connected
ip_box.editable = not net.connected ip_box.editable = not net.connected
var humans := ids.size() var humans := ids.size()
b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1) b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1)

View File

@@ -9,6 +9,7 @@ var net: Net
func _ready() -> void: func _ready() -> void:
_map_gamepad()
theme = Style.make_theme() theme = Style.make_theme()
var bg := Style.Backdrop.new() var bg := Style.Backdrop.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT) bg.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -23,10 +24,25 @@ func _ready() -> void:
get_tree().root.add_child.call_deferred(net) get_tree().root.add_child.call_deferred(net)
profile = Profile.load_or_new(save_path) profile = Profile.load_or_new(save_path)
profile.refresh_daily() profile.refresh_daily()
apply_settings()
show_screen(MenuScreen.new())
func apply_settings() -> void:
sfx.set_volume(profile.settings.volume) sfx.set_volume(profile.settings.volume)
sfx.set_music_volume(profile.settings.music) sfx.set_music_volume(profile.settings.music)
set_language(profile.settings.language) set_language(profile.settings.language)
show_screen(MenuScreen.new()) Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0
theme = Style.make_theme()
## Neues Profil: Sammlung, Decks und Fortschritt werden verworfen.
func reset_profile() -> void:
profile = Profile.starter()
profile.refresh_daily()
clear_battle_save()
save()
apply_settings()
func set_language(code: String) -> void: func set_language(code: String) -> void:
@@ -40,12 +56,69 @@ func show_screen(s: Control) -> void:
s.app = self s.app = self
s.set_anchors_preset(Control.PRESET_FULL_RECT) s.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(s) add_child(s)
if pad_mode:
focus_first_button.call_deferred()
var pad_mode := false # zuletzt Gamepad benutzt: Fokusrahmen sichtbar, Steuerkreuz wechselt Knöpfe
## Gamepad in Menüs: erster Tastendruck setzt den Fokus, Maus nimmt ihn wieder weg.
func _input(event: InputEvent) -> void:
if event is InputEventJoypadButton or (event is InputEventJoypadMotion and absf(event.axis_value) > 0.5):
if not pad_mode:
pad_mode = true
if get_viewport().gui_get_focus_owner() == null:
focus_first_button()
elif event is InputEventMouseMotion and pad_mode:
pad_mode = false
get_viewport().gui_release_focus()
## Gamepad-Tasten für die Menünavigation: A bestätigt, B bricht ab (falls nicht schon zugeordnet).
func _map_gamepad() -> void:
for pair in [["ui_accept", JOY_BUTTON_A], ["ui_cancel", JOY_BUTTON_B]]:
var action: String = pair[0]
if not InputMap.has_action(action):
InputMap.add_action(action)
if not InputMap.action_get_events(action).any(func(e): return e is InputEventJoypadButton):
var ev := InputEventJoypadButton.new()
ev.button_index = pair[1]
InputMap.action_add_event(action, ev)
func focus_first_button() -> void:
if screen == null or screen is BattleScreen:
return
var b := _first_button(screen)
if b != null:
b.grab_focus()
func _first_button(node: Node) -> Button:
for c in node.get_children():
if c is Button and not c.disabled and c.is_visible_in_tree():
return c
var found := _first_button(c)
if found != null:
return found
return null
func save() -> void: func save() -> void:
profile.save(save_path) 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: func _exit_tree() -> void:
if net != null and net.is_inside_tree(): if net != null and net.is_inside_tree():
net.leave() net.leave()

View File

@@ -7,6 +7,8 @@ var b_campaign: Button
var b_hotseat: Button var b_hotseat: Button
var b_online: Button var b_online: Button
var b_tutorial: Button var b_tutorial: Button
var b_challenge: Button
var b_resume: Button
var coins_label: Label var coins_label: Label
var coll_label: Label var coll_label: Label
var deck_label: Label var deck_label: Label
@@ -34,12 +36,17 @@ func _ready() -> void:
col.add_child(center) col.add_child(center)
var sz := Vector2(270, 44) var sz := Vector2(270, 44)
var p0: Profile = app.profile var p0: Profile = app.profile
b_tutorial = Style.cbutton(grid, "Anleitung", sz, func(): app.show_screen(BattleScreen.guided()), var part2: bool = p0.tutorial_done and not p0.tutorial2_done
Color("5a4628") if not p0.tutorial_done else null) if FileAccess.file_exists(app.battle_save_path()):
b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628"))
b_tutorial = Style.cbutton(grid, "Anleitung 2: Fallen" if part2 else "Anleitung", sz,
func(): app.show_screen(BattleScreen.guided(2 if part2 else 1)),
Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null)
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c")) b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c")) b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
b_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c")) b_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c"))
b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c")) b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
b_challenge = Style.cbutton(grid, "Herausforderung", sz, func(): app.show_screen(ChallengeScreen.new()), Color("285a3c"))
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new())) Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new())) Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new()))
Style.cbutton(grid, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new())) Style.cbutton(grid, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new()))
@@ -54,6 +61,17 @@ func _ready() -> void:
refresh() 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))
func refresh() -> void: func refresh() -> void:
var p: Profile = app.profile var p: Profile = app.profile
var ok: Array = p.deck_valid() var ok: Array = p.deck_valid()
@@ -61,23 +79,28 @@ func refresh() -> void:
b_campaign.disabled = not ok[0] b_campaign.disabled = not ok[0]
b_tutorial.disabled = not ok[0] b_tutorial.disabled = not ok[0]
b_online.disabled = not ok[0] b_online.disabled = not ok[0]
b_challenge.disabled = not ok[0]
b_challenge.text = tr("Herausforderung") + (" ✓" if p.challenge_week == Challenge.week() else "")
var total := 0 var total := 0
for cid in p.collection: for cid in p.collection:
total += p.collection[cid] total += p.collection[cid]
coins_label.text = "Münzen: %d · Staub: %d" % [p.coins, p.dust] coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
coll_label.text = "Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d" % [ coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.STAGES.size()] p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()]
var elems := Game.deck_elements(p.deck_cards()) var elems := Game.deck_elements(p.deck_cards())
deck_label.text = "Aktives Deck: %s (%s)" % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else "leer"] deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else tr("leer")]
var task := p.daily_task() var task := p.daily_task()
if p.daily.done: if p.daily.done:
daily_label.text = "Tagesaufgabe erledigt: %s ✓" % task.text daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text
else: else:
daily_label.text = "Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward] daily_label.text = tr("Tagesaufgabe: %s (%d/%d) · +%d Münzen") % [task.text, p.daily.progress, task.goal, task.reward]
warn_label.text = "" if ok[0] else "Aktives Deck nicht spielbar: " + ok[1] warn_label.text = "" if ok[0] else tr("Aktives Deck nicht spielbar: ") + tr(ok[1])
if ok[0] and not p.tutorial_done: if ok[0] and not p.tutorial_done:
warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos." warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos."
warn_label.add_theme_color_override("font_color", Style.GOLD) warn_label.add_theme_color_override("font_color", Style.GOLD)
elif ok[0] and not p.tutorial2_done:
warn_label.text = "Anleitung 2 zeigt Mulligan, Fallen und die Feldkombo Dampf."
warn_label.add_theme_color_override("font_color", Style.GOLD)
var notes := p.take_notices() var notes := p.take_notices()
notice_label.text = " · ".join(notes) notice_label.text = " · ".join(notes)
if not notes.is_empty(): if not notes.is_empty():

View File

@@ -1,27 +1,107 @@
class_name OpponentSelectScreen class_name OpponentSelectScreen
extends Control extends Control
## Einzelspieler: Auswahl, gegen wie viele Gegner gekämpft wird. ## Einzelspieler: Schwierigkeit, Gegnerzahl und Vorschau der Gegner (Element, Stärke).
var app var app
var rating_label: Label
var tip_label: Label
var diff_buttons := {}
var previews := {} # n -> Array von Game.Player
var preview_labels := {}
var b_tip: Button
var b_reroll: Button
var rerolls_left := 3
func _ready() -> void: func _ready() -> void:
var col := Style.column(self, 900, 50, 14) var col := Style.column(self, 1000, 30, 10)
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true) Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true)
Style.clabel(col, "Wie viele Gegner? Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben." % Game.START_LIFE, 18, Style.MUTED) Style.clabel(col, "Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben. Du gewinnst, wenn alle anderen besiegt sind." % Game.START_LIFE, 15, Style.MUTED)
Style.clabel(col, "Du gewinnst, wenn alle anderen besiegt sind. Mehr Gegner, mehr Chaos, mehr Münzen.", 16, Style.MUTED) Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true)
Style.spacer(col, 10) var dr := Style.row(col, 10)
dr.alignment = BoxContainer.ALIGNMENT_CENTER
for key in ["leicht", "normal", "schwer"]:
var k: String = key
diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k))
rating_label = Style.clabel(col, "", 14, Style.MUTED)
var tr_row := Style.row(col, 10)
tr_row.alignment = BoxContainer.ALIGNMENT_CENTER
tip_label = Style.clabel(tr_row, "", 14, Style.GOLD, "right", false, 520)
b_tip = Style.cbutton(tr_row, "Übernehmen", Vector2(140, 32), take_tip)
Style.spacer(col, 2)
for i in range(1, Game.MAX_OPPONENTS + 1): for i in range(1, Game.MAX_OPPONENTS + 1):
var n := i var n := i
var r := Style.row(col, 20) var r := Style.row(col, 16)
r.alignment = BoxContainer.ALIGNMENT_CENTER r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(r, "%d Gegner" % n, Vector2(200, 56), func(): app.show_screen(BattleScreen.new(n)), Color("285a3c")) Style.cbutton(r, "%d Gegner" % n, Vector2(150, 46), func(): start(n), Color("285a3c"))
var info := ("Duell" if n == 1 else "%d Spieler am Tisch" % (n + 1)) + " · Sieg: %d Münzen" % Profile.REWARD_WIN[n] var info := (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n]
Style.clabel(r, info, 16, Style.MUTED, "left", false, 420) Style.clabel(r, info, 13, Style.MUTED, "left", false, 150)
Style.spacer(col, 20) preview_labels[n] = Style.clabel(r, "", 13, Style.TEXT, "left", false, 600)
var br := Style.row(col) preview_labels[n].autowrap_mode = TextServer.AUTOWRAP_WORD
preview_labels[n].custom_minimum_size = Vector2(600, 0)
preview_labels[n].size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.spacer(col, 4)
var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) b_reroll = Style.cbutton(br, "Neu würfeln", Vector2(190, 40), reroll)
Style.cbutton(br, "Zurück", Vector2(150, 40), func(): app.show_screen(MenuScreen.new()))
refresh(true)
func set_difficulty(key: String) -> void:
app.profile.settings.difficulty = key
app.save()
refresh(true)
func take_tip() -> void:
var tip: String = app.profile.suggest_difficulty()
if tip != "":
set_difficulty(tip)
## Höchstens dreimal neu würfeln, sonst ließen sich schwache Gegner erwürfeln.
func reroll() -> void:
if rerolls_left <= 0:
return
rerolls_left -= 1
refresh(true)
func start(n: int) -> void:
app.show_screen(BattleScreen.against(previews[n]))
## Baut die Gegner für jede Anzahl vor und zeigt Element und Stärke; der Kampf nutzt genau diese.
func refresh(reroll: bool = false) -> void:
var p: Profile = app.profile
var d := p.difficulty()
for key in diff_buttons:
var b: Button = diff_buttons[key]
var active: bool = key == p.settings.difficulty
b.add_theme_stylebox_override("normal", Style.box(Color("285a3c") if active else Style.PANEL_LIGHT, 8, Style.GOLD if active else Style.MUTED, 2))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Color("285a3c") if active else Style.PANEL_LIGHT), 8, Style.GOLD if active else Style.MUTED, 2))
var arche: String = tr("schwache Archetypen") if d.tier == "leicht" else (tr("starke Archetypen") if d.tier == "schwer" else tr("alle Archetypen"))
rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s") % [
p.deck_rating(), p.target_rating(), arche, d.reward, tr("Vorausschau") if d.smart else tr("einfach")]
var tip := p.suggest_difficulty()
tip_label.text = (tr("Tipp: %s") % ((tr("%d Siege in Folge, versuche %s") % [p.records.streak, tr(Profile.DIFFICULTIES[tip].name)]) if p.records.streak >= 3
else (tr("%d Niederlagen in Folge, versuche %s") % [p.records.loss_streak, tr(Profile.DIFFICULTIES[tip].name)]))) if tip != "" else ""
b_tip.visible = tip != ""
if reroll:
var r := Game.make_rng()
for n in range(1, Game.MAX_OPPONENTS + 1):
var opps := []
for i in n:
opps.append(Game.random_opponent(r, i + 1, Game.START_LIFE, p.target_rating(), str(d.tier)))
previews[n] = opps
b_reroll.text = tr("Neu würfeln (%d)") % rerolls_left
b_reroll.disabled = rerolls_left <= 0
for n in previews:
var parts: Array[String] = []
for o in previews[n]:
parts.append("%s (%.1f)" % [o.title, Game.deck_rating(o.deck)])
preview_labels[n].text = ", ".join(parts)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:

View File

@@ -7,6 +7,8 @@ var music: HSlider
var speed: HSlider var speed: HSlider
var language: OptionButton var language: OptionButton
var music_label: Label var music_label: Label
var big_font: CheckButton
var confirm: Control
var anim: CheckButton var anim: CheckButton
var volume_label: Label var volume_label: Label
var speed_label: Label var speed_label: Label
@@ -58,24 +60,45 @@ func _ready() -> void:
anim.toggled.connect(_on_anim) anim.toggled.connect(_on_anim)
grid.add_child(anim) grid.add_child(anim)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "Große Schrift", 20, Style.TEXT, "left", false, 200)
big_font = CheckButton.new()
big_font.button_pressed = bool(s.get("big_font", false))
big_font.toggled.connect(_on_big_font)
grid.add_child(big_font)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200) Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200)
language = OptionButton.new() language = OptionButton.new()
language.custom_minimum_size = Vector2(280, 36) language.custom_minimum_size = Vector2(280, 36)
language.add_item("Deutsch", 0) language.add_item("Deutsch", 0)
language.add_item("English (Oberfläche)", 1) language.add_item("English", 1)
language.select(1 if s.language == "en" else 0) language.select(1 if s.language == "en" else 0)
language.item_selected.connect(_on_language) language.item_selected.connect(_on_language)
grid.add_child(language) grid.add_child(language)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.spacer(col, 20) Style.spacer(col, 20)
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED) Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
Style.spacer(col, 60) Style.spacer(col, 30)
var r := Style.row(col) var r := Style.row(col, 16)
r.alignment = BoxContainer.ALIGNMENT_CENTER 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())) Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Decks, Münzen und Fortschritt gehen verloren.",
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
_labels() _labels()
func _reset() -> void:
app.reset_profile()
app.show_screen(MenuScreen.new())
func _on_big_font(on: bool) -> void:
app.profile.settings.big_font = on
app.save()
app.apply_settings()
app.show_screen(SettingsScreen.new())
func _labels() -> void: func _labels() -> void:
volume_label.text = "%d %%" % int(round(volume.value * 100)) volume_label.text = "%d %%" % int(round(volume.value * 100))
music_label.text = "%d %%" % int(round(music.value * 100)) music_label.text = "%d %%" % int(round(music.value * 100))

View File

@@ -9,6 +9,8 @@ var players: Array[AudioStreamPlayer] = []
var volume := 0.8 var volume := 0.8
var music_player: AudioStreamPlayer var music_player: AudioStreamPlayer
var music_volume := 0.5 var music_volume := 0.5
var music_task := -1 # die Musik wird im Hintergrund berechnet (spart ~150 ms beim Start)
var music_box := {}
func _ready() -> void: func _ready() -> void:
@@ -26,11 +28,25 @@ func _ready() -> void:
add_child(p) add_child(p)
players.append(p) players.append(p)
music_player = AudioStreamPlayer.new() music_player = AudioStreamPlayer.new()
music_player.stream = _music()
add_child(music_player) add_child(music_player)
var box := music_box
music_task = WorkerThreadPool.add_task(func(): box["wav"] = _music())
func _process(_delta: float) -> void:
if music_task >= 0 and WorkerThreadPool.is_task_completed(music_task):
WorkerThreadPool.wait_for_task_completion(music_task)
music_task = -1
music_player.stream = music_box.wav
set_music_volume(music_volume) set_music_volume(music_volume)
func _exit_tree() -> void:
if music_task >= 0:
WorkerThreadPool.wait_for_task_completion(music_task)
music_task = -1
## Leise Hintergrundmusik: Arpeggio über vier Akkorde, als Schleife erzeugt. ## Leise Hintergrundmusik: Arpeggio über vier Akkorde, als Schleife erzeugt.
func _music() -> AudioStreamWAV: func _music() -> AudioStreamWAV:
var rate := 22050 var rate := 22050
@@ -67,7 +83,7 @@ func set_music_volume(v: float) -> void:
music_player.stop() music_player.stop()
else: else:
music_player.volume_db = linear_to_db(music_volume * 0.6) music_player.volume_db = linear_to_db(music_volume * 0.6)
if not music_player.playing: if not music_player.playing and music_player.stream != null:
music_player.play() music_player.play()

View File

@@ -2,6 +2,11 @@ class_name Style
## Farben, Schriften, Theme und Zeichenhilfen. ## Farben, Schriften, Theme und Zeichenhilfen.
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf") const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
static var font_scale := 1.0 # Einstellung „Große Schrift“
static func fs(size: int) -> int:
return int(round(size * font_scale))
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf") const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
const BG := Color("1a1c24") const BG := Color("1a1c24")
@@ -38,16 +43,16 @@ static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0
static func make_theme() -> Theme: static func make_theme() -> Theme:
var t := Theme.new() var t := Theme.new()
t.default_font = FONT t.default_font = FONT
t.default_font_size = 16 t.default_font_size = fs(16)
t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2)) t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2))
t.set_stylebox("hover", "Button", box(lighter(PANEL_LIGHT), 8, MUTED, 2)) t.set_stylebox("hover", "Button", box(lighter(PANEL_LIGHT), 8, MUTED, 2))
t.set_stylebox("pressed", "Button", box(darker(PANEL_LIGHT), 8, MUTED, 2)) t.set_stylebox("pressed", "Button", box(darker(PANEL_LIGHT), 8, MUTED, 2))
t.set_stylebox("disabled", "Button", box(darker(PANEL, 0.9), 8, PANEL, 2)) t.set_stylebox("disabled", "Button", box(darker(PANEL, 0.9), 8, PANEL, 2))
t.set_stylebox("focus", "Button", StyleBoxEmpty.new()) t.set_stylebox("focus", "Button", box(Color(0, 0, 0, 0), 8, GOLD, 3))
for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]: for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
t.set_color(name, "Button", TEXT) t.set_color(name, "Button", TEXT)
t.set_color("font_disabled_color", "Button", MUTED) t.set_color("font_disabled_color", "Button", MUTED)
t.set_font_size("font_size", "Button", 18) t.set_font_size("font_size", "Button", fs(18))
t.set_color("font_color", "Label", TEXT) t.set_color("font_color", "Label", TEXT)
t.set_stylebox("panel", "Panel", box(PANEL, 8)) t.set_stylebox("panel", "Panel", box(PANEL, 8))
t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new()) t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new())
@@ -84,7 +89,7 @@ static func label(parent: Node, text: String, size: int, color: Color, rect: Rec
l.text = text l.text = text
l.position = rect.position l.position = rect.position
l.size = rect.size l.size = rect.size
l.add_theme_font_size_override("font_size", size) l.add_theme_font_size_override("font_size", fs(size))
l.add_theme_color_override("font_color", color) l.add_theme_color_override("font_color", color)
if bold: if bold:
l.add_theme_font_override("font", FONT_BOLD) l.add_theme_font_override("font", FONT_BOLD)
@@ -160,6 +165,31 @@ static func spacer(parent: Node, h: float) -> Control:
return c return c
## Unsichtbare Vollbild-Ebene mit abgedunkeltem Hintergrund, die Klicks schluckt.
static func overlay(parent: Node) -> Control:
var o := Control.new()
o.set_anchors_preset(Control.PRESET_FULL_RECT)
o.visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
o.add_child(dark)
parent.add_child(o)
return o
## Bestätigungsdialog (unsichtbar): Titel, Hinweis, Ja-Knopf mit Aktion und ein Abbrechen-Knopf.
static func confirm(parent: Node, title: String, text: String, yes: String, on_yes: Callable, no: String = "Abbrechen", color: Color = MUTED) -> Control:
var o := overlay(parent)
panel(o, Rect2(340, 290, 600, 200), PANEL, 10, color, 2)
label(o, title, 26, TEXT, Rect2(340, 312, 600, 40), "center", true)
label(o, text, 16, MUTED, Rect2(340, 360, 600, 24), "center")
button(o, yes, Rect2(380, 420, 240, 46), on_yes, Color("5a2828"))
button(o, no, Rect2(660, 420, 240, 46), func(): o.visible = false)
return o
static func clear(node: Node) -> void: static func clear(node: Node) -> void:
for c in node.get_children(): for c in node.get_children():
node.remove_child(c) node.remove_child(c)
@@ -168,7 +198,8 @@ static func clear(node: Node) -> void:
# --- Zeichenhilfen für _draw() --------------------------------------------------- # --- Zeichenhilfen für _draw() ---------------------------------------------------
static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void: static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
var size := fs(size_in)
var font: Font = FONT_BOLD if bold else FONT var font: Font = FONT_BOLD if bold else FONT
var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size) var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var x := pos.x var x := pos.x
@@ -253,8 +284,8 @@ static func rules_text() -> String:
"Kombokarten sind seltener und stärker, Dreifach-Kombos sind mythisch. Ein Deck darf höchstens 2 Elemente enthalten.", "Kombokarten sind seltener und stärker, Dreifach-Kombos sind mythisch. Ein Deck darf höchstens 2 Elemente enthalten.",
Cards.RESONANCE_TEXT, Cards.RESONANCE_TEXT,
"Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):", "Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):",
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an alle gegnerischen Wesen, Blitz 2 Schaden", " Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an das stärkste gegnerische Wesen, Blitz 2 Schaden",
" an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen Schild.", " an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen +1/+1.",
"", "",
"Schlüsselwörter:", "Schlüsselwörter:",
] ]
@@ -264,10 +295,16 @@ static func rules_text() -> String:
lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES]) lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES])
lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.") lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.")
lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.") lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.")
lines.append("Elementbooster: 5 Karten eines Elements (und seiner Kombos) für %d Münzen." % Profile.ELEMENT_BOOSTER_COST)
lines.append("Deck-Code: im Deckbau unter „Code“ kopieren und mit anderen teilen.")
lines.append("Ein unterbrochener Einzelspieler-Kampf lässt sich im Menü fortsetzen.")
lines.append("") lines.append("")
lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.") lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.")
lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.") lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.")
lines.append("Netzwerkspiel: Fällt der Host aus, übernimmt ein Mitspieler; Zuschauer sehen die Partie ohne Hände.")
lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.") lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.")
lines.append("Deckstärke: Seltenheit, Schlüsselwörter und Effekte ergeben je Karte einen Wert. Gegnerdecks werden auf deine")
lines.append(" Stärke gebaut (Leicht 0,9-fach, Normal 1-fach, Schwer 1,2-fach mit mehr Münzen). Die Kampagne liegt nie darunter.")
lines.append("Kampagnen-Bosse haben ein Merkmal (★), zum Beispiel stärkere Feuerwesen. Netzwerkspiel im lokalen Netz.") lines.append("Kampagnen-Bosse haben ein Merkmal (★), zum Beispiel stärkere Feuerwesen. Netzwerkspiel im lokalen Netz.")
lines.append("Steuerung: Linksklick spielt Karten und wählt Ziele, Rechtsklick oder Esc bricht ab, Leertaste beendet den Zug, 1 bis 9 spielen Handkarten.") lines.append("Steuerung: Linksklick spielt Karten und wählt Ziele, Rechtsklick oder Esc bricht ab, Leertaste beendet den Zug, 1 bis 9 spielen Handkarten.")
return "\n".join(lines) return "\n".join(lines)

View File

@@ -24,9 +24,10 @@ func _ready() -> void:
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size(): for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i] var e: String = Cards.ELEMENTS[i]
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) 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))
fb.add_theme_font_size_override("font_size", 14)
search_box = LineEdit.new() search_box = LineEdit.new()
search_box.position = Vector2(400, 55) search_box.position = Vector2(424, 55)
search_box.size = Vector2(200, 34) search_box.size = Vector2(200, 34)
search_box.placeholder_text = "Suche" search_box.placeholder_text = "Suche"
search_box.text_changed.connect(func(t: String): search_box.text_changed.connect(func(t: String):
@@ -51,7 +52,7 @@ func _ready() -> void:
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus(): if event.is_action_pressed("ui_cancel") and not search_box.has_focus():
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())
@@ -66,7 +67,7 @@ func cards() -> Array:
out = out.filter(func(c): return filter in c.elements) out = out.filter(func(c): return filter in c.elements)
if search != "": if search != "":
var q := search.to_lower() var q := search.to_lower()
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().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())
return out return out
@@ -88,7 +89,7 @@ func dismantle() -> void:
if err == "": if err == "":
app.sfx.play("click") app.sfx.play("click")
app.save() app.save()
say("%s zerlegt (+%d Staub)" % [selected.name, Profile.DUST_DISMANTLE[selected.rarity]], true) say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[selected.rarity]], true)
else: else:
say(err, false) say(err, false)
refresh() refresh()
@@ -102,7 +103,7 @@ func craft() -> void:
app.sfx.play("play") app.sfx.play("play")
app.save() app.save()
var notes: Array[String] = app.profile.take_notices() var notes: Array[String] = app.profile.take_notices()
say("%s hergestellt" % selected.name + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true) say(tr("%s hergestellt") % selected.title() + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
else: else:
say(err, false) say(err, false)
refresh() refresh()
@@ -138,8 +139,8 @@ func refresh() -> void:
preview_holder.add_child(v) preview_holder.add_child(v)
var owned: int = p.collection.get(selected.id, 0) var owned: int = p.collection.get(selected.id, 0)
var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0}) var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0})
info_label.text = "%s · %s\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [ info_label.text = "%s · %s · Wert %.1f\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [
selected.combo(), selected.rarity, owned, st.played, st.games, st.wins] selected.combo(), selected.rarity, Cards.card_value(selected), owned, st.played, st.games, st.wins]
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity] b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity]
b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity] b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity]
b_dismantle.disabled = owned == 0 b_dismantle.disabled = owned == 0

49
tests/frame_probe.gd Normal file
View File

@@ -0,0 +1,49 @@
extends Node
## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen (5 Gegner).
var main: Node
var max_dt := 0.0
var frames_n := 0
var measuring := false
func _ready() -> void:
Game.test_seed = 4242
main = load("res://scenes/main.tscn").instantiate()
main.save_path = "user://_probe.json"
add_child(main)
await get_tree().process_frame
await get_tree().process_frame
var scr: BattleScreen = BattleScreen.new(5)
main.show_screen(scr)
await get_tree().process_frame
await get_tree().process_frame
var h: Game.Player = scr.game.human
while h.hand.size() < 8 and not h.deck.is_empty():
scr.game.draw(h)
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))
scr.mulligan.visible = false
h.mulligan_done = true
scr.ai_delay = 0.2
measuring = true
var start := Time.get_ticks_msec()
var actions := 0
var last_turn := scr.game.turn
while Time.get_ticks_msec() - start < 12000 and scr.game.winner == "":
if scr.game.current().is_human and scr.game.current() == h:
scr.game.end_turn()
scr.after_action()
await get_tree().process_frame
measuring = false
print("längste Bildzeit in 12 s KI-Spiel: %.0f ms (%d Bilder, Zug %d)" % [max_dt * 1000.0, frames_n, scr.game.turn])
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://_probe.json"))
get_tree().quit()
func _process(delta: float) -> void:
if measuring:
frames_n += 1
max_dt = maxf(max_dt, delta)

1
tests/frame_probe.gd.uid Normal file
View File

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

6
tests/frame_probe.tscn Normal file
View File

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

29
tests/lint.gd Normal file
View File

@@ -0,0 +1,29 @@
extends SceneTree
## Statische Prüfung: lädt jedes Skript; Parse- oder Kompilierfehler lassen den Lauf scheitern.
## godot --headless --path . -s tests/lint.gd
func scan(dir: String, out: Array) -> void:
var d := DirAccess.open(dir)
if d == null:
return
for sub in d.get_directories():
if not sub.begins_with("."):
scan(dir.path_join(sub), out)
for f in d.get_files():
if f.ends_with(".gd"):
out.append(dir.path_join(f))
func _initialize() -> void:
var files := []
for dir in ["res://scripts", "res://tests"]:
scan(dir, files)
var bad := 0
for path in files:
var script = load(path)
if script == null or not (script is GDScript):
printerr("FEHLER: %s lädt nicht" % path)
bad += 1
print("%d Skripte geprüft, %d fehlerhaft" % [files.size(), bad])
quit(1 if bad > 0 else 0)

1
tests/lint.gd.uid Normal file
View File

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

View File

@@ -1,11 +1,14 @@
extends Node extends Node
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge. ## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge; mit --host eröffnet er
## selbst und startet, sobald ein zweiter Mensch am Tisch sitzt (ohne KI).
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot ## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
## godot --path . tests/net_bot.tscn -- --host --name=BotHost
var net: Net var net: Net
var seat := -1 var seat := -1
var game: Game var game: Game
var t := 0.0 var t := 0.0
var hosting := false
func _ready() -> void: func _ready() -> void:
@@ -16,20 +19,37 @@ func _ready() -> void:
ip = a.substr(5) ip = a.substr(5)
if a.begins_with("--name="): if a.begins_with("--name="):
bot_name = a.substr(7) bot_name = a.substr(7)
if a == "--host":
hosting = true
net = Net.new() net = Net.new()
net.name = "Net" net.name = "Net"
get_tree().root.add_child.call_deferred(net) get_tree().root.add_child.call_deferred(net)
net.my_name = bot_name net.my_name = bot_name
net.my_deck = Profile.starter().deck.duplicate() net.my_deck = Profile.starter().deck.duplicate()
net.started.connect(func(s): seat = s) net.started.connect(func(s):
seat = s
if hosting:
net.broadcast() # erster Zustand für alle, wie es sonst der Kampfbildschirm tut
_act_host.call_deferred())
net.snapshot_received.connect(_on_snapshot) net.snapshot_received.connect(_on_snapshot)
net.ended.connect(func(_r): get_tree().quit()) net.ended.connect(func(_r): get_tree().quit())
await get_tree().process_frame await get_tree().process_frame
if hosting:
net.host()
net.lobby_changed.connect(func():
if net.game == null and net.names.size() >= 2:
net.start_battle(0))
net.action_applied.connect(func():
net.broadcast()
_act_host.call_deferred())
else:
net.join(ip) net.join(ip)
func _process(delta: float) -> void: func _process(delta: float) -> void:
t += delta t += delta
if hosting and net.game != null:
net.tick()
if t > 180.0 or FileAccess.file_exists("user://bot_quit"): if t > 180.0 or FileAccess.file_exists("user://bot_quit"):
net.leave() net.leave()
get_tree().quit() get_tree().quit()
@@ -43,7 +63,23 @@ func _on_snapshot(snap: Dictionary) -> void:
return return
if game.current_idx != seat: if game.current_idx != seat:
return return
var me: Game.Player = game.players[seat] _decide(game.players[seat])
func _act_host() -> void:
if not hosting or net.game == null or net.game.winner != "" or seat < 0 or net.game.current_idx != seat:
return
game = net.game
_decide(game.players[seat])
## Einfache Züge mit kurzer Bedenkzeit: Mulligan überspringen, erstes spielbares Wesen ohne Ziel,
## dann angreifen, sonst Zug beenden.
func _decide(me: Game.Player) -> void:
var g := game
await get_tree().create_timer(0.25).timeout
if game != g or (hosting and (net.game == null or net.game.current_idx != seat)):
return # inzwischen ein neuer Zustand
if not me.mulligan_done and me.turns == 1: if not me.mulligan_done and me.turns == 1:
net.send_action({"type": "mulligan", "hand": []}) net.send_action({"type": "mulligan", "hand": []})
return return

50
tests/perf_probe.gd Normal file
View File

@@ -0,0 +1,50 @@
extends SceneTree
## Laufzeitmessung: Start, KI-Entscheidungen, Deckerzeugung, Vorschau, Musik.
func ms(f: Callable, n: int = 1) -> float:
var t0 := Time.get_ticks_usec()
for i in n:
f.call()
return (Time.get_ticks_usec() - t0) / 1000.0 / n
func _initialize() -> void:
var t := ms(func(): Cards.all())
print("Kartenpool laden: %.1f ms" % t)
var r := RandomNumberGenerator.new()
r.seed = 3
var starter := Profile.starter().deck_cards()
print("Gegnerdeck (Zielstärke): %.1f ms" % ms(func(): DeckGen.build_opponent_deck_rated(r, ["Feuer", "Erde"], 1.5), 10))
print("Vorschau 1..5 Gegner: %.1f ms" % ms(func():
for n in range(1, 6):
for i in n:
Game.random_opponent(r, i + 1, Game.START_LIFE, 1.3, "")))
for n in [1, 3, 5]:
var g := Game.new_battle(starter, n, r, {"target_rating": 1.5})
# ein volles Brett für alle, damit die Entscheidung teuer wird
var uid := 5000
for p in g.players:
p.mulligan_done = true
for k in 4:
var c := Game.Creature.new(Cards.by_id("f_wolf"), p, uid)
uid += 1
c.ready = true
c.attacks_left = 1
p.board.append(c)
var h: Game.Player = g.players[0]
h.energy = 10
h.max_energy = 10
for k in 6:
h.hand.append(Cards.by_id("f_funke"))
AI.time_budget_ms = 100000
var t_full := ms(func(): AI.choose_action(g, h))
AI.time_budget_ms = 150
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(starter, 5, r).clone(), 20))
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
print("Deckstärke berechnen: %.3f ms" % ms(func(): Game.deck_rating(starter), 100))
print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(starter, 5, r).snapshot(0), 10))
quit()

1
tests/perf_probe.gd.uid Normal file
View File

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

View File

@@ -1,8 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Führt Import und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary] # Führt Import, statische Prüfung und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
set -euo pipefail set -euo pipefail
GODOT="${1:-godot}" GODOT="${1:-godot}"
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
"$GODOT" --headless --path . --import > /dev/null 2>&1 || true "$GODOT" --headless --path . --import > /dev/null 2>&1 || true
"$GODOT" --headless --path . -s tests/lint.gd 2>&1 | grep -E "FEHLER|Skripte" || true
"$GODOT" --headless --path . -s tests/lint.gd > /dev/null 2>&1
"$GODOT" --headless --path . -s tests/test_runner.gd 2>&1 | tee /tmp/dinger_tests.log | grep -E "FEHLER|Prüfungen|SCRIPT ERROR" || true "$GODOT" --headless --path . -s tests/test_runner.gd 2>&1 | tee /tmp/dinger_tests.log | grep -E "FEHLER|Prüfungen|SCRIPT ERROR" || true
grep -q " 0 fehlgeschlagen" /tmp/dinger_tests.log grep -q " 0 fehlgeschlagen" /tmp/dinger_tests.log

41
tests/strength_probe.gd Normal file
View File

@@ -0,0 +1,41 @@
extends SceneTree
## Archetyp-Stärke: jede Elementkombination gegen zufällige andere, gleiche Zielstärke, beide KI.
func archetypes() -> Array:
var out := []
for e in Cards.ELEMENTS:
out.append([e])
for i in Cards.ELEMENTS.size():
for j in range(i + 1, Cards.ELEMENTS.size()):
out.append([Cards.ELEMENTS[i], Cards.ELEMENTS[j]])
return out
func _initialize() -> void:
var r := RandomNumberGenerator.new()
r.seed = 21
var arts := archetypes()
var n := int(OS.get_environment("N")) if OS.get_environment("N") != "" else 60
var results := {}
for a in arts:
var wins := 0
for i in n:
var b: Array = arts[r.randi_range(0, arts.size() - 1)]
var deck_a := Game.build_opponent_deck_rated(r, a, 1.6)
var deck_b := Game.build_opponent_deck_rated(r, b, 1.6)
var opp := Game.Player.new("Gegner 1", deck_b, false, Game.START_LIFE, "x")
var g := Game.new_battle_with(deck_a, [opp], r)
var guard := 0
while g.winner == "" and guard < 300:
AI.take_turn(g, g.current())
guard += 1
if g.winner == "human":
wins += 1
results["+".join(a)] = wins * 100 / n
print("%-14s %3d%%" % ["+".join(a), wins * 100 / n])
var f := FileAccess.open(DeckGen.ARCHETYPE_PATH, FileAccess.WRITE)
f.store_string(JSON.stringify(results, "\t"))
f.close()
print("nach %s geschrieben" % DeckGen.ARCHETYPE_PATH)
quit()

View File

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

View File

@@ -55,7 +55,11 @@ func put(p: Game.Player, cid: String, ready: bool = true) -> Game.Creature:
func _initialize() -> void: func _initialize() -> void:
TranslationServer.set_locale("de") # das Spiel setzt die Sprache aus den Einstellungen; hier fehlt der Einstieg
test_kartenpool() test_kartenpool()
test_kampf_speichern()
test_elementbooster_und_deckcode()
test_englische_karten()
test_kombos() test_kombos()
test_wesen_spielen() test_wesen_spielen()
test_flink() test_flink()
@@ -106,12 +110,18 @@ func _initialize() -> void:
test_neue_fallen() test_neue_fallen()
test_kapitel_zwei_und_merkmalwahl() test_kapitel_zwei_und_merkmalwahl()
test_herausforderung_und_bestenliste() test_herausforderung_und_bestenliste()
test_deckstaerke_und_schwierigkeit()
test_starter_gegen_passende_gegner()
test_kurve_statistik_vorschlag_verbesserung()
test_wasser_antworten()
test_ki_zeitbudget()
test_uebersetzung_vollstaendig()
print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed]) print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed])
quit(1 if failed > 0 else 0) quit(1 if failed > 0 else 0)
func test_kartenpool() -> void: func test_kartenpool() -> void:
check(Cards.all().size() == 106, "106 Karten") check(Cards.all().size() == 118, "118 Karten")
for key in Cards.COMBOS: for key in Cards.COMBOS:
var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key) var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key)
check(found, "Kombo vorhanden: " + key) check(found, "Kombo vorhanden: " + key)
@@ -456,7 +466,7 @@ func test_spielstand() -> void:
p.coins = 999 p.coins = 999
p.save(path) p.save(path)
var p2 := Profile.load_or_new(path) var p2 := Profile.load_or_new(path)
check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == 39, "geladen") check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == Cards.of_rarity("gewöhnlich").size() + 2, "geladen")
for suffix in ["", ".bak"]: for suffix in ["", ".bak"]:
if FileAccess.file_exists(path + suffix): if FileAccess.file_exists(path + suffix):
DirAccess.remove_absolute(abs + suffix) DirAccess.remove_absolute(abs + suffix)
@@ -508,7 +518,7 @@ func test_resonanz() -> void:
put(h, "f_kobold") put(h, "f_kobold")
put(h, "f_kobold") put(h, "f_kobold")
g.update_resonance() g.update_resonance()
check(h.board[0].power() == 7, "Resonanz höchstens +4") check(h.board[0].power() == 6 and Game.resonance_for(1) == 1 and Game.resonance_for(3) == 3 and Game.resonance_for(4) == 3, "Resonanz höchstens +3")
for i in [1, 3, 4, 5, 6]: for i in [1, 3, 4, 5, 6]:
h.board[i].health = 0 h.board[i].health = 0
g.cleanup() g.cleanup()
@@ -527,15 +537,16 @@ func test_feldkombos() -> void:
g.play_card(h, C("w_reiter")) g.play_card(h, C("w_reiter"))
check(strong.frozen and not weak.frozen, "Dampf friert das stärkste Wesen") check(strong.frozen and not weak.frozen, "Dampf friert das stärkste Wesen")
check(g.log.any(func(l): return l.begins_with("Feldkombo Dampf")), "Feldkombo im Verlauf") check(g.log.any(func(l): return l.begins_with("Feldkombo Dampf")), "Feldkombo im Verlauf")
# Lava: 1 Schaden an alle gegnerischen Wesen # Lava: 1 Schaden an das stärkste gegnerische Wesen
s = setup(["e_golem"]) s = setup(["e_golem"])
g = s[0] g = s[0]
h = s[1] h = s[1]
o = s[2][0] o = s[2][0]
put(h, "f_wolf") put(h, "f_wolf")
var k := put(o, "f_kobold") var k := put(o, "f_kobold")
var big := put(o, "e_troll")
g.play_card(h, C("e_golem")) g.play_card(h, C("e_golem"))
check(not (k in o.board), "Lava tötet den 2/1 Kobold") check(k in o.board and big.health == 5, "Lava trifft nur das stärkste Wesen")
# Blitz: 2 Schaden an den schwächsten Gegner # Blitz: 2 Schaden an den schwächsten Gegner
s = setup(["l_wind"], [], 2) s = setup(["l_wind"], [], 2)
g = s[0] g = s[0]
@@ -544,7 +555,7 @@ func test_feldkombos() -> void:
s[2][1].life = 5 s[2][1].life = 5
g.play_card(h, C("l_wind")) 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][1].life == 3 and s[2][0].life == 15, "Blitz trifft den schwächsten Gegner")
# Schlamm heilt, Sturm zieht, Sand gibt Schild # Schlamm heilt, Sturm zieht, Sand gibt +1/+1
s = setup(["e_wall", "l_wind", "w_reiter"]) s = setup(["e_wall", "l_wind", "w_reiter"])
g = s[0] g = s[0]
h = s[1] h = s[1]
@@ -555,13 +566,13 @@ func test_feldkombos() -> void:
var hand_before := h.hand.size() var hand_before := h.hand.size()
g.play_card(h, C("l_wind")) g.play_card(h, C("l_wind"))
check(h.hand.size() == hand_before, "Sturm zieht 1 (eine gespielt, eine gezogen)") check(h.hand.size() == hand_before, "Sturm zieht 1 (eine gespielt, eine gezogen)")
check(not h.board[2].shield, "nur eine Feldkombo je Ausspielen") check(h.board[2].attack == 1, "nur eine Feldkombo je Ausspielen")
s = setup(["l_wind"]) s = setup(["l_wind"])
g = s[0] g = s[0]
h = s[1] h = s[1]
put(h, "e_wall") put(h, "e_wall")
g.play_card(h, C("l_wind")) g.play_card(h, C("l_wind"))
check(h.board[1].shield, "Sand: neues Wesen hat Schild") check(h.board[1].attack == 2 and h.board[1].health == 2, "Sand: neues Wesen erhält +1/+1")
# Gleiche Elemente lösen keine Feldkombo aus # Gleiche Elemente lösen keine Feldkombo aus
s = setup(["f_kobold"]) s = setup(["f_kobold"])
g = s[0] g = s[0]
@@ -729,7 +740,7 @@ func test_fallen() -> void:
g.end_turn() g.end_turn()
var a := put(o, "e_troll") var a := put(o, "e_troll")
check(g.attack(a, h) == "", "Angriff in die Feuerfalle") check(g.attack(a, h) == "", "Angriff in die Feuerfalle")
check(h.traps.is_empty() and a.health == 3 and h.life == 26, "Feuerfalle: 3 Schaden, Angriff geht durch") check(h.traps.is_empty() and a.health == 3 and h.life == 27, "Feuerfalle: 3 Schaden, Angriff geht durch")
# Frostfalle bricht den Angriff ab # Frostfalle bricht den Angriff ab
s = setup(["w_falle"]) s = setup(["w_falle"])
g = s[0] g = s[0]
@@ -1082,7 +1093,7 @@ func test_deck_umbenennen_und_auffuellen() -> void:
func test_neue_fallen() -> void: func test_neue_fallen() -> void:
check(Cards.all().filter(func(c): return c.is_trap()).size() == 10, "zehn Fallen") check(Cards.all().filter(func(c): return c.is_trap()).size() == 12, "zwölf Fallen")
check(C("e_sandfalle").text() == "Falle. Wenn ein Gegner ein Wesen ausspielt: Es wird eingefroren.", "Fallentext je Auslöser") check(C("e_sandfalle").text() == "Falle. Wenn ein Gegner ein Wesen ausspielt: Es wird eingefroren.", "Fallentext je Auslöser")
check(C("f_aschefalle").text().ends_with("3 Schaden an dieses Wesen."), "Aschefalle-Text") check(C("f_aschefalle").text().ends_with("3 Schaden an dieses Wesen."), "Aschefalle-Text")
# Böenfalle: Angreifer zurück auf die Hand, Angriff verpufft # Böenfalle: Angreifer zurück auf die Hand, Angriff verpufft
@@ -1105,7 +1116,7 @@ func test_neue_fallen() -> void:
g.play_card(o, C("f_wolf")) g.play_card(o, C("f_wolf"))
check(o.board.is_empty(), "Aschefalle tötet den 3/2 Wolf beim Ausspielen") check(o.board.is_empty(), "Aschefalle tötet den 3/2 Wolf beim Ausspielen")
# Sandfalle friert das ausgespielte Wesen ein, Spiegelfalle beschwört einen Windgeist # Sandfalle friert das ausgespielte Wesen ein, Spiegelfalle beschwört einen Windgeist
s = setup(["e_sandfalle", "l_spiegel"], ["e_troll"]) s = setup(["e_sandfalle", "l_spiegel"], ["e_troll", "w_reiter"])
g = s[0] g = s[0]
h = s[1] h = s[1]
o = s[2][0] o = s[2][0]
@@ -1113,8 +1124,9 @@ func test_neue_fallen() -> void:
g.play_card(h, C("l_spiegel")) g.play_card(h, C("l_spiegel"))
g.end_turn() g.end_turn()
g.play_card(o, C("e_troll")) g.play_card(o, C("e_troll"))
check(o.board.size() == 1 and o.board[0].frozen, "Sandfalle friert ein") check(o.board.size() == 1 and o.board[0].frozen and h.traps.size() == 1, "Sandfalle friert ein, eine Falle je Auslöser")
check(h.board.size() == 1 and h.board[0].name == "Windgeist", "Spiegelfalle beschwört Windgeist") g.play_card(o, C("w_reiter")) # Wasser neben Erde: Schlamm heilt nur
check(h.board.size() == 1 and h.board[0].name == "Windgeist", "Spiegelfalle beschwört Windgeist beim zweiten Wesen")
check(h.traps.is_empty(), "beide Fallen verbraucht") check(h.traps.is_empty(), "beide Fallen verbraucht")
# Nebelfalle bricht ab und zieht, Dornenfalle verletzt und heilt # Nebelfalle bricht ab und zieht, Dornenfalle verletzt und heilt
s = setup(["w_nebelfalle", "e_dornen"]) s = setup(["w_nebelfalle", "e_dornen"])
@@ -1136,7 +1148,11 @@ func test_neue_fallen() -> void:
func test_kapitel_zwei_und_merkmalwahl() -> void: func test_kapitel_zwei_und_merkmalwahl() -> void:
check(Campaign.total() == 20 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2, "zwanzig Stufen in zwei Kapiteln") check(Campaign.total() == 30 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2 and Campaign.chapter(29) == 3, "dreißig Stufen in drei Kapiteln")
check(Campaign.stage(25).perks.size() == 3 and Campaign.stage(25).name == "Nyx" and Campaign.target_rating(29, 1.0) == 4.4, "Kapitel 3 mit drei Merkmalen und höherer Zielstärke")
for st in Campaign.STAGES3:
for pk in st.perks:
check(Game.PERKS.has(pk), "Merkmal %s bekannt" % pk)
for i in Campaign.total(): for i in Campaign.total():
var st := Campaign.stage(i) var st := Campaign.stage(i)
check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1)) check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1))
@@ -1196,3 +1212,268 @@ func test_herausforderung_und_bestenliste() -> void:
check(text.begins_with("karte;seltenheit;gespielt;partien;siege") and "beste_serie;5" in text, "CSV-Inhalt") check(text.begins_with("karte;seltenheit;gespielt;partien;siege") and "beste_serie;5" in text, "CSV-Inhalt")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
Profile.today_override = "" Profile.today_override = ""
func test_deckstaerke_und_schwierigkeit() -> void:
check(is_equal_approx(Cards.card_value(C("f_kobold")), 1.0) and is_equal_approx(Cards.card_value(C("ff_phoenix")), 4.6), "Kartenwert aus Seltenheit, Schlüsselwörtern, Effekten")
var starter := Profile.starter().deck_cards()
var rs := Game.deck_rating(starter)
check(rs > 1.2 and rs < 1.5, "Starter mit zwei seltenen Karten bei etwa 1,3 (%.2f)" % rs)
check(Profile.starter().deck_valid()[0] and starter.any(func(c): return c.id == "f_drache") and starter.any(func(c): return c.id == "e_baum"), "Feuerdrache und Uralter Baum im Starterdeck")
var r := RandomNumberGenerator.new()
r.seed = 5
for target in [1.2, 1.6, 2.2, 2.6]:
for elems in [["Feuer"], ["Wasser", "Luft"]]:
var d := Game.build_opponent_deck_rated(r, elems, target)
check(Game.validate_deck(d)[0] and absf(Game.deck_rating(d) - target) <= 0.25, "Zielstärke %.1f für %s erreicht (%.2f)" % [target, "+".join(elems), Game.deck_rating(d)])
var strong := Game.build_opponent_deck_rated(r, ["Feuer", "Luft"], 3.0)
check(Game.deck_rating(strong) >= 2.7, "Zielstärke 3,0 mit zwei Elementen nahezu erreichbar (%.2f)" % Game.deck_rating(strong))
var g := Game.new_battle(starter, 2, r, {"target_rating": 1.3})
for o in g.opponents:
check(absf(Game.deck_rating(o.deck + o.hand) - 1.3) <= 0.3, "Gegner auf Zielstärke (%.2f)" % Game.deck_rating(o.deck + o.hand))
check(is_equal_approx(Campaign.target_rating(0, 1.3), 1.3) and is_equal_approx(Campaign.target_rating(0, 2.5), 2.5) and is_equal_approx(Campaign.target_rating(9, 1.3), 2.6), "Kampagne: Stufe oder eigene Stärke, was höher ist")
var opps := Campaign.opponents(2, r, 1.3)
check(opps.size() == 2 and absf(Game.deck_rating(opps[0].deck) - 1.5) <= 0.25 and absf(Game.deck_rating(opps[1].deck) - 1.5) <= 0.3, "Borin und Gefolge auf 1,5")
var p := Profile.starter()
check(p.difficulty().name == "Normal" and is_equal_approx(p.target_rating(), rs), "Normal: Zielstärke gleich eigener Stärke")
p.settings.difficulty = "schwer"
check(is_equal_approx(p.target_rating(), rs * 1.2) and p.difficulty().smart, "Schwer: 1,2-fach mit Vorausschau-KI")
p.settings.difficulty = "leicht"
check(not p.difficulty().smart and is_equal_approx(p.target_rating(), rs * 0.9), "Leicht: einfache KI, 0,9-fach")
var reward := p.record_battle(true, 1, starter, -1, [], 5, {}, p.difficulty().reward)
check(reward == int(round(Profile.REWARD_WIN[1] * 0.75)), "Leicht: weniger Münzen")
var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict())))
check(v.settings.difficulty == "leicht", "Schwierigkeit gespeichert")
v.settings.difficulty = "unsinn"
check(v.difficulty().name == "Normal", "unbekannte Schwierigkeit fällt auf Normal zurück")
func test_starter_gegen_passende_gegner() -> void:
check(Game.archetypes("leicht").size() == 5 and Game.archetypes("schwer").size() == 5 and Game.archetypes().size() == 10, "Archetypen geteilt")
var table: Dictionary = DeckGen.strength()
var keys: Array = table.keys()
keys.sort_custom(func(a, b): return table[a] < table[b])
check(keys[0] in Game.archetypes("leicht") and keys[-1] in Game.archetypes("schwer"), "schwächster Archetyp in Leicht, stärkster in Schwer")
check(Game.archetypes("leicht").all(func(k): return table[k] <= table[Game.archetypes("schwer")[0]]), "Hälften nach Stärke getrennt")
var r := RandomNumberGenerator.new()
r.seed = 6
var starter := Profile.starter().deck_cards()
var rs := Game.deck_rating(starter)
var results := {}
for key in ["leicht", "schwer"]:
var d: Dictionary = Profile.DIFFICULTIES[key]
var wins := 0
var games := 24
for i in games:
var g := Game.new_battle(starter, 1, r, {"target_rating": rs * float(d.factor), "tier": d.tier})
var arch := Cards.combo_key(Game.deck_elements(g.opponents[0].deck + g.opponents[0].hand))
check(arch in Game.archetypes(d.tier), "Archetyp passt zur Stufe (%s)" % arch)
var guard := 0
while g.winner == "" and guard < 300:
var p := g.current()
AI.take_turn(g, p, (not d.smart) and not p.is_human)
guard += 1
if g.winner == "human":
wins += 1
results[key] = wins
check(results.leicht > results.schwer, "Leicht ist leichter als Schwer (%d zu %d von 24)" % [results.leicht, results.schwer])
check(results.leicht >= 16, "Leicht: Starter gewinnt meistens (%d von 24)" % results.leicht)
func test_kurve_statistik_vorschlag_verbesserung() -> void:
var starter := Profile.starter().deck_cards()
check(is_zero_approx(DeckGen.curve_penalty(starter)), "Starterkurve ohne Abzug")
var teuer := []
for i in 20:
teuer.append(Cards.by_id("f_zunge"))
check(DeckGen.curve_penalty(teuer) >= 0.4 - 0.001 and Game.deck_rating(teuer) < 1.0, "teures Deck: Kurvenabzug")
var stats := {"f_kobold": {"played": 30, "games": 20, "wins": 16}}
check(DeckGen.card_value(Cards.by_id("f_kobold"), stats) > Cards.card_value(Cards.by_id("f_kobold")), "gute Statistik hebt den Wert")
check(Game.deck_rating(starter, stats) > Game.deck_rating(starter), "Deckstärke mit Statistik")
var few := {"f_kobold": {"played": 3, "games": 3, "wins": 3}}
check(is_equal_approx(DeckGen.card_value(Cards.by_id("f_kobold"), few), 1.0), "zu wenig Partien: Basiswert")
# Vorschlag nach Serien
var p := Profile.starter()
var mono := starter
check(p.suggest_difficulty() == "", "kein Vorschlag am Anfang")
for i in 3:
p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 4)
check(p.suggest_difficulty() == "", "drei knappe Siege: noch kein Vorschlag")
p.records.streak = 0
p.records.margins = []
for i in 3:
p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 20)
check(p.suggest_difficulty() == "schwer" and p.notices.any(func(n): return n.begins_with("Tipp: 3 Siege")), "drei deutliche Siege: Schwer vorschlagen")
p.records.streak = 0
p.records.margins = []
for i in 5:
p.record_battle(true, 1, mono, -1, [], 6, {}, 1.0, 2)
check(p.suggest_difficulty() == "schwer" and p.records.margins.size() == 5, "fünf Siege egal wie knapp")
p.settings.difficulty = "schwer"
p.take_notices()
for i in 3:
p.record_battle(false, 1, mono)
check(p.records.loss_streak == 3 and p.suggest_difficulty() == "normal", "drei Niederlagen: Normal vorschlagen")
p.settings.difficulty = "leicht"
check(p.suggest_difficulty() == "", "auf Leicht kein Abstieg")
# Deck verbessern
var q := Profile.starter()
check(q.improve_deck_with("ff_phoenix", true) != "", "nicht besessen")
q.collection["ff_phoenix"] = 1
check(q.improve_deck_with("ff_phoenix", true) == "", "Phönix passt (Feuer)")
q.collection["ww_leviathan"] = 1
check(q.improve_deck_with("ww_leviathan", true) != "", "Leviathan passt nicht (drittes Element)")
check(q.deck_suggestions(["ff_phoenix", "ww_leviathan", "f_kobold"]) == ["ff_phoenix"], "Vorschläge gefiltert")
var before := q.deck.duplicate()
check(q.improve_deck_with("ff_phoenix") == "" and q.deck.count("ff_phoenix") == 1 and q.deck.size() == 20 and q.deck_valid()[0], "Phönix eingetauscht")
check(Game.deck_rating(q.deck_cards()) > Game.deck_rating(before.map(func(id): return Cards.by_id(id))), "Deck stärker")
check(q.improve_deck_with("ff_phoenix", true) != "", "zweite Kopie nicht möglich")
func test_wasser_antworten() -> void:
var s := setup(["w_ebbe", "w_tiefenstoss", "w_strudel"])
var g: Game = s[0]
var h: Game.Player = s[1]
var o: Game.Player = s[2][0]
var t := put(o, "e_troll")
g.play_card(h, C("w_ebbe"), t)
check(t.health == 5 and t.frozen, "Ebbe: 1 Schaden und Eis")
g.play_card(h, C("w_tiefenstoss"), t)
check(t.health == 2, "Tiefenstoß: 3 Schaden")
var hand := h.hand.size()
g.play_card(h, C("w_strudel"), t)
check(o.board.is_empty() and C("e_troll") in o.hand and h.hand.size() == hand, "Strudel: zurück auf die Hand, eine Karte gezogen")
check(C("w_ebbe").rarity == "gewöhnlich" and Profile.starter().collection.has("w_ebbe"), "Ebbe in der Startsammlung")
func test_ki_zeitbudget() -> void:
var r := RandomNumberGenerator.new()
r.seed = 12
var g := Game.new_battle(Profile.starter().deck_cards(), 5, r)
var h: Game.Player = g.players[0]
h.mulligan_done = true
h.energy = 10
h.max_energy = 10
for i in 8:
h.hand.append(Cards.by_id("f_funke"))
for p in g.players:
for i in 3:
put(p, "e_troll")
AI.time_budget_ms = 1
var t0 := Time.get_ticks_msec()
var act := AI.choose_action(g, h)
var ms := Time.get_ticks_msec() - t0
AI.time_budget_ms = 150
check(act.type in ["play", "attack", "end"] and ms < 80, "Zeitbudget greift (%d ms)" % ms)
check(Cards.by_id("wl_gischt").keywords == ["Flink"] and Cards.by_id("wl_gischt").combo() == "Sturm", "Gischtläufer für Sturm")
var v := Profile.from_dict(JSON.parse_string(JSON.stringify(Profile.starter().to_dict())))
check(v.settings.has("big_font") and not v.tutorial2_done and v.records.margins is Array, "Version 5 mit neuen Feldern")
## Elementbooster enthalten nur Karten des Elements; Deck-Codes sind umkehrbar und robust.
## Englisch: jede Karte hat einen englischen Namen, Kartentexte und Schlüsselwörter werden übersetzt.
func test_englische_karten() -> void:
check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen")
var de: String = Cards.by_id("f_funke").text()
TranslationServer.set_locale("en")
var funke := Cards.by_id("f_funke")
var falle := Cards.by_id("w_falle")
var wall := Cards.by_id("e_wall")
check(funke.title() == "Spark" and funke.text() == "2 damage to a target.", "Funke auf Englisch: %s / %s" % [funke.title(), funke.text()])
check(falle.text().begins_with("Trap. When an enemy creature attacks") and "The attack fizzles" in falle.text(), "Fallentext auf Englisch: %s" % falle.text())
check(wall.text() == "Guardian." and Cards.by_id("l_spiegel").text().ends_with("Summon a Wind Spirit."), "Schlüsselwort und Beschwörung auf Englisch")
TranslationServer.set_locale("de")
check(funke.title() == "Funke" and funke.text() == de, "zurück auf Deutsch")
func test_elementbooster_und_deckcode() -> void:
var p := Profile.starter()
p.coins = 500
var r := RandomNumberGenerator.new()
r.seed = 5
var got: Array = p.open_booster(r, "Wasser")
check(got.size() == 5 and got.all(func(c): return "Wasser" in c.elements) and p.coins == 500 - Profile.ELEMENT_BOOSTER_COST, "Wasser-Booster: fünf Wasserkarten, %d Münzen" % Profile.ELEMENT_BOOSTER_COST)
p.coins = 100
check(p.open_booster(r, "Feuer").is_empty() and p.coins == 100, "Elementbooster braucht %d Münzen" % Profile.ELEMENT_BOOSTER_COST)
for e in Cards.ELEMENTS:
for rar in Profile.RARITY_WEIGHTS:
check(not Profile.booster_pool(rar, e).is_empty(), "%s hat %s Karten" % [e, rar])
var deck: Array = Profile.starter().deck
var code := Profile.deck_code(deck)
check(code.begins_with(Profile.CODE_PREFIX) and Profile.parse_deck_code(code).size() == 20, "Deck-Code hin und zurück")
var back: Array = Profile.parse_deck_code(code)
back.sort()
var orig: Array = deck.duplicate()
orig.sort()
check(back == orig, "Deck-Code enthält genau die Karten")
check(Profile.parse_deck_code("kaputt").is_empty() and Profile.parse_deck_code("DINGER1:x_nix*2").is_empty()
and Profile.parse_deck_code("DINGER1:f_funke*9").size() == Game.MAX_COPIES, "ungültige Codes leer, Kopien gedeckelt")
check(Cards.by_id("w_flutfalle").is_trap() and Cards.by_id("fl_sturmfalke").combo() == "Blitz" and Cards.by_id("el_sandsturm").is_combo(), "neue Karten im Pool")
## Voller Spielstand (viewer -1) stellt Hände, Decks und Bretter wieder her; Zuschauer (-2) sehen nichts Verborgenes.
func test_kampf_speichern() -> void:
var r := RandomNumberGenerator.new()
r.seed = 7
var g := Game.new_battle(Profile.starter().deck_cards(), 2, r)
g.start()
AI.take_turn(g, g.current())
var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1)))
var g2 := Game.from_snapshot(d)
var same := g2.players.size() == g.players.size()
for i in g.players.size():
var a: Game.Player = g.players[i]
var b: Game.Player = g2.players[i]
var ids := func(cs: Array): return cs.map(func(c): return c.id)
same = same and ids.call(a.hand) == ids.call(b.hand) and ids.call(a.deck) == ids.call(b.deck) \
and a.life == b.life and a.board.size() == b.board.size() and a.is_human == b.is_human
check(same and g2.current_idx == g.current_idx and g2.turn == g.turn and g2.human.is_human, "voller Spielstand: Hände, Decks und Bretter erhalten")
var guard := 0
while g2.winner == "" and guard < 300:
AI.take_turn(g2, g2.current())
guard += 1
check(g2.winner != "", "fortgesetzte Partie ist bis zum Ende spielbar")
var s2: Dictionary = g.snapshot(-2)
check(s2.players.all(func(p): return not (p.hand is Array) and not (p.traps is Array) and not (p.deck is Array)), "Zuschauer-Sicht ohne Hände, Decks und Fallen")
## Jeder tr("...")-Text in der Oberfläche und jeder Anleitungsschritt hat einen englischen Eintrag.
func test_uebersetzung_vollstaendig() -> void:
var keys := {}
var csv := FileAccess.get_file_as_string("res://translations/ui.csv")
for line in csv.split("\n"):
if line.is_empty() or line.begins_with("keys,"):
continue
var key := line
if line.begins_with("\""):
key = line.substr(1, line.find("\",", 1) - 1)
else:
key = line.substr(0, line.find(","))
keys[key] = true
var missing := []
for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + ["Wesen", "Zauber", "Falle"] + Cards.TRIGGER_TEXT.values():
if not keys.has(k):
missing.append(k)
var d := DirAccess.open("res://scripts/ui")
d.list_dir_begin()
var name := d.get_next()
while name != "":
if name.ends_with(".gd"):
var src := FileAccess.get_file_as_string("res://scripts/ui/" + name)
var rx := RegEx.new()
rx.compile("tr\\(\"([^\"]+)\"\\)")
for m in rx.search_all(src):
var k: String = m.get_string(1)
if not keys.has(k) and not (k in missing):
missing.append(k)
name = d.get_next()
var rx_cards := RegEx.new()
rx_cards.compile("tr\\(\"([^\"]+)\"\\)")
for m in rx_cards.search_all(FileAccess.get_file_as_string("res://scripts/cards.gd")):
if not keys.has(m.get_string(1)) and not (m.get_string(1) in missing):
missing.append(m.get_string(1))
for stepset in [BattleTutorial.STEPS1, BattleTutorial.STEPS2]:
for st in stepset:
if not keys.has(st[0]):
missing.append(st[0])
check(missing.is_empty(), "fehlende Übersetzungen: %s" % [missing])

View File

@@ -74,6 +74,14 @@ func key(code: int) -> void:
await frames(2) await frames(2)
func pad(button: int) -> void:
var ev := InputEventJoypadButton.new()
ev.button_index = button
ev.pressed = true
get_viewport().push_input(ev)
await frames(2)
## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist. ## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist.
func clear_mulligan(scr) -> void: func clear_mulligan(scr) -> void:
if scr.mulligan_box.visible: if scr.mulligan_box.visible:
@@ -149,13 +157,23 @@ func _ready() -> void:
for suffix in ["", ".bak", ".corrupt"]: for suffix in ["", ".bak", ".corrupt"]:
if FileAccess.file_exists(SAVE + suffix): if FileAccess.file_exists(SAVE + suffix):
DirAccess.remove_absolute(abs + 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 = load("res://scenes/main.tscn").instantiate()
main.save_path = SAVE main.save_path = SAVE
add_child(main) add_child(main)
await frames(3) await frames(3)
for t in [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen, var fast := OS.get_environment("DINGER_FAST") != ""
test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_hotseat, var suite := [test_menu_und_regeln, test_gamepad_menue, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen,
test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]: test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_anleitung2, test_herausforderung,
test_kampf_fortsetzen, test_gamepad_deckbau, test_deckcode, test_elementbooster]
if not fast:
suite += [test_hotseat, test_netzwerk, test_host_wechsel, test_zuschauer, test_kampf.bind(2, 3), test_kampf_abbrechen]
suite.append(test_profil_zuruecksetzen)
var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_host_wechsel,test_zuschauer
if only != "":
suite = suite.filter(func(t: Callable): return t.get_method() in only.split(","))
for t in suite:
await ensure_menu() await ensure_menu()
await t.call() await t.call()
for suffix in ["", ".bak", ".corrupt"]: for suffix in ["", ".bak", ".corrupt"]:
@@ -196,6 +214,13 @@ func test_booster() -> void:
check(scr.revealed.size() == 5, "alle aufgedeckt") check(scr.revealed.size() == 5, "alle aufgedeckt")
await shot("04_booster_offen") await shot("04_booster_offen")
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert") check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert")
var ids: Array = scr.cards.map(func(c): return c.id)
var better: Array = main.profile.deck_suggestions(ids)
check(scr.b_improve.visible == (not better.is_empty()), "Deck-Vorschlag nur bei passender Karte")
if scr.b_improve.visible:
var deck_before: Array = main.profile.deck.duplicate()
await click(scr.b_improve)
check(main.profile.deck != deck_before and main.profile.deck_valid()[0] and "im Deck" in scr.msg_label.text, "Karte ins Deck übernommen")
check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand") check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand")
await click(find_button("Zurück")) await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
@@ -206,6 +231,7 @@ func test_deckbau() -> void:
var scr = screen() var scr = screen()
check(scr is DeckScreen, "Deckbildschirm") check(scr is DeckScreen, "Deckbildschirm")
check(scr.deck.size() == 20, "Deck geladen") check(scr.deck.size() == 20, "Deck geladen")
check("Stärke" in scr.elems_label.text, "Deckstärke im Deckbau")
await shot("05_deck") await shot("05_deck")
await click(find_button("Leeren")) await click(find_button("Leeren"))
check(scr.deck.is_empty(), "geleert") check(scr.deck.is_empty(), "geleert")
@@ -279,7 +305,14 @@ func test_deckbau() -> void:
await click(find_button("Auto-Ausfüllen")) await click(find_button("Auto-Ausfüllen"))
check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar") check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar")
await shot("25_deck_slots") await shot("25_deck_slots")
await click(scr.deck_list.get_children()[0]) # ungespeicherte Änderung: eine Karte entfernt
check(scr.dirty(), "Deck geändert")
await click(find_button("Zurück")) await click(find_button("Zurück"))
check(scr.confirm.visible and screen() == scr, "Warnung bei ungespeicherten Änderungen")
await click(find_button("Weiter bearbeiten"))
check(not scr.confirm.visible, "weiter bearbeiten")
await click(find_button("Zurück"))
await click(find_button("Verwerfen"))
check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen") check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen")
@@ -345,8 +378,43 @@ func start_battle(n_opp: int) -> void:
func test_gegnerauswahl() -> void: func test_gegnerauswahl() -> void:
await click(find_button("Einzelspieler")) await click(find_button("Einzelspieler"))
check(screen() is OpponentSelectScreen, "Gegnerauswahl geöffnet") var sel = screen()
check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet")
check("Deckstärke" in sel.rating_label.text, "Deckstärke angezeigt")
check(sel.preview_labels[3].text.count("(") == 3 and sel.previews[3].size() == 3, "Vorschau zeigt drei Gegner mit Stärke")
for i in 3:
await click(sel.b_reroll)
check(sel.rerolls_left == 0 and sel.b_reroll.disabled, "höchstens dreimal neu würfeln")
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")
var previewed: Array = sel.previews[1].map(func(o): return o.title)
await shot("07_gegnerauswahl") await shot("07_gegnerauswahl")
await click(find_button("1 Gegner"))
var hard = screen()
check(hard.game.opponents.map(func(o): return o.title) == previewed, "Kampf nutzt die vorgeschauten Gegner")
var own := Game.deck_rating(main.profile.deck_cards())
var opp: Game.Player = hard.game.opponents[0]
var opp_rating := Game.deck_rating(opp.deck + opp.hand)
check(opp_rating >= own * 1.05 - 0.05 and hard.smart_ai, "Schwer: Gegnerdeck stärker (%.2f zu %.2f), Vorausschau-KI" % [opp_rating, own])
await give_up()
await click(find_button("Einzelspieler"))
await click(find_button("Leicht"))
await click(find_button("1 Gegner"))
var easy = screen()
opp = easy.game.opponents[0]
check(Game.deck_rating(opp.deck + opp.hand) <= own * 1.0 + 0.15 and not easy.smart_ai, "Leicht: schwächeres Deck, einfache KI")
await give_up()
await click(find_button("Einzelspieler"))
await click(find_button("Normal"))
main.profile.records.streak = 3
main.profile.records.margins = [20, 22, 18]
screen().refresh()
check(screen().b_tip.visible and "Schwer" in screen().tip_label.text, "Vorschlag nach drei deutlichen Siegen")
await click(find_button("Übernehmen"))
check(main.profile.settings.difficulty == "schwer", "Vorschlag übernommen")
main.profile.records.streak = 0
main.profile.records.margins = []
await click(find_button("Normal"))
await click(find_button("Zurück")) await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
for n in [1, 5]: for n in [1, 5]:
@@ -358,6 +426,17 @@ func test_gegnerauswahl() -> void:
await give_up() await give_up()
## Kampf verlassen: nach Spielende über den Ergebnisdialog, sonst per Aufgeben.
func leave_battle() -> void:
var scr = screen()
if scr is BattleScreen and scr.game != null and scr.game.winner != "":
await frames(2)
await click(find_button("Zurück zum Menü"))
check(screen() is MenuScreen, "Ergebnisdialog führt ins Menü")
else:
await give_up()
func give_up() -> void: func give_up() -> void:
var scr = screen() var scr = screen()
await clear_mulligan(scr) await clear_mulligan(scr)
@@ -370,6 +449,97 @@ func give_up() -> void:
check(screen() is MenuScreen, "Aufgeben führt ins Menü") check(screen() is MenuScreen, "Aufgeben führt ins Menü")
## Gamepad im Deckbau: Karten und Deckzeilen sind fokussierbar, A wirkt wie ein Klick, B geht zurück.
func test_gamepad_deckbau() -> void:
await click(find_button("Deck bauen"))
var scr = screen()
var before: int = scr.deck.size()
await pad(JOY_BUTTON_DPAD_DOWN)
check(get_viewport().gui_get_focus_owner() != null and main.pad_mode, "Gamepad setzt Fokus im Deckbau")
var row: Control = scr.deck_list.get_child(0)
row.grab_focus()
await frames(1)
check(row.has_focus() and scr.preview.visible, "Deckzeile fokussierbar, Vorschau folgt")
await pad(JOY_BUTTON_A)
check(scr.deck.size() == before - 1, "A entfernt die fokussierte Karte aus dem Deck")
var elems: Array = Game.deck_elements(scr.deck_cards())
var target: CardView = null
for v in scr.grid.views():
if not v.dim and v.card.elements.all(func(e): return e in elems):
target = v
break
target.grab_focus()
await frames(1)
check(target.has_focus() and scr.preview.visible and scr.preview.card == target.card, "Karte im Raster fokussierbar mit Vorschau")
var target_id: String = target.card.id
await pad(JOY_BUTTON_A) # das Raster wird danach neu aufgebaut, target ist dann freigegeben
check(scr.deck.size() == before and target_id in scr.deck, "A nimmt die fokussierte Karte ins Deck (%d Karten, Meldung '%s')" % [scr.deck.size(), scr.msg_label.text])
await shot("31_gamepad_deckbau")
await pad(JOY_BUTTON_B)
check(scr.confirm.visible, "B fragt bei ungespeicherten Änderungen nach")
await click(find_button("Verwerfen"))
check(screen() is MenuScreen, "Verwerfen führt ins Menü")
await hover(main)
## Deck-Code kopieren, einfügen und übernehmen.
func test_deckcode() -> void:
await click(find_button("Deck bauen"))
var scr = screen()
await click(find_button("Code"))
check(scr.code_dialog.visible and scr.code_box.text.begins_with(Profile.CODE_PREFIX), "Code-Dialog zeigt den Deck-Code")
await click(find_button("In Zwischenablage"))
check(DisplayServer.clipboard_get() == scr.code_box.text, "Code in der Zwischenablage")
await shot("32_deckcode")
scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,x_nix*1"
await click(find_button("Übernehmen"))
check(scr.deck.size() == Game.DECK_SIZE and "Ungültiger Code" in scr.msg_label.text, "ungültiger Code wird abgelehnt")
scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,fff_sonne*1"
await click(find_button("Übernehmen"))
check(scr.deck == ["f_funke", "f_funke", "e_wall"] and "Kern der Sonne" in scr.msg_label.text and not scr.code_dialog.visible, "Code übernommen, fehlende Karte gemeldet")
await click(find_button("Zurück"))
await click(find_button("Verwerfen"))
check(screen() is MenuScreen, "Deckbau verlassen")
## Elementbooster liefert nur Karten des Elements.
func test_elementbooster() -> void:
main.profile.coins = 300
await click(find_button("Booster öffnen"))
var scr = screen()
await click(find_button("Feuer"))
check(scr.cards.size() == 5 and scr.cards.all(func(c): return "Feuer" in c.elements) and main.profile.coins == 300 - Profile.ELEMENT_BOOSTER_COST, "Feuer-Booster: fünf Feuerkarten")
await click(find_button("Alle aufdecken"))
await shot("33_elementbooster")
await click(find_button("Zurück"))
## 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: func test_einstellungen() -> void:
await click(find_button("Einstellungen")) await click(find_button("Einstellungen"))
var scr = screen() var scr = screen()
@@ -382,7 +552,15 @@ func test_einstellungen() -> void:
check(main.profile.settings.animations == false, "Animationen abschaltbar") check(main.profile.settings.animations == false, "Animationen abschaltbar")
check(is_equal_approx(Profile.load_or_new(SAVE).settings.volume, 0.3), "Einstellungen auf Platte") check(is_equal_approx(Profile.load_or_new(SAVE).settings.volume, 0.3), "Einstellungen auf Platte")
scr.anim.button_pressed = true scr.anim.button_pressed = true
scr.big_font.button_pressed = true
await frames(2)
check(main.profile.settings.big_font and Style.font_scale > 1.0 and screen() is SettingsScreen, "große Schrift aktiv")
screen().big_font.button_pressed = false
await frames(2)
check(is_equal_approx(Style.font_scale, 1.0), "normale Schrift")
var scr_b = screen()
await shot("13_einstellungen") await shot("13_einstellungen")
scr = scr_b
await click(find_button("Zurück")) await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
@@ -390,6 +568,11 @@ func test_einstellungen() -> void:
func test_erfolge() -> void: func test_erfolge() -> void:
await click(find_button("Erfolge")) await click(find_button("Erfolge"))
check(screen() is AchievementsScreen, "Erfolge geöffnet") check(screen() is AchievementsScreen, "Erfolge geöffnet")
var csv := "user://statistik.csv"
if FileAccess.file_exists(csv):
DirAccess.remove_absolute(ProjectSettings.globalize_path(csv))
await click(find_button("Statistik exportieren"))
check(FileAccess.file_exists(csv) and "Gespeichert" in screen().export_label.text, "Statistik exportiert")
await shot("14_erfolge") await shot("14_erfolge")
await click(find_button("Zurück")) await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
@@ -427,6 +610,103 @@ func test_werkstatt() -> void:
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
func test_gamepad_menue() -> void:
check(get_viewport().gui_get_focus_owner() == null, "ohne Gamepad kein Fokus")
await pad(JOY_BUTTON_DPAD_DOWN)
var f := get_viewport().gui_get_focus_owner()
check(f is Button and main.pad_mode, "Gamepad setzt den Fokus auf einen Knopf")
var down := InputEventJoypadButton.new()
down.button_index = JOY_BUTTON_DPAD_DOWN
down.pressed = true
Input.parse_input_event(down)
await frames(2)
var f2 := get_viewport().gui_get_focus_owner()
check(f2 is Button and f2 != f, "Steuerkreuz wechselt den Knopf")
var target_text: String = f2.text
check(InputMap.action_get_events("ui_accept").any(func(e): return e is InputEventJoypadButton), "A ist auf ui_accept gemappt")
var accept := InputEventJoypadButton.new()
accept.button_index = JOY_BUTTON_A
accept.pressed = true
get_viewport().push_input(accept)
await frames(1)
var release := InputEventJoypadButton.new()
release.button_index = JOY_BUTTON_A
release.pressed = false
get_viewport().push_input(release)
await frames(3)
check(not (screen() is MenuScreen) or target_text == "Beenden", "A öffnet den fokussierten Bildschirm (%s)" % target_text)
await hover(main)
check(not main.pad_mode and get_viewport().gui_get_focus_owner() == null, "Maus nimmt den Fokus wieder")
func test_anleitung2() -> void:
check(main.profile.tutorial_done and not main.profile.tutorial2_done, "Teil 1 erledigt, Teil 2 offen")
check(find_button("Anleitung 2: Fallen") != null, "Menü bietet Teil 2 an")
await click(find_button("Anleitung 2: Fallen"))
var scr = screen()
check(scr is BattleScreen and scr.tutorial and scr.tutorial_part == 2, "Anleitung 2 gestartet")
scr.ai_delay = 0.0
var g: Game = scr.game
check(g.opponents[0].name == "Mara" and g.human.hand.any(func(c): return c.id == "w_falle") and g.human.hand.any(func(c): return c.id == "w_reiter"), "Mara, Frostfalle und Wellenreiter auf der Hand")
await frames(2)
check(scr.mulligan_box.visible and scr.tutorial_step == 0, "Schritt 1: Mulligan-Maske")
await click(scr.b_keep)
check(scr.tutorial_step == 1 and "Frostfalle" in scr.tutorial_text.text, "Schritt 2: Falle legen")
await shot("29_anleitung2")
# Zug 1 hat nur 1 Energie: Zug beenden, dann Falle legen
await key(KEY_SPACE)
await wait_for_human(scr)
check(scr.highlight_layer.get_child_count() >= 1, "Falle hervorgehoben")
var idx: int = g.human.hand.map(func(c): return c.id).find("w_falle")
check(idx >= 0 and idx < 9, "Falle auf der Hand")
await key(KEY_1 + idx)
check(g.human.traps.size() == 1 and scr.tutorial_step == 2, "Falle gelegt, Schritt 3")
# Mara läuft in die Falle: zwei Züge abwarten
for i in 2:
await clear_mulligan(scr)
if g.current() == g.human and g.winner == "":
await key(KEY_SPACE)
await wait_for_human(scr)
if scr.tutorial_step >= 3:
break
check(scr.tutorial_step >= 3 and g.log.any(func(l): return l.begins_with("Falle von Du")), "Mara löst die Frostfalle aus, Schritt 4")
await give_up()
check(main.profile.tutorial2_done and find_button("Anleitung") != null, "Teil 2 erledigt")
func test_profil_zuruecksetzen() -> void:
await click(find_button("Einstellungen"))
await click(find_button("Profil zurücksetzen"))
var scr = screen()
check(scr.confirm.visible, "Rückfrage")
await click(find_button("Abbrechen"))
check(not scr.confirm.visible, "abgebrochen")
await click(find_button("Profil zurücksetzen"))
await click(find_button("Ja, zurücksetzen"))
check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and not main.profile.tutorial_done, "Profil zurückgesetzt")
check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert")
func test_herausforderung() -> void:
await click(find_button("Herausforderung"))
var scr = screen()
check(scr is ChallengeScreen, "Herausforderung geöffnet")
var ch := Challenge.current()
var allowed := Challenge.deck_allowed(ch, main.profile.deck_cards()) == ""
check(find_button("Antreten").disabled == not allowed, "Antreten je nach Deck")
await shot("28_herausforderung")
if allowed:
await click(find_button("Antreten"))
var b = screen()
check(b is BattleScreen and b.challenge == ch and b.game.opponents.size() == ch.n_opp, "Herausforderungskampf")
var everyone: String = ch.rules.get("everyone_perk", "")
check(everyone == "" or b.game.players.all(func(p): return everyone in p.perks), "Regel gilt für alle")
await give_up()
else:
await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü")
func test_hotseat() -> void: func test_hotseat() -> void:
await click(find_button("Lokal zu mehreren")) await click(find_button("Lokal zu mehreren"))
var scr = screen() var scr = screen()
@@ -458,7 +738,7 @@ func test_hotseat() -> void:
await clear_mulligan(b) await clear_mulligan(b)
check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch") check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch")
await shot("19_hotseat_kampf") await shot("19_hotseat_kampf")
await give_up() await leave_battle()
func test_sprache() -> void: func test_sprache() -> void:
@@ -469,10 +749,12 @@ func test_sprache() -> void:
await frames(2) await frames(2)
check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch") check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch")
check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen") check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen")
check(TranslationServer.translate("Zug %d · Du bist dran") == "Turn %d · Your turn" and TranslationServer.translate("Nicht genug Energie") == "Not enough energy", "dynamische Texte übersetzt")
check(find_button("Back") != null, "Knöpfe übersetzt") check(find_button("Back") != null, "Knöpfe übersetzt")
await shot("26_englisch") await shot("26_englisch")
await click(find_button("Back")) await click(find_button("Back"))
check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch") check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch")
check(screen().coins_label.text.begins_with("Coins:"), "zusammengesetzte Texte übersetzt")
await click(find_button("Settings")) await click(find_button("Settings"))
var scr2 = screen() var scr2 = screen()
scr2.language.select(0) scr2.language.select(0)
@@ -499,25 +781,61 @@ func test_anleitung() -> void:
await shot("22_anleitung_mulligan") await shot("22_anleitung_mulligan")
await click(scr.b_swap) await click(scr.b_swap)
check(not scr.mulligan_box.visible and g.human.mulligan_done, "getauscht") check(not scr.mulligan_box.visible and g.human.mulligan_done, "getauscht")
check(g.log.any(func(l): return "tauscht 1 Karten" in l), "Tausch im Verlauf") check(g.log.any(func(l): return "tauscht 1 Karte" in l), "Tausch im Verlauf")
check(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar") check(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar")
var idx := -1 var idx := -1
for i in g.human.hand.size(): for i in g.human.hand.size():
if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1: if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1:
idx = i idx = i
break break
check(idx < 0 or scr.highlight_layer.get_child_count() >= 1, "spielbare Wesen hervorgehoben")
if idx >= 0: if idx >= 0:
var played_id: String = g.human.hand[idx].id
await key(KEY_1 + idx) await key(KEY_1 + idx)
check(g.human.board.size() == 1, "Wesen per Taste gespielt") check(g.human.board.size() == 1, "Wesen per Taste gespielt")
check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen") check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen")
check(scr.highlight_layer.get_child_count() == 1 and scr.highlight_layer.get_child(0).get_rect().intersects(scr.b_end.get_rect()), "Zug-beenden-Knopf hervorgehoben")
# Rückgängig: Wesen zurück auf die Hand
check(scr.b_undo.visible, "Rückgängig angeboten")
var energy: int = g.human.energy
await click(scr.b_undo)
g = scr.game
check(g.human.board.is_empty() and g.human.hand.any(func(c): return c.id == played_id) and g.human.energy == energy + 1, "Rückgängig stellt Hand und Energie her")
check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht")
await key(KEY_1 + idx)
check(g.human.board.size() == 1, "erneut gespielt")
await shot("22_anleitung") await shot("22_anleitung")
# Gamepad: Zeiger bewegen, Karte spielen
await pad(JOY_BUTTON_DPAD_RIGHT)
check(scr.gp_zone == "hand" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweiter Handkarte")
var pad_idx := -1
for i in g.human.hand.size():
if g.human.hand[i].is_creature() and g.can_play(g.human, g.human.hand[i])[0]:
pad_idx = i
break
if pad_idx >= 0:
while scr.gp_index != pad_idx:
await pad(JOY_BUTTON_DPAD_RIGHT)
var board_before: int = g.human.board.size()
await pad(JOY_BUTTON_A)
check(g.human.board.size() == board_before + 1, "Gamepad A spielt das Wesen")
scr.ai_delay = 0.75
await key(KEY_SPACE) await key(KEY_SPACE)
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
await wait_for_human(scr) await frames(2)
check(scr.b_skip.visible and not scr.b_end.visible, "Überspringen-Knopf während des Gegnerzugs")
await click(scr.b_skip)
var w := 0
while g.current() != g.human and g.winner == "" and w < 120:
await frames(1)
w += 1
check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w)
scr.ai_delay = 0.0
await clear_mulligan(scr)
check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug") check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug")
var step_before: int = scr.tutorial_step var step_before: int = scr.tutorial_step
await click(scr.b_tut_next) await click(scr.b_tut_next)
check(scr.tutorial_step == step_before + 1, "Weiter-Knopf") check(scr.tutorial_step >= step_before + 1, "Weiter-Knopf (Schritt %d)" % step_before)
await key(KEY_ESCAPE) await key(KEY_ESCAPE)
check(scr.confirm.visible, "Esc öffnet Aufgeben") check(scr.confirm.visible, "Esc öffnet Aufgeben")
await key(KEY_ESCAPE) await key(KEY_ESCAPE)
@@ -532,11 +850,20 @@ func test_netzwerk() -> void:
check(scr is LobbyScreen, "Lobby geöffnet") check(scr is LobbyScreen, "Lobby geöffnet")
await click(find_button("Spiel eröffnen")) await click(find_button("Spiel eröffnen"))
check(main.net.is_host and main.net.connected, "Spiel eröffnet") check(main.net.is_host and main.net.connected, "Spiel eröffnet")
if FileAccess.file_exists("user://bot_quit"):
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://bot_quit"))
var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"] var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"]
var pid := OS.create_process(OS.get_executable_path(), args) var pid := OS.create_process(OS.get_executable_path(), args)
check(pid > 0, "Bot-Prozess gestartet") check(pid > 0, "Bot-Prozess gestartet")
var wait := 0 var wait := 0
while main.net.names.size() < 2 and wait < 900: while main.net.names.size() < 2 and wait < 600:
await frames(1)
wait += 1
if main.net.names.size() < 2:
print("HINWEIS: Bot kam nicht, zweiter Versuch")
OS.kill(pid)
pid = OS.create_process(OS.get_executable_path(), args)
while main.net.names.size() < 2 and wait < 1500:
await frames(1) await frames(1)
wait += 1 wait += 1
check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path()) check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path())
@@ -546,7 +873,7 @@ func test_netzwerk() -> void:
check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host") check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host")
b.ai_delay = 0.0 b.ai_delay = 0.0
var rounds := 0 var rounds := 0
while b.game != null and b.game.winner == "" and rounds < 4: while b.game != null and b.game.winner == "" and rounds < 2:
if b.game.current() == b.perspective: if b.game.current() == b.perspective:
await clear_mulligan(b) await clear_mulligan(b)
if b.game.current() == b.perspective: if b.game.current() == b.perspective:
@@ -556,37 +883,196 @@ func test_netzwerk() -> void:
await frames(1) await frames(1)
w2 += 1 w2 += 1
rounds += 1 rounds += 1
check(b.game.turn >= 3 and b.game.winner == "", "mehrere Runden gespielt, Partie läuft (Zug %d)" % b.game.turn) if b.game.winner != "":
print("DEBUG Netz: Sieger=", b.game.winner, " Verlauf=", b.game.log.slice(maxi(0, b.game.log.size() - 12)))
check(b.game.turn >= 2 and b.game.winner == "", "Runden gespielt, Partie läuft (Zug %d)" % b.game.turn)
check(b.game.log.any(func(l): return l.begins_with("Bot spielt") or l.begins_with("Bot behält") or l.begins_with("Bot tauscht")), "Bot hat gehandelt") check(b.game.log.any(func(l): return l.begins_with("Bot spielt") or l.begins_with("Bot behält") or l.begins_with("Bot tauscht")), "Bot hat gehandelt")
await shot("24_netzwerk_kampf") await shot("24_netzwerk_kampf")
OS.kill(pid) # Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen
var quit_file := "user://bot_quit"
var f := FileAccess.open(quit_file, FileAccess.WRITE)
f.store_string("x")
f.close()
var w3 := 0 var w3 := 0
while b.game.winner == "" and w3 < 1500: while main.net.waiting.is_empty() and w3 < 600:
await frames(1) await frames(1)
w3 += 1 w3 += 1
if b.game.winner != "": check(main.net.waiting.has("Bot") and b.game.log.any(func(l): return "Verbindung verloren" in l), "Platz wird freigehalten")
check(b.game.winner == "human" and b.game.log.any(func(l): return "verlassen" in l), "Trennung des Bots zählt als Aufgabe") var turn_before: int = b.game.turn
await frames(2) for i in 2:
check(b.overlay.visible, "Spielende angezeigt") await clear_mulligan(b)
await click(find_button("Zurück zum Menü")) if b.game.current() == b.perspective and b.game.winner == "":
else: await click(b.b_end)
print("HINWEIS: Bot-Trennung nach 25 s noch nicht erkannt, Aufgeben stattdessen") var w4 := 0
await give_up() while b.game.current() != b.perspective and b.game.winner == "" and w4 < 300:
await frames(1)
w4 += 1
check(b.game.turn >= turn_before + 1 and b.game.log.any(func(l): return "übersprungen" in l), "Züge des fehlenden Spielers werden übersprungen")
DirAccess.remove_absolute(ProjectSettings.globalize_path(quit_file))
await frames(5)
var pid2 := OS.create_process(OS.get_executable_path(), args)
var w5 := 0
while not main.net.waiting.is_empty() and w5 < 900:
await frames(1)
w5 += 1
check(main.net.waiting.is_empty() and main.net.seats.size() == 2 and b.game.log.any(func(l): return l == "Bot ist zurück."), "Bot wieder verbunden, Platz zurück")
var turn2: int = b.game.turn
for i in 2:
await clear_mulligan(b)
if b.game.current() == b.perspective and b.game.winner == "":
await click(b.b_end)
var w6 := 0
while b.game.current() != b.perspective and b.game.winner == "" and w6 < 900:
await frames(1)
w6 += 1
check(b.game.turn >= turn2 + 1, "Partie läuft nach der Rückkehr weiter")
OS.kill(pid2)
await leave_battle()
check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü") check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü")
func _bot_args(extra: Array) -> Array:
return ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--"] + extra
func _bot_quit(on: bool) -> void:
var path := "user://bot_quit"
if on:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("x")
f.close()
elif FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## Verbindet sich mit dem Bot-Host (mehrere Versuche, bis er lauscht).
func _join_bot_host(watch: bool) -> void:
await click(find_button("Netzwerkspiel"))
var lobby = screen()
lobby.ip_box.text = "127.0.0.1"
for attempt in 8:
await click(find_button("Zuschauen" if watch else "Beitreten"))
var w := 0
while not main.net.connected and w < 180:
await frames(1)
w += 1
if main.net.connected:
return
main.net.leave()
await frames(30)
## Der Host (ein Bot) verschwindet: der einzige Mitspieler übernimmt und spielt zu Ende.
func test_host_wechsel() -> void:
_bot_quit(false)
var pid := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
check(pid > 0, "Bot-Host gestartet")
await frames(30)
await _join_bot_host(false)
var w := 0
while not (screen() is BattleScreen) and w < 900:
await frames(1)
w += 1
var b = screen()
check(b is BattleScreen and b.is_client(), "als Mitspieler beim Bot-Host im Kampf")
w = 0
while (b.game == null or main.net.backup.is_empty()) and w < 600:
await frames(1)
w += 1
check(b.game != null and main.net.i_am_successor and not main.net.backup.is_empty(), "Nachfolger kennt den vollen Spielstand")
main.net.rejoin_seconds = 3.0
_bot_quit(true)
w = 0
while not main.net.is_host and w < 900:
await frames(1)
w += 1
check(main.net.is_host and b.game == main.net.game and screen() == b and b.game.log.any(func(l): return "übernimmt als Host" in l),
"Host-Wechsel: Mitspieler führt das Spiel weiter")
check(main.net.waiting.has("BotHost"), "Platz des alten Hosts bleibt zunächst frei")
await shot("34_host_wechsel")
# Der alte Host kehrt nicht zurück: ein Zug wird übersprungen, nach der Frist scheidet er aus
b.ai_delay = 0.0
await clear_mulligan(b)
if b.game.current() == b.perspective:
await click(b.b_end)
w = 0
while b.game.winner == "" and w < 900:
await frames(1)
w += 1
check(b.game.log.any(func(l): return "übersprungen" in l), "Zug des fehlenden Hosts übersprungen")
check(b.game.winner != "" and b.game.winner_player == b.perspective and b.game.log.any(func(l): return "kehrt nicht zurück" in l),
"nach der Frist scheidet der alte Host aus, Partie gewonnen (Sieger '%s', Zug %d)" % [b.game.winner, b.game.turn])
await leave_battle()
_bot_quit(false)
check(screen() is MenuScreen and not main.net.connected, "nach dem Host-Wechsel zurück im Menü")
## Zuschauer: sieht die Partie zweier Bots ohne Hände und ohne eigene Züge.
func test_zuschauer() -> void:
_bot_quit(false)
var pid_h := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
await frames(30)
var pid_c := OS.create_process(OS.get_executable_path(), _bot_args(["--ip=127.0.0.1", "--name=Bot"]))
check(pid_h > 0 and pid_c > 0, "zwei Bots gestartet")
await _join_bot_host(true)
var w := 0
while not (screen() is BattleScreen) and w < 1200:
await frames(1)
w += 1
var b = screen()
check(b is BattleScreen and b.spectating(), "als Zuschauer im Kampf")
w = 0
while (b.game == null or b.game.turn < 2) and w < 900:
await frames(1)
w += 1
check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.mulligan_box.visible and not b.b_end.visible,
"Zuschauer sieht die Partie ohne Hände und ohne Züge (Zug %d)" % (b.game.turn if b.game != null else 0))
await shot("35_zuschauer")
check(not b.handover.visible and not b.is_hotseat(), "Zuschauer bekommt keine Hotseat-Übergabe")
if screen() is BattleScreen: # die Bots spielen schnell, die Partie kann schon vorbei sein
await leave_battle()
else:
var w2 := 0
while not (screen() is MenuScreen) and w2 < 300:
await frames(1)
w2 += 1
check(screen() is MenuScreen and not main.net.connected, "Zuschauer zurück im Menü")
_bot_quit(true)
await frames(60)
_bot_quit(false)
func test_kampagne() -> void: func test_kampagne() -> void:
await click(find_button("Kampagne")) await click(find_button("Kampagne"))
var scr = screen() var scr = screen()
check(scr is CampaignScreen, "Kampagne geöffnet") check(scr is CampaignScreen, "Kampagne geöffnet")
await shot("15_kampagne") await shot("15_kampagne")
check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen") check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen")
check(find_button("Kapitel 2").disabled and scr.perk_box == null, "Kapitel 2 und Merkmal noch gesperrt")
await click(find_button("Kämpfen")) await click(find_button("Kämpfen"))
var battle = screen() var battle = screen()
check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet") check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet")
check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner") 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")
await shot("16_kampagne_kampf") await shot("16_kampagne_kampf")
await give_up() await give_up()
# Kapitel 1 als abgeschlossen setzen: Kapitel 2 und Merkmalwahl
main.profile.campaign_stage = Campaign.STAGES.size()
await click(find_button("Kampagne"))
var scr2 = screen()
check(scr2.chapter == 2 and scr2.perk_box != null, "Kapitel 2 offen, Merkmal wählbar")
scr2.perk_box.select(1)
scr2.perk_box.item_selected.emit(1)
check(main.profile.perk == Game.PERKS.keys()[0], "Merkmal gewählt")
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.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf")
await give_up()
main.profile.campaign_stage = 0
main.profile.perk = ""
main.save()
func test_kampf(n_opp: int, shot_turn: int) -> void: func test_kampf(n_opp: int, shot_turn: int) -> void:
@@ -642,8 +1128,12 @@ func test_kampf_abbrechen() -> void:
await click(trap_view) await click(trap_view)
check(g.human.traps.size() == 1 and scr.trap_views.size() == 1, "Falle gelegt und sichtbar") check(g.human.traps.size() == 1 and scr.trap_views.size() == 1, "Falle gelegt und sichtbar")
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0] var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
await hover(scr.log_box) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird
await hover(reiter_view) await hover(reiter_view)
var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel) var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel)
if hint_boxes.size() != 1:
print("DEBUG Hinweis: hints=", scr.combo_hints(Cards.by_id("w_reiter")), " kinder=", scr.preview_holder.get_children().map(func(c): return c.get_class()),
" brett=", g.human.board.map(func(c): return c.name), " hovered=", get_viewport().gui_get_hovered_control(), " reiter_pos=", reiter_view.get_global_rect())
check(hint_boxes.size() == 1 and "Feldkombo Dampf" in hint_boxes[0].text, "Kombo-Hinweis in der Vorschau") check(hint_boxes.size() == 1 and "Feldkombo Dampf" in hint_boxes[0].text, "Kombo-Hinweis in der Vorschau")
await shot("21_kampf_falle_hinweis") await shot("21_kampf_falle_hinweis")
var spells: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0]) var spells: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0])
@@ -654,6 +1144,9 @@ func test_kampf_abbrechen() -> void:
await right_click() await right_click()
check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab") check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab")
await hover(scr.hand_views[0]) await hover(scr.hand_views[0])
if scr.preview_holder.get_child_count() == 0:
print("DEBUG Hover: mulligan=", scr.mulligan_box.visible, " 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") check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover")
await click(find_button("Lupe")) await click(find_button("Lupe"))
check(scr.zoom_player == scr.others()[0], "Lupe öffnet die erste Spalte") check(scr.zoom_player == scr.others()[0], "Lupe öffnet die erste Spalte")

View File

@@ -57,3 +57,163 @@ Hand tauschen?,Swap cards?
Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game. Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game.
Fallen,Traps Fallen,Traps
Du bist dran,Your turn Du bist dran,Your turn
Herausforderung,Challenge
Antreten,Enter
Statistik exportieren,Export statistics
Gegnerzüge überspringen,Skip AI turns
Rückgängig,Undo
Kapitel 1,Chapter 1
Kapitel 2,Chapter 2
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
"Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d","Collection: %d of %d cards (%d total) · Campaign: %d/%d"
Aktives Deck: %s (%s),Active deck: %s (%s)
leer,empty
Tagesaufgabe erledigt: %s ✓,Daily task done: %s ✓
Tagesaufgabe: %s (%d/%d) · +%d Münzen,Daily task: %s (%d/%d) · +%d coins
"Aktives Deck nicht spielbar: ","Active deck not playable: "
Zug %d · Du bist dran,Turn %d · Your turn
Zug %d · %s ist dran …,Turn %d · %s is playing …
Wähle ein Ziel,Choose a target
Wähle ein Angriffsziel,Choose an attack target
Abgebrochen,Cancelled
%s kann nicht angreifen,%s cannot attack
Nicht dein Zug,Not your turn
Nicht genug Energie,Not enough energy
Dein Feld ist voll,Your board is full
Kein gültiges Ziel,No valid target
Ungültiges Ziel,Invalid target
Höchstens 2 Fallen,At most 2 traps
Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first
Sieg!,Victory!
Niederlage,Defeat
+%d Münzen,+%d coins
"Deck braucht genau %d Karten (%d)","Deck needs exactly %d cards (%d)"
Höchstens %dx dieselbe Karte,At most %dx the same card
Karte nicht oft genug in der Sammlung,Not enough copies in your collection
Höchstens %d Elemente im Deck,At most %d elements in a deck
Schwierigkeit,Difficulty
Leicht,Easy
Normal,Normal
Schwer,Hard
"Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s","Your deck strength %.1f · Opponent decks %.1f · %s · Coins x%.2f · AI: %s"
schwache Archetypen,weaker archetypes
starke Archetypen,stronger archetypes
alle Archetypen,all archetypes
Vorausschau,lookahead
einfach,simple
Stärke %.1f,Strength %.1f
"Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten","Deck strength: average of rarity, keywords and effects"
Tipp: %s,Tip: %s
Übernehmen,Apply
Neu würfeln,Reroll
Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s)
Anleitung 2: Fallen,Tutorial 2: Traps
Neu würfeln (%d),Reroll (%d)
Profil zurücksetzen,Reset profile
Große Schrift,Large text
Verwerfen,Discard
Weiter bearbeiten,Keep editing
Ungespeicherte Änderungen,Unsaved changes
"Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",Welcome! Your hand is at the bottom. Every card costs energy (◆); you have 1 on your first turn. Click a creature that costs 1 to play it.
Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.,Creatures attack from the next turn on. End your turn with the button on the right or the space bar.
Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.,"Green frames mark creatures ready to attack. Click a creature, then Fenn or one of his creatures. Guardians must fall first."
Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.,Resonance: creatures sharing an element boost each other's attack. Play a second fire or earth creature.
Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an das stärkste gegnerische Wesen.,Field combo: a creature next to one of yours with a different element triggers an effect. Lava deals 1 damage to the strongest enemy creature.
"Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.",Spells resolve immediately. Spark or Fireball can hit Fenn directly. Pick targets by clicking; right-click cancels.
Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!,Fenn loses at 0 life. Swap weak opening hands on your first turn (mulligan). Good luck!
"Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst.",Mulligan: on your first turn you may put cards back once and redraw. Decide in the dialog what to keep.
"Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast.",Traps lie face down and trigger during enemy turns. Set the Frost Trap once you have 2 energy.
"Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein.","End your turn and wait. If Mara attacks you or a creature, the attack fizzles and her creature freezes."
Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander.,Field combo Steam: a water creature next to a fire creature freezes the strongest enemy creature. Play both in sequence.
"Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara!",Undo (U) takes back a creature you just played as long as nothing was triggered. Good luck against Mara!
"%d Siege in Folge, versuche %s","%d wins in a row, try %s"
"%d Niederlagen in Folge, versuche %s","%d losses in a row, try %s"
Duell,Duel
%d am Tisch,%d at the table
%d Spieler am Tisch,%d players at the table
%d Münzen,%d coins
Stärke %.1f · %d Münzen,Strength %.1f · %d coins
Abbrechen,Cancel
"Ja, zurücksetzen","Yes, reset"
"Speichern und zurück","Save and go back"
"Kampf fortsetzen","Resume battle"
Aufgeben beendet den Kampf ohne Münzen.,Surrendering ends the battle without coins.
Speichern hebt ihn für später auf.,Saving keeps it for later.
%s Schaden an den Angreifer,%s damage to the attacker
%s Schaden an dieses Wesen,%s damage to that creature
Der Angreifer wird eingefroren,The attacker is frozen
Es wird eingefroren,It is frozen
Es geht zurück auf die Hand,It returns to its owner's hand
Der Angriff verpufft,The attack fizzles
Beschwöre einen %s,Summon a %s
%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
Heile dich um %s,Heal yourself for %s
Ziehe 1 Karte,Draw 1 card
Ziehe %s Karten,Draw %s cards
Friere ein gegnerisches Wesen ein,Freeze an enemy creature
Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature
Friere alle gegnerischen Wesen ein,Freeze all enemy creatures
Ein gegnerisches Wesen zurück auf die Hand,Return an enemy creature to its owner's hand
Alle gegnerischen Wesen zurück auf die Hand,Return all enemy creatures to their owners' hands
Ein befreundetes Wesen erhält %s,A friendly creature gets %s
Alle befreundeten Wesen erhalten %s,All friendly creatures get %s
Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield
Erhält Schild,Gets Shield
Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield
Falle. %s: %s,Trap. %s: %s
Beim Ausspielen: %s,On play: %s
Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift,When an enemy creature attacks you or one of your creatures
Wenn ein Gegner ein Wesen ausspielt,When an opponent plays a creature
Wächter,Guardian
Flink,Swift
Schild,Shield
Doppelschlag,Double Strike
Wesen,Creature
Zauber,Spell
Falle,Trap
gewöhnlich,common
selten,rare
episch,epic
legendär,legendary
mythisch,mythic
Feuer,Fire
Wasser,Water
Erde,Earth
Luft,Air
Inferno,Inferno
Tiefsee,Deep Sea
Berg,Mountain
Orkan,Hurricane
Dampf,Steam
Lava,Lava
Blitz,Lightning
Schlamm,Mud
Sturm,Storm
Sand,Sand
Sonne,Sun
Ozean,Ocean
Gebirge,Mountains
Himmel,Sky
"Code übernommen, es fehlen: ","Code applied, missing: "
Elemente: ,Elements:
%s zerlegt (+%d Staub),%s dismantled (+%d dust)
%s hergestellt,%s crafted
%s ist jetzt im Deck %s,%s is now in deck %s
Deck-Code,Deck code
In Zwischenablage,To clipboard
Aus Zwischenablage,From clipboard
Code,Code
Ungültiger Code,Invalid code
Code übernommen,Code applied
Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.,Copy to share or paste a code. Cards you do not own are skipped.
Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos,Element booster (%d coins): only cards of that element and its combos
"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks.","Chapter 3: three perks, full tables and the strongest decks."
Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!,All three chapters completed. The Archmage is defeated for good!
Kapitel %d,Chapter %d
Zuschauen,Watch
Zuschauer,Spectator
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden"
1 keys en
57 Klicke Karten an die du zurücklegen willst. Einmal pro Partie. Click the cards you want to put back. Once per game.
58 Fallen Traps
59 Du bist dran Your turn
60 Herausforderung Challenge
61 Antreten Enter
62 Statistik exportieren Export statistics
63 Gegnerzüge überspringen Skip AI turns
64 Rückgängig Undo
65 Kapitel 1 Chapter 1
66 Kapitel 2 Chapter 2
67 Münzen: %d · Staub: %d Coins: %d · Dust: %d
68 Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d Collection: %d of %d cards (%d total) · Campaign: %d/%d
69 Aktives Deck: %s (%s) Active deck: %s (%s)
70 leer empty
71 Tagesaufgabe erledigt: %s ✓ Daily task done: %s ✓
72 Tagesaufgabe: %s (%d/%d) · +%d Münzen Daily task: %s (%d/%d) · +%d coins
73 Aktives Deck nicht spielbar: Active deck not playable:
74 Zug %d · Du bist dran Turn %d · Your turn
75 Zug %d · %s ist dran … Turn %d · %s is playing …
76 Wähle ein Ziel Choose a target
77 Wähle ein Angriffsziel Choose an attack target
78 Abgebrochen Cancelled
79 %s kann nicht angreifen %s cannot attack
80 Nicht dein Zug Not your turn
81 Nicht genug Energie Not enough energy
82 Dein Feld ist voll Your board is full
83 Kein gültiges Ziel No valid target
84 Ungültiges Ziel Invalid target
85 Höchstens 2 Fallen At most 2 traps
86 Ein Wächter muss zuerst angegriffen werden A guardian must be attacked first
87 Sieg! Victory!
88 Niederlage Defeat
89 +%d Münzen +%d coins
90 Deck braucht genau %d Karten (%d) Deck needs exactly %d cards (%d)
91 Höchstens %dx dieselbe Karte At most %dx the same card
92 Karte nicht oft genug in der Sammlung Not enough copies in your collection
93 Höchstens %d Elemente im Deck At most %d elements in a deck
94 Schwierigkeit Difficulty
95 Leicht Easy
96 Normal Normal
97 Schwer Hard
98 Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s Your deck strength %.1f · Opponent decks %.1f · %s · Coins x%.2f · AI: %s
99 schwache Archetypen weaker archetypes
100 starke Archetypen stronger archetypes
101 alle Archetypen all archetypes
102 Vorausschau lookahead
103 einfach simple
104 Stärke %.1f Strength %.1f
105 Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten Deck strength: average of rarity, keywords and effects
106 Tipp: %s Tip: %s
107 Übernehmen Apply
108 Neu würfeln Reroll
109 Deck verbessern: %s ins aktive Deck (%s) Improve deck: add %s to the active deck (%s)
110 Anleitung 2: Fallen Tutorial 2: Traps
111 Neu würfeln (%d) Reroll (%d)
112 Profil zurücksetzen Reset profile
113 Große Schrift Large text
114 Verwerfen Discard
115 Weiter bearbeiten Keep editing
116 Ungespeicherte Änderungen Unsaved changes
117 Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick. Welcome! Your hand is at the bottom. Every card costs energy (◆); you have 1 on your first turn. Click a creature that costs 1 to play it.
118 Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste. Creatures attack from the next turn on. End your turn with the button on the right or the space bar.
119 Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen. Green frames mark creatures ready to attack. Click a creature, then Fenn or one of his creatures. Guardians must fall first.
120 Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen. Resonance: creatures sharing an element boost each other's attack. Play a second fire or earth creature.
121 Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an das stärkste gegnerische Wesen. Field combo: a creature next to one of yours with a different element triggers an effect. Lava deals 1 damage to the strongest enemy creature.
122 Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab. Spells resolve immediately. Spark or Fireball can hit Fenn directly. Pick targets by clicking; right-click cancels.
123 Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg! Fenn loses at 0 life. Swap weak opening hands on your first turn (mulligan). Good luck!
124 Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst. Mulligan: on your first turn you may put cards back once and redraw. Decide in the dialog what to keep.
125 Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast. Traps lie face down and trigger during enemy turns. Set the Frost Trap once you have 2 energy.
126 Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein. End your turn and wait. If Mara attacks you or a creature, the attack fizzles and her creature freezes.
127 Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander. Field combo Steam: a water creature next to a fire creature freezes the strongest enemy creature. Play both in sequence.
128 Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara! Undo (U) takes back a creature you just played as long as nothing was triggered. Good luck against Mara!
129 %d Siege in Folge, versuche %s %d wins in a row, try %s
130 %d Niederlagen in Folge, versuche %s %d losses in a row, try %s
131 Duell Duel
132 %d am Tisch %d at the table
133 %d Spieler am Tisch %d players at the table
134 %d Münzen %d coins
135 Stärke %.1f · %d Münzen Strength %.1f · %d coins
136 Abbrechen Cancel
137 Ja, zurücksetzen Yes, reset
138 Speichern und zurück Save and go back
139 Kampf fortsetzen Resume battle
140 Aufgeben beendet den Kampf ohne Münzen. Surrendering ends the battle without coins.
141 Speichern hebt ihn für später auf. Saving keeps it for later.
142 %s Schaden an den Angreifer %s damage to the attacker
143 %s Schaden an dieses Wesen %s damage to that creature
144 Der Angreifer wird eingefroren The attacker is frozen
145 Es wird eingefroren It is frozen
146 Es geht zurück auf die Hand It returns to its owner's hand
147 Der Angriff verpufft The attack fizzles
148 Beschwöre einen %s Summon a %s
149 %s Schaden an ein gegnerisches Wesen %s damage to an enemy creature
150 %s Schaden an ein Ziel %s damage to a target
151 %s Schaden an alle gegnerischen Wesen %s damage to all enemy creatures
152 %s Schaden an alle Gegner %s damage to all opponents
153 %s Schaden an alle Gegner und ihre Wesen %s damage to all opponents and their creatures
154 Heile dich um %s Heal yourself for %s
155 Ziehe 1 Karte Draw 1 card
156 Ziehe %s Karten Draw %s cards
157 Friere ein gegnerisches Wesen ein Freeze an enemy creature
158 Friere das stärkste gegnerische Wesen ein Freeze the strongest enemy creature
159 Friere alle gegnerischen Wesen ein Freeze all enemy creatures
160 Ein gegnerisches Wesen zurück auf die Hand Return an enemy creature to its owner's hand
161 Alle gegnerischen Wesen zurück auf die Hand Return all enemy creatures to their owners' hands
162 Ein befreundetes Wesen erhält %s A friendly creature gets %s
163 Alle befreundeten Wesen erhalten %s All friendly creatures get %s
164 Ein befreundetes Wesen erhält Schild A friendly creature gets Shield
165 Erhält Schild Gets Shield
166 Alle befreundeten Wesen erhalten Schild All friendly creatures get Shield
167 Falle. %s: %s Trap. %s: %s
168 Beim Ausspielen: %s On play: %s
169 Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift When an enemy creature attacks you or one of your creatures
170 Wenn ein Gegner ein Wesen ausspielt When an opponent plays a creature
171 Wächter Guardian
172 Flink Swift
173 Schild Shield
174 Doppelschlag Double Strike
175 Wesen Creature
176 Zauber Spell
177 Falle Trap
178 gewöhnlich common
179 selten rare
180 episch epic
181 legendär legendary
182 mythisch mythic
183 Feuer Fire
184 Wasser Water
185 Erde Earth
186 Luft Air
187 Inferno Inferno
188 Tiefsee Deep Sea
189 Berg Mountain
190 Orkan Hurricane
191 Dampf Steam
192 Lava Lava
193 Blitz Lightning
194 Schlamm Mud
195 Sturm Storm
196 Sand Sand
197 Sonne Sun
198 Ozean Ocean
199 Gebirge Mountains
200 Himmel Sky
201 Code übernommen, es fehlen: Code applied, missing:
202 Elemente: Elements:
203 %s zerlegt (+%d Staub) %s dismantled (+%d dust)
204 %s hergestellt %s crafted
205 %s ist jetzt im Deck %s %s is now in deck %s
206 Deck-Code Deck code
207 In Zwischenablage To clipboard
208 Aus Zwischenablage From clipboard
209 Code Code
210 Ungültiger Code Invalid code
211 Code übernommen Code applied
212 Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen. Copy to share or paste a code. Cards you do not own are skipped.
213 Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos Element booster (%d coins): only cards of that element and its combos
214 Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks. Chapter 3: three perks, full tables and the strongest decks.
215 Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt! All three chapters completed. The Archmage is defeated for good!
216 Kapitel %d Chapter %d
217 Zuschauen Watch
218 Zuschauer Spectator
219 Zuschauer: Hände sind verdeckt Spectator: hands are hidden

Binary file not shown.