commit 536a523f50bfdf1aa426ca895a98e455c7245969 Author: Team3 Date: Wed Sep 16 13:22:07 2026 +0200 Dinger: Kartenspiel in Godot 4.7 Elemente, Kombos, Fallen, KI mit Vorausschau, Kampagne, Hotseat und Netzwerk, Werkstatt, Erfolge, Tagesaufgaben, Tests und Exporte. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e6ea0eb --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +name: Tests + +on: [push, pull_request] + +jobs: + headless: + 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: Tests + run: ./tests/run_headless.sh ./godot + + fenster: + 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 (virtueller Bildschirm) + 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 + run: grep -q " 0 fehlgeschlagen" fenster.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54134c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.godot/ +build/ +python/.venv/ +python/**/__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2cf8e5 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Dinger + +Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle mit denselben Regeln. + +## Start + +Projektordner in Godot öffnen und auf „Play“ drücken, oder im Terminal: + +``` +godot --path . +``` + +Der Spielstand liegt unter `user://spielstand.json` +(Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`). + +## Spielsysteme + +- 100 Karten in `data/karten.json`: 4 Elemente, 10 Zweier-Kombos (auch gleiche Elemente), 4 mythische Dreifach-Kombos, 4 Fallen +- Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus +- Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht) +- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern, Erfolge, Tagesaufgabe +- Lokal zu mehreren: bis zu 4 Menschen am selben Rechner plus KI +- Booster, fünf Deck-Slots, Werkstatt (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen) +- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`) +- Anleitung (geführte erste Partie), Mulligan im ersten Zug, Tastatur (Leertaste, 1 bis 9, Esc, Enter) +- 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) +- Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch + +## Tests + +`tests/run_headless.sh` führt Import und Headless-Prüfungen aus (auch in `.github/workflows/tests.yml`). + +``` +godot --headless --path . --import # einmalig: Ressourcen importieren +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/matchup.gd # Startvorteil und Mono gegen Paar +godot --headless --path . -s tests/dump_cards.gd # Kartenpool aus Code nach JSON schreiben (nur bei Bedarf) +DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots +``` + +## Export + +Export-Vorlagen für Godot 4.7 installieren (Editor: Editor → Export-Vorlagen verwalten), dann: + +``` +godot --headless --path . --export-release Linux build/dinger-linux.x86_64 +godot --headless --path . --export-release Windows build/dinger-windows.exe +``` + +## Aufbau + +- `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/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) +- `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen +- `scripts/campaign.gd` – Kampagnenstufen +- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton +- `scenes/main.tscn` – Startszene +- `scripts/net.gd` – Netzwerkspiel (ENet) +- `translations/ui.csv` – englische Oberflächentexte diff --git a/anforderungen.md b/anforderungen.md new file mode 100644 index 0000000..533b399 --- /dev/null +++ b/anforderungen.md @@ -0,0 +1,24 @@ +# Dinger + +## Grundidee +- Kartenspiel wie zum Beispiel Magic +- PC-Spiel +- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln +- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl +- Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt +- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich +- Geführte erste Partie als Einstieg + +## Sammeln +- Booster öffnen +- Karten haben Seltenheiten +- Aus der Sammlung werden Decks gebaut (mehrere Slots) +- Überschüssige Karten lassen sich zerlegen und neue herstellen + +## Elemente +- Karten gehören zu Elementen +- Elemente sind kombinierbar, daraus entstehen viele Varianten +- Gleiche Elemente sind auch eine Kombo (Feuer + Feuer) +- Jedes Element und jede Kombo hat eine eigene Spielweise +- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus +- Verdeckte Fallen bringen Bluff und Reaktion im gegnerischen Zug diff --git a/assets/fonts/DejaVuSans-Bold.ttf b/assets/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..426812b Binary files /dev/null and b/assets/fonts/DejaVuSans-Bold.ttf differ diff --git a/assets/fonts/DejaVuSans-Bold.ttf.import b/assets/fonts/DejaVuSans-Bold.ttf.import new file mode 100644 index 0000000..3c53181 --- /dev/null +++ b/assets/fonts/DejaVuSans-Bold.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://jqogbchdskrc" +path="res://.godot/imported/DejaVuSans-Bold.ttf-28339d4fde761e3a49fa62b16c3f5900.fontdata" + +[deps] + +source_file="res://assets/fonts/DejaVuSans-Bold.ttf" +dest_files=["res://.godot/imported/DejaVuSans-Bold.ttf-28339d4fde761e3a49fa62b16c3f5900.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=3 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/assets/fonts/DejaVuSans.ttf b/assets/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..fb0bd94 Binary files /dev/null and b/assets/fonts/DejaVuSans.ttf differ diff --git a/assets/fonts/DejaVuSans.ttf.import b/assets/fonts/DejaVuSans.ttf.import new file mode 100644 index 0000000..7782bc6 --- /dev/null +++ b/assets/fonts/DejaVuSans.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://l7leg081apqp" +path="res://.godot/imported/DejaVuSans.ttf-0dcd47e426510e02f045e926ff1bfdf0.fontdata" + +[deps] + +source_file="res://assets/fonts/DejaVuSans.ttf" +dest_files=["res://.godot/imported/DejaVuSans.ttf-0dcd47e426510e02f045e926ff1bfdf0.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=3 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/data/karten.json b/data/karten.json new file mode 100644 index 0000000..8b2eca9 --- /dev/null +++ b/data/karten.json @@ -0,0 +1,2002 @@ +{ + "karten": [ + { + "id": "f_funke", + "name": "Funke", + "elements": [ + "Feuer" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_any", + "amount": 2 + } + ] + }, + { + "id": "f_kobold", + "name": "Feuerkobold", + "elements": [ + "Feuer" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 2, + "health": 1 + }, + { + "id": "f_wolf", + "name": "Flammenwolf", + "elements": [ + "Feuer" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 3, + "health": 2 + }, + { + "id": "f_feuerball", + "name": "Feuerball", + "elements": [ + "Feuer" + ], + "cost": 3, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_any", + "amount": 4 + } + ] + }, + { + "id": "f_asche", + "name": "Aschekrieger", + "elements": [ + "Feuer" + ], + "cost": 3, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 4, + "health": 2 + }, + { + "id": "f_drache", + "name": "Feuerdrache", + "elements": [ + "Feuer" + ], + "cost": 5, + "rarity": "selten", + "kind": "Wesen", + "attack": 6, + "health": 4, + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 2 + } + ] + }, + { + "id": "w_eis", + "name": "Eissplitter", + "elements": [ + "Wasser" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "freeze", + "target": "enemy_creature" + } + ] + }, + { + "id": "w_geist", + "name": "Flussgeist", + "elements": [ + "Wasser" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 1, + "health": 2, + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "w_reiter", + "name": "Wellenreiter", + "elements": [ + "Wasser" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 2, + "health": 3 + }, + { + "id": "w_gezeiten", + "name": "Gezeiten", + "elements": [ + "Wasser" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 2 + } + ] + }, + { + "id": "w_frost", + "name": "Frostmagier", + "elements": [ + "Wasser" + ], + "cost": 3, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 2, + "health": 4, + "effects": [ + { + "kind": "freeze", + "target": "enemy_creature" + } + ] + }, + { + "id": "w_kraken", + "name": "Kraken", + "elements": [ + "Wasser" + ], + "cost": 5, + "rarity": "selten", + "kind": "Wesen", + "attack": 4, + "health": 6, + "effects": [ + { + "kind": "freeze", + "target": "all_enemy_creatures" + } + ] + }, + { + "id": "e_wall", + "name": "Steinwall", + "elements": [ + "Erde" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 0, + "health": 4, + "keywords": [ + "Wächter" + ] + }, + { + "id": "e_wurzel", + "name": "Wurzelwächter", + "elements": [ + "Erde" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 2, + "health": 4, + "keywords": [ + "Wächter" + ] + }, + { + "id": "e_haerten", + "name": "Verhärten", + "elements": [ + "Erde" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "buff", + "target": "friendly_creature", + "amount": 2, + "amount2": 2 + } + ] + }, + { + "id": "e_golem", + "name": "Felsgolem", + "elements": [ + "Erde" + ], + "cost": 3, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 3, + "health": 5 + }, + { + "id": "e_troll", + "name": "Bergtroll", + "elements": [ + "Erde" + ], + "cost": 4, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 4, + "health": 6, + "keywords": [ + "Wächter" + ] + }, + { + "id": "e_baum", + "name": "Uralter Baum", + "elements": [ + "Erde" + ], + "cost": 5, + "rarity": "selten", + "kind": "Wesen", + "attack": 3, + "health": 9, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "heal", + "target": "self_hero", + "amount": 5 + } + ] + }, + { + "id": "l_wind", + "name": "Windgeist", + "elements": [ + "Luft" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 1, + "health": 1, + "keywords": [ + "Flink" + ] + }, + { + "id": "l_falke", + "name": "Falke", + "elements": [ + "Luft" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 3, + "health": 1, + "keywords": [ + "Flink" + ] + }, + { + "id": "l_stoss", + "name": "Windstoß", + "elements": [ + "Luft" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "bounce", + "target": "enemy_creature" + } + ] + }, + { + "id": "l_taenzer", + "name": "Sturmtänzer", + "elements": [ + "Luft" + ], + "cost": 3, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 3, + "health": 3, + "keywords": [ + "Flink" + ] + }, + { + "id": "l_schlange", + "name": "Luftschlange", + "elements": [ + "Luft" + ], + "cost": 3, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 2, + "health": 2, + "keywords": [ + "Doppelschlag" + ] + }, + { + "id": "l_greif", + "name": "Greif", + "elements": [ + "Luft" + ], + "cost": 5, + "rarity": "selten", + "kind": "Wesen", + "attack": 5, + "health": 4, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "ff_walze", + "name": "Flammenwalze", + "elements": [ + "Feuer", + "Feuer" + ], + "cost": 4, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 3 + } + ] + }, + { + "id": "ff_elementar", + "name": "Inferno-Elementar", + "elements": [ + "Feuer", + "Feuer" + ], + "cost": 6, + "rarity": "episch", + "kind": "Wesen", + "attack": 7, + "health": 5, + "effects": [ + { + "kind": "damage", + "target": "all_enemy_heroes", + "amount": 3 + } + ] + }, + { + "id": "ff_phoenix", + "name": "Phönix", + "elements": [ + "Feuer", + "Feuer" + ], + "cost": 7, + "rarity": "legendär", + "kind": "Wesen", + "attack": 8, + "health": 6, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "damage", + "target": "all_enemies", + "amount": 4 + } + ] + }, + { + "id": "ww_eiszeit", + "name": "Eiszeit", + "elements": [ + "Wasser", + "Wasser" + ], + "cost": 4, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "freeze", + "target": "all_enemy_creatures" + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "ww_schrecken", + "name": "Tiefseeschrecken", + "elements": [ + "Wasser", + "Wasser" + ], + "cost": 6, + "rarity": "episch", + "kind": "Wesen", + "attack": 5, + "health": 7, + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 2 + } + ] + }, + { + "id": "ww_leviathan", + "name": "Leviathan", + "elements": [ + "Wasser", + "Wasser" + ], + "cost": 8, + "rarity": "legendär", + "kind": "Wesen", + "attack": 8, + "health": 8, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 2 + }, + { + "kind": "heal", + "target": "self_hero", + "amount": 5 + } + ] + }, + { + "id": "ee_bastion", + "name": "Felsbastion", + "elements": [ + "Erde", + "Erde" + ], + "cost": 3, + "rarity": "selten", + "kind": "Wesen", + "attack": 1, + "health": 7, + "keywords": [ + "Wächter", + "Schild" + ] + }, + { + "id": "ee_riese", + "name": "Bergriese", + "elements": [ + "Erde", + "Erde" + ], + "cost": 6, + "rarity": "episch", + "kind": "Wesen", + "attack": 6, + "health": 9, + "keywords": [ + "Wächter" + ] + }, + { + "id": "ee_titan", + "name": "Titan der Erde", + "elements": [ + "Erde", + "Erde" + ], + "cost": 8, + "rarity": "legendär", + "kind": "Wesen", + "attack": 8, + "health": 12, + "keywords": [ + "Wächter", + "Schild" + ], + "effects": [ + { + "kind": "heal", + "target": "self_hero", + "amount": 6 + } + ] + }, + { + "id": "ll_wirbel", + "name": "Wirbelsturm", + "elements": [ + "Luft", + "Luft" + ], + "cost": 3, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "bounce", + "target": "enemy_creature" + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "ll_adler", + "name": "Sturmadler", + "elements": [ + "Luft", + "Luft" + ], + "cost": 5, + "rarity": "episch", + "kind": "Wesen", + "attack": 4, + "health": 4, + "keywords": [ + "Flink", + "Doppelschlag" + ] + }, + { + "id": "ll_herr", + "name": "Herr der Winde", + "elements": [ + "Luft", + "Luft" + ], + "cost": 7, + "rarity": "legendär", + "kind": "Wesen", + "attack": 6, + "health": 6, + "keywords": [ + "Flink", + "Doppelschlag" + ], + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 2 + } + ] + }, + { + "id": "fw_wolke", + "name": "Dampfwolke", + "elements": [ + "Feuer", + "Wasser" + ], + "cost": 2, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_creature", + "amount": 2 + }, + { + "kind": "freeze", + "target": "enemy_creature" + } + ] + }, + { + "id": "fw_kessel", + "name": "Kesselgeist", + "elements": [ + "Feuer", + "Wasser" + ], + "cost": 4, + "rarity": "episch", + "kind": "Wesen", + "attack": 3, + "health": 4, + "effects": [ + { + "kind": "damage", + "target": "enemy_any", + "amount": 2 + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "fw_geysir", + "name": "Geysir-Drache", + "elements": [ + "Feuer", + "Wasser" + ], + "cost": 7, + "rarity": "legendär", + "kind": "Wesen", + "attack": 6, + "health": 6, + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 3 + }, + { + "kind": "freeze", + "target": "all_enemy_creatures" + } + ] + }, + { + "id": "fe_golem", + "name": "Lavagolem", + "elements": [ + "Feuer", + "Erde" + ], + "cost": 3, + "rarity": "selten", + "kind": "Wesen", + "attack": 3, + "health": 4, + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 1 + } + ] + }, + { + "id": "fe_wall", + "name": "Magmawall", + "elements": [ + "Feuer", + "Erde" + ], + "cost": 5, + "rarity": "episch", + "kind": "Wesen", + "attack": 2, + "health": 8, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 2 + } + ] + }, + { + "id": "fe_vulkan", + "name": "Vulkanherz", + "elements": [ + "Feuer", + "Erde" + ], + "cost": 7, + "rarity": "legendär", + "kind": "Wesen", + "attack": 7, + "health": 8, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "damage", + "target": "enemy_any", + "amount": 4 + } + ] + }, + { + "id": "fl_schlag", + "name": "Blitzschlag", + "elements": [ + "Feuer", + "Luft" + ], + "cost": 2, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_any", + "amount": 3 + } + ] + }, + { + "id": "fl_vogel", + "name": "Donnervogel", + "elements": [ + "Feuer", + "Luft" + ], + "cost": 4, + "rarity": "episch", + "kind": "Wesen", + "attack": 3, + "health": 2, + "keywords": [ + "Flink", + "Doppelschlag" + ] + }, + { + "id": "fl_gott", + "name": "Gewittergott", + "elements": [ + "Feuer", + "Luft" + ], + "cost": 7, + "rarity": "legendär", + "kind": "Wesen", + "attack": 7, + "health": 5, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "damage", + "target": "all_enemy_heroes", + "amount": 3 + } + ] + }, + { + "id": "we_kroete", + "name": "Sumpfkröte", + "elements": [ + "Wasser", + "Erde" + ], + "cost": 2, + "rarity": "selten", + "kind": "Wesen", + "attack": 1, + "health": 5, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "freeze", + "target": "enemy_creature" + } + ] + }, + { + "id": "we_moor", + "name": "Moorwächter", + "elements": [ + "Wasser", + "Erde" + ], + "cost": 5, + "rarity": "episch", + "kind": "Wesen", + "attack": 3, + "health": 8, + "keywords": [ + "Wächter", + "Schild" + ] + }, + { + "id": "we_koloss", + "name": "Sumpfkoloss", + "elements": [ + "Wasser", + "Erde" + ], + "cost": 8, + "rarity": "legendär", + "kind": "Wesen", + "attack": 5, + "health": 14, + "keywords": [ + "Wächter", + "Schild" + ], + "effects": [ + { + "kind": "freeze", + "target": "all_enemy_creatures" + } + ] + }, + { + "id": "wl_boe", + "name": "Sturmbö", + "elements": [ + "Wasser", + "Luft" + ], + "cost": 2, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "freeze", + "target": "enemy_creature" + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "wl_nebel", + "name": "Nebelreiter", + "elements": [ + "Wasser", + "Luft" + ], + "cost": 4, + "rarity": "episch", + "kind": "Wesen", + "attack": 3, + "health": 3, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "bounce", + "target": "enemy_creature" + } + ] + }, + { + "id": "wl_koenigin", + "name": "Sturmkönigin", + "elements": [ + "Wasser", + "Luft" + ], + "cost": 7, + "rarity": "legendär", + "kind": "Wesen", + "attack": 5, + "health": 6, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "bounce", + "target": "all_enemy_creatures" + } + ] + }, + { + "id": "el_laeufer", + "name": "Sandläufer", + "elements": [ + "Erde", + "Luft" + ], + "cost": 2, + "rarity": "selten", + "kind": "Wesen", + "attack": 2, + "health": 3, + "keywords": [ + "Flink", + "Schild" + ] + }, + { + "id": "el_duene", + "name": "Dünenwächter", + "elements": [ + "Erde", + "Luft" + ], + "cost": 4, + "rarity": "episch", + "kind": "Wesen", + "attack": 3, + "health": 5, + "keywords": [ + "Wächter", + "Flink" + ] + }, + { + "id": "el_koenig", + "name": "Wüstenkönig", + "elements": [ + "Erde", + "Luft" + ], + "cost": 7, + "rarity": "legendär", + "kind": "Wesen", + "attack": 6, + "health": 8, + "keywords": [ + "Wächter", + "Flink", + "Schild" + ], + "effects": [ + { + "kind": "buff", + "target": "all_friendly_creatures", + "amount": 1, + "amount2": 1 + } + ] + }, + { + "id": "f_glutpfeil", + "name": "Glutpfeil", + "elements": [ + "Feuer" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_creature", + "amount": 3 + } + ] + }, + { + "id": "f_elementar", + "name": "Feuerelementar", + "elements": [ + "Feuer" + ], + "cost": 4, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 5, + "health": 3 + }, + { + "id": "f_ritter", + "name": "Lodernder Ritter", + "elements": [ + "Feuer" + ], + "cost": 4, + "rarity": "selten", + "kind": "Wesen", + "attack": 3, + "health": 3, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "damage", + "target": "enemy_any", + "amount": 1 + } + ] + }, + { + "id": "f_sturm", + "name": "Flammensturm", + "elements": [ + "Feuer" + ], + "cost": 6, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 4 + } + ] + }, + { + "id": "w_schild", + "name": "Gezeitenschild", + "elements": [ + "Wasser" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "buff", + "target": "friendly_creature", + "amount2": 3 + } + ] + }, + { + "id": "w_qualle", + "name": "Quallenschwarm", + "elements": [ + "Wasser" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 2, + "health": 2, + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "w_tiefen", + "name": "Tiefenwächter", + "elements": [ + "Wasser" + ], + "cost": 4, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 3, + "health": 6, + "keywords": [ + "Wächter" + ] + }, + { + "id": "w_flut", + "name": "Flutwelle", + "elements": [ + "Wasser" + ], + "cost": 5, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "freeze", + "target": "all_enemy_creatures" + }, + { + "kind": "heal", + "target": "self_hero", + "amount": 3 + } + ] + }, + { + "id": "e_steinhaut", + "name": "Steinhaut", + "elements": [ + "Erde" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "shield", + "target": "friendly_creature" + } + ] + }, + { + "id": "e_kiesel", + "name": "Kieselgolem", + "elements": [ + "Erde" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 2, + "health": 3 + }, + { + "id": "e_hirte", + "name": "Baumhirte", + "elements": [ + "Erde" + ], + "cost": 4, + "rarity": "selten", + "kind": "Wesen", + "attack": 2, + "health": 6, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "heal", + "target": "self_hero", + "amount": 3 + } + ] + }, + { + "id": "e_beben", + "name": "Erdbeben", + "elements": [ + "Erde" + ], + "cost": 5, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 2 + }, + { + "kind": "buff", + "target": "all_friendly_creatures", + "amount2": 2 + } + ] + }, + { + "id": "l_brise", + "name": "Brise", + "elements": [ + "Luft" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "l_fuchs", + "name": "Wolkenfuchs", + "elements": [ + "Luft" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 2, + "health": 2, + "keywords": [ + "Flink" + ] + }, + { + "id": "l_himmel", + "name": "Himmelsstoß", + "elements": [ + "Luft" + ], + "cost": 3, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_any", + "amount": 2 + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "l_laeufer", + "name": "Windläufer", + "elements": [ + "Luft" + ], + "cost": 4, + "rarity": "selten", + "kind": "Wesen", + "attack": 3, + "health": 4, + "keywords": [ + "Flink" + ] + }, + { + "id": "ff_glut", + "name": "Glutherz", + "elements": [ + "Feuer", + "Feuer" + ], + "cost": 3, + "rarity": "selten", + "kind": "Wesen", + "attack": 3, + "health": 3, + "effects": [ + { + "kind": "damage", + "target": "all_enemy_heroes", + "amount": 2 + } + ] + }, + { + "id": "ww_abyss", + "name": "Abyssalwächter", + "elements": [ + "Wasser", + "Wasser" + ], + "cost": 5, + "rarity": "selten", + "kind": "Wesen", + "attack": 3, + "health": 8, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "ee_faust", + "name": "Felsenfaust", + "elements": [ + "Erde", + "Erde" + ], + "cost": 4, + "rarity": "selten", + "kind": "Wesen", + "attack": 5, + "health": 6, + "keywords": [ + "Schild" + ] + }, + { + "id": "ll_zwilling", + "name": "Zwillingswind", + "elements": [ + "Luft", + "Luft" + ], + "cost": 4, + "rarity": "selten", + "kind": "Wesen", + "attack": 2, + "health": 3, + "keywords": [ + "Flink", + "Doppelschlag" + ] + }, + { + "id": "fw_nebel", + "name": "Nebelgeist", + "elements": [ + "Feuer", + "Wasser" + ], + "cost": 3, + "rarity": "selten", + "kind": "Wesen", + "attack": 2, + "health": 4, + "effects": [ + { + "kind": "damage", + "target": "all_enemy_creatures", + "amount": 1 + }, + { + "kind": "freeze", + "target": "enemy_creature" + } + ] + }, + { + "id": "fe_schlange", + "name": "Magmaschlange", + "elements": [ + "Feuer", + "Erde" + ], + "cost": 4, + "rarity": "selten", + "kind": "Wesen", + "attack": 4, + "health": 4, + "effects": [ + { + "kind": "damage", + "target": "enemy_creature", + "amount": 2 + } + ] + }, + { + "id": "fl_bote", + "name": "Sturmbote", + "elements": [ + "Feuer", + "Luft" + ], + "cost": 3, + "rarity": "selten", + "kind": "Wesen", + "attack": 3, + "health": 2, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "damage", + "target": "enemy_any", + "amount": 1 + } + ] + }, + { + "id": "we_moorkroete", + "name": "Moorkröte", + "elements": [ + "Wasser", + "Erde" + ], + "cost": 3, + "rarity": "selten", + "kind": "Wesen", + "attack": 2, + "health": 6, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "heal", + "target": "self_hero", + "amount": 3 + } + ] + }, + { + "id": "wl_boen", + "name": "Böenreiter", + "elements": [ + "Wasser", + "Luft" + ], + "cost": 3, + "rarity": "selten", + "kind": "Wesen", + "attack": 3, + "health": 3, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "el_duenen", + "name": "Dünenläufer", + "elements": [ + "Erde", + "Luft" + ], + "cost": 3, + "rarity": "selten", + "kind": "Wesen", + "attack": 3, + "health": 3, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "shield", + "target": "friendly_creature" + } + ] + }, + { + "id": "f_funkenwerfer", + "name": "Funkenwerfer", + "elements": [ + "Feuer" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 2, + "health": 2, + "effects": [ + { + "kind": "damage", + "target": "enemy_creature", + "amount": 1 + } + ] + }, + { + "id": "f_zunge", + "name": "Flammenzunge", + "elements": [ + "Feuer" + ], + "cost": 5, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 5, + "health": 5 + }, + { + "id": "f_magma", + "name": "Magmaschlag", + "elements": [ + "Feuer" + ], + "cost": 4, + "rarity": "selten", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_creature", + "amount": 5 + } + ] + }, + { + "id": "f_falle", + "name": "Feuerfalle", + "elements": [ + "Feuer" + ], + "cost": 2, + "rarity": "selten", + "kind": "Falle", + "effects": [ + { + "kind": "damage", + "target": "trigger_source", + "amount": 3 + } + ], + "trigger": "attacked" + }, + { + "id": "w_schlange", + "name": "Flussschlange", + "elements": [ + "Wasser" + ], + "cost": 3, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 3, + "health": 3 + }, + { + "id": "w_taucher", + "name": "Perlentaucher", + "elements": [ + "Wasser" + ], + "cost": 4, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 3, + "health": 4, + "effects": [ + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "w_quelle", + "name": "Heilende Quelle", + "elements": [ + "Wasser" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "heal", + "target": "self_hero", + "amount": 6 + } + ] + }, + { + "id": "w_falle", + "name": "Frostfalle", + "elements": [ + "Wasser" + ], + "cost": 2, + "rarity": "selten", + "kind": "Falle", + "effects": [ + { + "kind": "cancel", + "target": "none" + }, + { + "kind": "freeze", + "target": "trigger_source" + } + ], + "trigger": "attacked" + }, + { + "id": "e_lehm", + "name": "Lehmkrieger", + "elements": [ + "Erde" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 3, + "health": 2 + }, + { + "id": "e_steinschlag", + "name": "Steinschlag", + "elements": [ + "Erde" + ], + "cost": 3, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "damage", + "target": "enemy_creature", + "amount": 3 + } + ] + }, + { + "id": "e_hueter", + "name": "Waldhüter", + "elements": [ + "Erde" + ], + "cost": 5, + "rarity": "selten", + "kind": "Wesen", + "attack": 5, + "health": 7, + "keywords": [ + "Wächter" + ] + }, + { + "id": "e_falle", + "name": "Erdwall", + "elements": [ + "Erde" + ], + "cost": 2, + "rarity": "selten", + "kind": "Falle", + "effects": [ + { + "kind": "summon", + "target": "none", + "card_id": "e_wall" + } + ], + "trigger": "attacked" + }, + { + "id": "l_bote", + "name": "Windbote", + "elements": [ + "Luft" + ], + "cost": 1, + "rarity": "gewöhnlich", + "kind": "Wesen", + "attack": 1, + "health": 2, + "keywords": [ + "Flink" + ] + }, + { + "id": "l_rueckenwind", + "name": "Rückenwind", + "elements": [ + "Luft" + ], + "cost": 2, + "rarity": "gewöhnlich", + "kind": "Zauber", + "effects": [ + { + "kind": "buff", + "target": "friendly_creature", + "amount": 2 + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "l_jaeger", + "name": "Himmelsjäger", + "elements": [ + "Luft" + ], + "cost": 5, + "rarity": "selten", + "kind": "Wesen", + "attack": 5, + "health": 3, + "keywords": [ + "Flink" + ] + }, + { + "id": "l_falle", + "name": "Windfalle", + "elements": [ + "Luft" + ], + "cost": 2, + "rarity": "selten", + "kind": "Falle", + "effects": [ + { + "kind": "bounce", + "target": "trigger_source" + } + ], + "trigger": "creature_played" + }, + { + "id": "f_aschefalle", + "name": "Aschefalle", + "elements": [ + "Feuer" + ], + "cost": 3, + "rarity": "episch", + "kind": "Falle", + "trigger": "creature_played", + "effects": [ + { + "kind": "damage", + "target": "trigger_source", + "amount": 3 + } + ] + }, + { + "id": "w_nebelfalle", + "name": "Nebelfalle", + "elements": [ + "Wasser" + ], + "cost": 1, + "rarity": "selten", + "kind": "Falle", + "trigger": "attacked", + "effects": [ + { + "kind": "cancel", + "target": "none" + }, + { + "kind": "draw", + "target": "none", + "amount": 1 + } + ] + }, + { + "id": "e_dornen", + "name": "Dornenfalle", + "elements": [ + "Erde" + ], + "cost": 3, + "rarity": "episch", + "kind": "Falle", + "trigger": "attacked", + "effects": [ + { + "kind": "damage", + "target": "trigger_source", + "amount": 2 + }, + { + "kind": "heal", + "target": "self_hero", + "amount": 3 + } + ] + }, + { + "id": "e_sandfalle", + "name": "Sandfalle", + "elements": [ + "Erde" + ], + "cost": 2, + "rarity": "selten", + "kind": "Falle", + "trigger": "creature_played", + "effects": [ + { + "kind": "freeze", + "target": "trigger_source" + } + ] + }, + { + "id": "l_boenfalle", + "name": "Böenfalle", + "elements": [ + "Luft" + ], + "cost": 3, + "rarity": "episch", + "kind": "Falle", + "trigger": "attacked", + "effects": [ + { + "kind": "bounce", + "target": "trigger_source" + } + ] + }, + { + "id": "l_spiegel", + "name": "Spiegelfalle", + "elements": [ + "Luft" + ], + "cost": 4, + "rarity": "episch", + "kind": "Falle", + "trigger": "creature_played", + "effects": [ + { + "kind": "summon", + "target": "none", + "card_id": "l_wind" + } + ] + }, + { + "id": "fff_sonne", + "name": "Kern der Sonne", + "elements": [ + "Feuer", + "Feuer", + "Feuer" + ], + "cost": 9, + "rarity": "mythisch", + "kind": "Wesen", + "attack": 9, + "health": 7, + "keywords": [ + "Flink" + ], + "effects": [ + { + "kind": "damage", + "target": "all_enemies", + "amount": 5 + } + ] + }, + { + "id": "www_ozean", + "name": "Herz des Ozeans", + "elements": [ + "Wasser", + "Wasser", + "Wasser" + ], + "cost": 9, + "rarity": "mythisch", + "kind": "Wesen", + "attack": 7, + "health": 10, + "keywords": [ + "Wächter" + ], + "effects": [ + { + "kind": "freeze", + "target": "all_enemy_creatures" + }, + { + "kind": "draw", + "target": "none", + "amount": 3 + } + ] + }, + { + "id": "eee_gebirge", + "name": "Seele des Gebirges", + "elements": [ + "Erde", + "Erde", + "Erde" + ], + "cost": 10, + "rarity": "mythisch", + "kind": "Wesen", + "attack": 10, + "health": 15, + "keywords": [ + "Wächter", + "Schild" + ], + "effects": [ + { + "kind": "heal", + "target": "self_hero", + "amount": 10 + }, + { + "kind": "buff", + "target": "all_friendly_creatures", + "amount": 2, + "amount2": 2 + } + ] + }, + { + "id": "lll_himmel", + "name": "Atem des Himmels", + "elements": [ + "Luft", + "Luft", + "Luft" + ], + "cost": 9, + "rarity": "mythisch", + "kind": "Wesen", + "attack": 7, + "health": 7, + "keywords": [ + "Flink", + "Doppelschlag" + ], + "effects": [ + { + "kind": "bounce", + "target": "all_enemy_creatures" + } + ] + } + ] +} \ No newline at end of file diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..20b7f8b --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,73 @@ +[preset.0] + +name="Linux" +platform="Linux" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="*.json" +exclude_filter="python/*" +export_path="build/dinger-linux.x86_64" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=true +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +binary_format/architecture="x86_64" + +[preset.1] + +name="Windows" +platform="Windows Desktop" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="*.json" +exclude_filter="python/*" +export_path="build/dinger-windows.exe" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=true +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +binary_format/architecture="x86_64" +application/modify_resources=true +application/icon="" +application/console_wrapper_icon="" +application/icon_interpolation=4 +application/file_version="" +application/product_version="" +application/company_name="Dinger" +application/product_name="Dinger" +application/file_description="Dinger Kartenspiel" +application/copyright="" +application/trademarks="" +application/export_angle=0 +application/export_d3d12=0 +application/d3d12_agility_sdk_multiarch=true diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..0177193 --- /dev/null +++ b/project.godot @@ -0,0 +1,28 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. + +config_version=5 + +[application] + +config/name="Dinger" +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") + +[display] + +window/size/viewport_width=1280 +window/size/viewport_height=800 +window/stretch/mode="canvas_items" +window/stretch/aspect="keep" + +[internationalization] + +locale/translations=PackedStringArray("res://translations/ui.en.translation") +locale/fallback="de" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..4c9b75f --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/main.gd" id="1"] + +[node name="Main" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1") diff --git a/scripts/ai.gd b/scripts/ai.gd new file mode 100644 index 0000000..37d8bb1 --- /dev/null +++ b/scripts/ai.gd @@ -0,0 +1,269 @@ +class_name AI +## KI mit einfacher Vorausschau: jede mögliche Aktion wird auf einer Kopie des +## Spiels ausgeführt und die Stellung danach bewertet. Die gierige Variante +## (choose_action_greedy) dient als Vergleich. + + +# --- Bewertung ------------------------------------------------------------------- + +static func creature_value(c: Game.Creature) -> float: + var v := float(c.power() + c.health) + if c.taunt(): + v += 2.0 + if c.shield: + v += 1.5 + if c.attacks_per_turn() == 2: + v += c.power() + if c.frozen: + v -= c.power() * 0.5 + return v + + +static func board_value(p: Game.Player) -> float: + var v := 0.0 + for c in p.board: + v += creature_value(c) + return v + + +## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, +## die der Gegner negativ. Ein besiegter Gegner ist viel wert. +static func evaluate(g: Game, p: Game.Player) -> float: + if not p.alive(): + 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 alive_enemies := 0 + for e in g.players: + if e == p: + continue + if not e.alive(): + score += 40.0 + continue + alive_enemies += 1 + score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2 + if e.life <= 6: + score += 3.0 # fast besiegt: dranbleiben + if alive_enemies == 0: + return 10000.0 + return score + + +# --- Vorausschau --------------------------------------------------------------- + +static func candidate_actions(game: Game, p: Game.Player) -> Array: + var out := [] + for card in p.hand: + if not game.can_play(p, card)[0]: + continue + var tt: String = card.target_type() + if tt == "": + out.append({"type": "play", "card": card, "target": null}) + else: + var valid := game.valid_targets(p, tt) + if valid.is_empty(): + out.append({"type": "play", "card": card, "target": null}) + for t in valid: + out.append({"type": "play", "card": card, "target": t}) + for cr in p.board: + if not cr.can_attack(): + continue + for e in game.enemies_of(p): + for t in e.board + [e]: + if game.can_attack(cr, t)[0]: + out.append({"type": "attack", "creature": cr, "target": t}) + return out + + +## Führt eine Aktion des Originals auf der Kopie aus. +static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void: + var p2: Game.Player = g2.players[game.current_idx] + var target = null + if action.get("target") != null: + target = map_target(g2, game, action.target) + if action.type == "play": + g2.play_card(p2, action.card, target) + else: + g2.attack(g2.find_creature(action.creature.uid), target) + + +static func map_target(g2: Game, game: Game, t): + if t is Game.Creature: + return g2.find_creature(t.uid) + return g2.players[game.players.find(t)] + + +const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe + + +## Mulligan der KI: teure Karten im ersten Zug zurücklegen. +static func mulligan_cards(p: Game.Player) -> Array: + return p.hand.filter(func(c): return c.cost >= 5) + + +## Beste Aktion nach einer Vorausschau von zwei Schritten: jede Aktion wird auf einer +## Kopie ausgeführt und mit ihrem besten Folgeschritt bewertet (z. B. verstärken, dann angreifen). +static func choose_action(game: Game, p: Game.Player) -> Dictionary: + if game.winner != "" or game.enemies_of(p).is_empty(): + return {"type": "end"} + if not p.mulligan_done and p.turns == 1 and p == game.current(): + return {"type": "mulligan", "cards": mulligan_cards(p)} + var best := {"type": "end"} + var best_score := evaluate(game, p) + 0.01 + for action in candidate_actions(game, p): + var g2 := game.clone_for(p) + apply_on_clone(g2, game, action) + var p2: Game.Player = g2.players[game.current_idx] + var score := evaluate(g2, p2) + if g2.winner == "": + var count := 0 + for f in candidate_actions(g2, p2): + if count >= FOLLOW_UPS: + break + count += 1 + var g3 := g2.clone_for(p2) + apply_on_clone(g3, g2, f) + score = maxf(score, evaluate(g3, g3.players[g2.current_idx]) - 0.001) + if score > best_score: + best_score = score + best = action + return best + + +# --- Gierige Vergleichs-KI ------------------------------------------------------- + +static func _kills(attacker, defender) -> bool: + return not defender.shield and defender.health <= attacker.power() + + +static func _value(c) -> int: + return c.power() + c.health + (2 if c.taunt() else 0) + + +static func _best(arr: Array, key: Callable): + var best = null + var best_v := 0 + for x in arr: + var v: int = key.call(x) + if best == null or v > best_v: + best = x + best_v = v + return best + + +static func _threat_score(h) -> int: + var board_attack := 0 + for c in h.board: + board_attack += c.power() + return h.life - board_attack + + +static func _weakest(heroes: Array): + var best = null + for h in heroes: + if best == null or _threat_score(h) < _threat_score(best): + best = h + return best + + +static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: + if game.winner != "" or game.enemies_of(p).is_empty(): + return {"type": "end"} + if not p.mulligan_done and p.turns == 1 and p == game.current(): + return {"type": "mulligan", "cards": mulligan_cards(p)} + var playable := p.hand.filter(func(c): return game.can_play(p, c)[0]) + if not playable.is_empty(): + Game.shuffle(playable, game.rng) + var card = playable[0] + for c in playable: + if c.cost > card.cost: + card = c + return {"type": "play", "card": card, "target": pick_target(game, p, card)} + for cr in p.board: + if cr.can_attack(): + var t = pick_attack_target(game, p, cr) + if t != null: + return {"type": "attack", "creature": cr, "target": t} + return {"type": "end"} + + +static func pick_target(game: Game, p: Game.Player, card: Cards.Card): + var tt := card.target_type() + if tt == "": + return null + var valid := game.valid_targets(p, tt) + if valid.is_empty(): + return null + var fx: Cards.Effect = null + for f in card.effects: + if f.target == tt: + fx = f + break + var creatures := valid.filter(func(t): return t is Game.Creature) + var heroes := valid.filter(func(t): return t is Game.Player) + match fx.kind: + "damage": + var killable := creatures.filter(func(c): return not c.shield and c.health <= fx.amount) + if not killable.is_empty(): + return _best(killable, Callable(AI, "_value")) + if not heroes.is_empty(): + return _weakest(heroes) + return _best(creatures, Callable(AI, "_value")) + "freeze", "bounce": + return _best(creatures, func(c): return c.card.cost * 100 + c.power()) + "buff", "shield": + return _best(creatures, func(c): return (100 if c.can_attack() else 0) + c.power()) + return valid[0] + + +static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature): + var enemies := game.enemies_of(p) + var total := 0 + for c in p.board: + if c.can_attack(): + total += c.power() * c.attacks_left + var options := [] + for e in enemies: + var taunts: Array = e.board.filter(func(c): return c.taunt()) + if not taunts.is_empty(): + options.append_array(taunts) + else: + if total >= e.life: + return e + options.append_array(e.board) + options.append(e) + var creatures := options.filter(func(t): return t is Game.Creature) + var heroes := options.filter(func(t): return t is Game.Player) + var good := creatures.filter(func(c): return _kills(cr, c) and not _kills(c, cr)) + if not good.is_empty(): + return _best(good, Callable(AI, "_value")) + var trade := creatures.filter(func(c): return _kills(cr, c) and _value(c) > _value(cr)) + if not trade.is_empty(): + return _best(trade, Callable(AI, "_value")) + if not heroes.is_empty(): + return _weakest(heroes) + if not creatures.is_empty(): + return _best(creatures, func(c): return 1000 - c.health) + return null + + +# --- Ausführung ------------------------------------------------------------------- + +static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void: + match action.type: + "play": + game.play_card(p, action.card, action.target) + "attack": + game.attack(action.creature, action.target) + "mulligan": + game.mulligan(p, action.cards) + _: + game.end_turn() + + +## Spielt den ganzen Zug (für Simulationen und Tests). greedy: gierige KI statt Vorausschau. +static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: int = 100) -> void: + for i in limit: + var action := choose_action_greedy(game, p) if greedy else choose_action(game, p) + apply_action(game, p, action) + if action.type == "end" or game.winner != "": + return diff --git a/scripts/ai.gd.uid b/scripts/ai.gd.uid new file mode 100644 index 0000000..f9c3417 --- /dev/null +++ b/scripts/ai.gd.uid @@ -0,0 +1 @@ +uid://pewo135bho86 diff --git a/scripts/campaign.gd b/scripts/campaign.gd new file mode 100644 index 0000000..07fa111 --- /dev/null +++ b/scripts/campaign.gd @@ -0,0 +1,59 @@ +class_name Campaign +## Kampagne: zehn benannte Magier, jede Stufe mit mehr Gegnern am Tisch. + +const STAGES := [ + {"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "feuer_angriff"}, + {"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "leben_35"}, + {"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "waechter_leben"}, + {"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "bias": 1.2, "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": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "extra_karten"}, + {"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "resonanz_doppelt"}, + {"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "schild_wesen"}, + {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "bias": 2.5, "reward": 300, "perk": "alle_flink"}, + {"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "bias": 3.0, "reward": 500, "perk": "start_energie"}, +] + + +## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück. +const STAGES2 := [ + {"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "bias": 2.0, "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": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "bias": 2.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": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "bias": 2.5, "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": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "bias": 3.0, "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": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "bias": 3.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"]}, +] + + +static func total() -> int: + return STAGES.size() + STAGES2.size() + + +static func chapter(stage: int) -> int: + return 1 if stage < STAGES.size() else 2 + + +## Stufe mit einheitlichem Feld "perks". +static func stage(i: int) -> Dictionary: + if i < STAGES.size(): + var st: Dictionary = STAGES[i].duplicate() + st["perks"] = [st.perk] + return st + return STAGES2[i - STAGES.size()] + + +static func opponents(stage_index: int, r: RandomNumberGenerator) -> Array: + var st := stage(stage_index) + var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE + var boss := Game.Player.new(st.name, Game.build_opponent_deck(r, st.elements, st.bias), false, life, st.title) + boss.perks = Array(st.perks) + var out := [boss] + var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) + for k in extra: + out.append(Game.random_opponent(r, k + 1)) + return out diff --git a/scripts/campaign.gd.uid b/scripts/campaign.gd.uid new file mode 100644 index 0000000..582480d --- /dev/null +++ b/scripts/campaign.gd.uid @@ -0,0 +1 @@ +uid://bmpb6g16h42ps diff --git a/scripts/cards.gd b/scripts/cards.gd new file mode 100644 index 0000000..8562122 --- /dev/null +++ b/scripts/cards.gd @@ -0,0 +1,292 @@ +class_name Cards +## Elemente, Kombos und der Kartenpool. + +const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"] +const ELEMENT_COLORS := { + "Feuer": Color("be3c23"), + "Wasser": Color("2d64be"), + "Erde": Color("73552d"), + "Luft": Color("78aac8"), +} +# Jede Paarung ist eine Kombo, auch gleiche Elemente. +const COMBOS := { + "Feuer+Feuer": "Inferno", + "Wasser+Wasser": "Tiefsee", + "Erde+Erde": "Berg", + "Luft+Luft": "Orkan", + "Feuer+Wasser": "Dampf", + "Feuer+Erde": "Lava", + "Feuer+Luft": "Blitz", + "Wasser+Erde": "Schlamm", + "Wasser+Luft": "Sturm", + "Erde+Luft": "Sand", + # Dreifach-Kombos: die mythische Stufe + "Feuer+Feuer+Feuer": "Sonne", + "Wasser+Wasser+Wasser": "Ozean", + "Erde+Erde+Erde": "Gebirge", + "Luft+Luft+Luft": "Himmel", +} +# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird. +const FIELD_COMBO_TEXT := { + "Dampf": "Friere das stärkste gegnerische Wesen ein", + "Lava": "1 Schaden an alle gegnerischen Wesen", + "Blitz": "2 Schaden an den schwächsten Gegner", + "Schlamm": "Heile dich um 3", + "Sturm": "Ziehe 1 Karte", + "Sand": "Das neue Wesen erhält Schild", +} +const RESONANCE_TEXT := "Resonanz: Ein Wesen hat +1 Angriff je anderem eigenen Wesen, das ein Element mit ihm teilt (höchstens +4)." +const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"] +const RARITY_COLORS := { + "gewöhnlich": Color("aaaaaa"), + "selten": Color("4682e6"), + "episch": Color("aa46dc"), + "legendär": Color("f0aa28"), + "mythisch": Color("ff5a8c"), +} +const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"] +const KEYWORD_HELP := { + "Wächter": "Muss zuerst angegriffen werden.", + "Flink": "Kann sofort angreifen.", + "Schild": "Blockt den nächsten Schaden.", + "Doppelschlag": "Greift zweimal pro Zug an.", +} +const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"] +const DATA_PATH := "res://data/karten.json" + + +static func combo_key(elements: Array) -> String: + var sorted := elements.duplicate() + sorted.sort_custom(func(a, b): return ELEMENTS.find(a) < ELEMENTS.find(b)) + return "+".join(sorted) + + +static func combo_name(elements: Array) -> String: + if elements.size() == 1: + return elements[0] + return COMBOS[combo_key(elements)] + + +static func field_combo_effect(name: String) -> Effect: + match name: + "Dampf": + return Effect.new("freeze", "strongest_enemy_creature") + "Lava": + return Effect.new("damage", "all_enemy_creatures", 1) + "Blitz": + return Effect.new("damage", "weakest_enemy_hero", 2) + "Schlamm": + return Effect.new("heal", "self_hero", 3) + "Sturm": + return Effect.new("draw", "none", 1) + "Sand": + return Effect.new("shield", "self_creature") + return null + + +const TRIGGER_TEXT := { + "attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift", + "creature_played": "Wenn ein Gegner ein Wesen ausspielt", +} + + +class Effect extends RefCounted: + var kind: String # damage, heal, draw, freeze, bounce, buff, shield, summon, cancel + var target: String # siehe CHOSEN_TARGETS und Game.resolve() + var amount: int + var amount2: int + var card_id: String # für summon + + func _init(k: String, t: String, a: int = 0, a2: int = 0, cid: String = "") -> void: + kind = k + target = t + amount = a + amount2 = a2 + card_id = cid + + func to_dict() -> Dictionary: + var d := {"kind": kind, "target": target} + if amount != 0: + d["amount"] = amount + if amount2 != 0: + d["amount2"] = amount2 + if card_id != "": + d["card_id"] = card_id + return d + + static func from_dict(d: Dictionary) -> Effect: + return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", ""))) + + func text() -> String: + var n := str(amount) + if target == "trigger_source": + match kind: + "damage": + return n + " Schaden an den Angreifer" + "freeze": + return "Der Angreifer wird eingefroren" + "bounce": + return "Es geht zurück auf die Hand" + match kind: + "cancel": + return "Der Angriff verpufft" + "summon": + return "Beschwöre einen " + Cards.by_id(card_id).name + "damage": + match target: + "enemy_creature": + return n + " Schaden an ein gegnerisches Wesen" + "enemy_any": + return n + " Schaden an ein Ziel" + "all_enemy_creatures": + return n + " Schaden an alle gegnerischen Wesen" + "all_enemy_heroes": + return n + " Schaden an alle Gegner" + "all_enemies": + return n + " Schaden an alle Gegner und ihre Wesen" + "heal": + return "Heile dich um " + n + "draw": + return "Ziehe 1 Karte" if amount == 1 else "Ziehe " + n + " Karten" + "freeze": + if target == "enemy_creature": + return "Friere ein gegnerisches Wesen ein" + return "Friere alle gegnerischen Wesen ein" + "bounce": + if target == "enemy_creature": + return "Ein gegnerisches Wesen zurück auf die Hand" + return "Alle gegnerischen Wesen zurück auf die Hand" + "buff": + var b := "+%d/+%d" % [amount, amount2] + if target == "friendly_creature": + return "Ein befreundetes Wesen erhält " + b + return "Alle befreundeten Wesen erhalten " + b + "shield": + if target == "friendly_creature": + return "Ein befreundetes Wesen erhält Schild" + if target == "self_creature": + return "Erhält Schild" + return "Alle befreundeten Wesen erhalten Schild" + return "" + + +class Card extends RefCounted: + var id: String + var name: String + var elements: Array + var cost: int + var rarity: String + var kind: String # "Wesen", "Zauber" oder "Falle" + var attack: int = 0 + var health: int = 0 + var keywords: Array = [] + var effects: Array = [] + var trigger: String = "" # nur Fallen: attacked, creature_played + + func is_creature() -> bool: + return kind == "Wesen" + + func is_trap() -> bool: + return kind == "Falle" + + func to_dict() -> Dictionary: + var d := {"id": id, "name": name, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind} + if is_creature(): + d["attack"] = attack + d["health"] = health + if not keywords.is_empty(): + d["keywords"] = keywords + if not effects.is_empty(): + d["effects"] = effects.map(func(e): return e.to_dict()) + if trigger != "": + d["trigger"] = trigger + return d + + static func from_dict(d: Dictionary) -> Card: + var c := Card.new() + c.id = d.id + c.name = d.name + c.elements = Array(d.elements) + c.cost = int(d.cost) + c.rarity = d.rarity + c.kind = d.kind + c.attack = int(d.get("attack", 0)) + c.health = int(d.get("health", 0)) + c.keywords = Array(d.get("keywords", [])) + for e in d.get("effects", []): + c.effects.append(Effect.from_dict(e)) + c.trigger = str(d.get("trigger", "")) + return c + + func is_combo() -> bool: + return elements.size() >= 2 + + func combo() -> String: + return Cards.combo_name(elements) + + func target_type() -> String: + for fx in effects: + if fx.target in Cards.CHOSEN_TARGETS: + return fx.target + return "" + + func text() -> String: + var parts: Array[String] = [] + if not keywords.is_empty(): + var kws: Array[String] = [] + for k in Cards.KEYWORDS: + if k in keywords: + kws.append(k) + parts.append(", ".join(kws) + ".") + if not effects.is_empty(): + var fx: Array[String] = [] + for f in effects: + fx.append(f.text()) + var s := ", ".join(fx) + "." + if is_trap(): + if trigger == "creature_played": + s = s.replace("an den Angreifer", "an dieses Wesen").replace("Der Angreifer wird", "Es wird") + parts.append("Falle. %s: %s" % [Cards.TRIGGER_TEXT[trigger], s]) + else: + parts.append(("Beim Ausspielen: " + s) if is_creature() else s) + return " ".join(parts) + + +static var _all: Array = [] +static var _by_id: Dictionary = {} + + +static func all() -> Array: + if _all.is_empty(): + _build() + return _all + + +static func by_id(id: String) -> Card: + if _all.is_empty(): + _build() + return _by_id[id] + + +static func has_id(id) -> bool: + if _all.is_empty(): + _build() + return _by_id.has(id) + + +static func of_rarity(r: String) -> Array: + return all().filter(func(c): return c.rarity == r) + + +## Lädt den Kartenpool aus data/karten.json. +static func _build() -> void: + var json := JSON.new() + var ok := json.parse(FileAccess.get_file_as_string(DATA_PATH)) + assert(ok == OK and json.data is Dictionary and json.data.has("karten"), "data/karten.json fehlt oder ist ungültig") + _all = [] + _by_id = {} + for d in json.data.karten: + var c := Card.from_dict(d) + assert(not _by_id.has(c.id), "Karten-ID doppelt: " + c.id) + _by_id[c.id] = c + _all.append(c) diff --git a/scripts/cards.gd.uid b/scripts/cards.gd.uid new file mode 100644 index 0000000..b73c492 --- /dev/null +++ b/scripts/cards.gd.uid @@ -0,0 +1 @@ +uid://dmhun2kwnifg5 diff --git a/scripts/challenge.gd b/scripts/challenge.gd new file mode 100644 index 0000000..16ae475 --- /dev/null +++ b/scripts/challenge.gd @@ -0,0 +1,33 @@ +class_name Challenge +## Wöchentliche Herausforderung: feste Regelvariante, einmal pro Woche belohnt. + +const LIST := [ + {"id": "eile", "name": "Eile", "text": "Alle beginnen mit 3 Energie und 20 Leben, drei Gegner.", "n_opp": 3, + "rules": {"everyone_perk": "start_energie", "start_life": 20}, "reward": 200}, + {"id": "resonanz", "name": "Resonanzsturm", "text": "Resonanz zählt für alle doppelt, zwei Gegner.", "n_opp": 2, + "rules": {"everyone_perk": "resonanz_doppelt"}, "reward": 180}, + {"id": "flink", "name": "Blitzkrieg", "text": "Alle Wesen sind flink, vier Gegner.", "n_opp": 4, + "rules": {"everyone_perk": "alle_flink"}, "reward": 250}, + {"id": "schild", "name": "Panzerschlacht", "text": "Alle Wesen kommen mit Schild, Duell mit 40 Leben.", "n_opp": 1, + "rules": {"everyone_perk": "schild_wesen", "start_life": 40}, "reward": 150}, + {"id": "karten", "name": "Kartenflut", "text": "Jeder zieht 2 Karten pro Zug, fünf Gegner am Tisch.", "n_opp": 5, + "rules": {"everyone_perk": "extra_karten"}, "reward": 300}, + {"id": "mono", "name": "Reine Lehre", "text": "Nur Decks mit einem Element sind erlaubt, drei Gegner.", "n_opp": 3, + "rules": {"mono_only": true}, "reward": 220}, +] + + +static func week() -> int: + var t := Profile.today() + return int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 604800) + + +static func current() -> Dictionary: + return LIST[week() % LIST.size()] + + +## Prüft, ob das Deck zur Herausforderung passt. Gibt "" oder eine Fehlermeldung zurück. +static func deck_allowed(ch: Dictionary, deck: Array) -> String: + if ch.rules.get("mono_only", false) and Game.deck_elements(deck).size() != 1: + return "Diese Woche sind nur Decks mit einem Element erlaubt" + return "" diff --git a/scripts/challenge.gd.uid b/scripts/challenge.gd.uid new file mode 100644 index 0000000..8fd8739 --- /dev/null +++ b/scripts/challenge.gd.uid @@ -0,0 +1 @@ +uid://n5h6q6kfoo34 diff --git a/scripts/game.gd b/scripts/game.gd new file mode 100644 index 0000000..6e73d9b --- /dev/null +++ b/scripts/game.gd @@ -0,0 +1,854 @@ +class_name Game +extends RefCounted +## Spielregeln: Spieler, Wesen, Züge, Angriffe, Effekte, Feldkombos, Resonanz. + +const MAX_BOARD := 5 +const MAX_HAND := 8 +const MAX_ENERGY := 10 +const START_HAND := 4 +# Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben. +const START_LIFE := 30 +const MAX_OPPONENTS := 5 + +const RESONANCE_MAX := 4 +const MAX_TRAPS := 2 + +# Merkmale der Kampagnen-Bosse (Player.perks). +const PERKS := { + "feuer_angriff": "Feuerwesen haben +1 Angriff", + "flink_angriff": "Flinke Wesen haben +1 Angriff", + "waechter_leben": "Wächter kommen mit +2 Leben", + "extra_karten": "Zieht 2 Karten pro Zug", + "leben_35": "Beginnt mit 35 Leben", + "zauber_schaden": "Schadenszauber machen +1 Schaden", + "schild_wesen": "Wesen kommen mit Schild", + "alle_flink": "Alle Wesen sind flink", + "start_energie": "Beginnt mit 3 Energie", + "resonanz_doppelt": "Resonanz zählt doppelt", +} + +## Für wiederholbare Tests: 0 = echter Zufall, sonst Startwert (zählt je Aufruf hoch). +static var test_seed := 0 + + +static func make_rng() -> RandomNumberGenerator: + var r := RandomNumberGenerator.new() + if test_seed != 0: + r.seed = test_seed + test_seed += 1 + else: + r.randomize() + return r + +const DECK_SIZE := 20 +const MAX_COPIES := 3 +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: + var card: Cards.Card + var owner_ref: WeakRef # schwache Referenz: kein Zyklus Spieler <-> Wesen + var name: String + var uid: int + var attack: int + var resonance := 0 # +1, wenn ein anderes eigenes Wesen ein Element teilt + var health: int + var max_health: int + var shield: bool + var frozen := false + var ready: bool + var attacks_left: int + + func _init(c: Cards.Card, o, id: int) -> void: + card = c + owner_ref = weakref(o) + name = c.name + uid = id + attack = c.attack + health = c.health + max_health = c.health + shield = "Schild" in c.keywords + ready = "Flink" in c.keywords + attacks_left = attacks_per_turn() if ready else 0 + + func owner(): + return owner_ref.get_ref() + + ## Angriffswert inklusive Resonanz und Boss-Merkmalen. + func power() -> int: + var v := attack + resonance + var o = owner() + if o != null: + if "feuer_angriff" in o.perks and "Feuer" in card.elements: + v += 1 + if "flink_angriff" in o.perks and "Flink" in card.keywords: + v += 1 + return v + + func attacks_per_turn() -> int: + return 2 if "Doppelschlag" in card.keywords else 1 + + func taunt() -> bool: + return "Wächter" in card.keywords + + func can_attack() -> bool: + return ready and attacks_left > 0 and not frozen and power() > 0 + + func clone(new_owner) -> Creature: + var c := Creature.new(card, new_owner, uid) + c.attack = attack + c.resonance = resonance + c.health = health + c.max_health = max_health + c.shield = shield + c.frozen = frozen + c.ready = ready + c.attacks_left = attacks_left + return c + + +class Player extends RefCounted: + var name: String + var title: String + var is_human: bool + var life: int + var max_life: int + var deck: Array = [] + var hand: Array = [] + var board: Array = [] + var energy := 0 + var max_energy := 0 + var fatigue := 0 + var start_bonus := 0 # einmalige Extra-Energie im ersten Zug (Ausgleich fürs Nachziehen) + var traps: Array = [] # verdeckt liegende Fallenkarten + var order := 0 # Platz in der Zugreihenfolge + var perks: Array = [] # Boss-Merkmale (siehe PERKS) + var turns := 0 # begonnene eigene Züge + var mulligan_done := false + + func _init(n: String, d: Array, h: bool, l: int, t: String = "") -> void: + name = n + title = t + is_human = h + life = l + max_life = l + deck = d.duplicate() + + func alive() -> bool: + return life > 0 + + func clone() -> Player: + var p := Player.new(name, deck, is_human, life, title) + p.max_life = max_life + p.hand = hand.duplicate() + p.energy = energy + p.max_energy = max_energy + p.fatigue = fatigue + p.start_bonus = start_bonus + p.traps = traps.duplicate() + p.order = order + p.perks = perks.duplicate() + p.turns = turns + p.mulligan_done = mulligan_done + for c in board: + p.board.append(c.clone(p)) + return p + + +var human: Player # der (erste) menschliche Spieler +var humans: Array = [] # alle menschlichen Spieler (Hotseat: mehrere) +var opponents: Array = [] # alle außer dem ersten Menschen +var players: Array = [] # in Zugreihenfolge +var winner_player: Player = null +var rng: RandomNumberGenerator +var turn := 0 +var current_idx := 0 +var log: Array[String] = [] +var events: Array = [] # strukturierte Ereignisse für Animationen +var winner := "" # "", "human" oder "opponents" +var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse +var next_uid := 1 + + +func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void: + human = h + opponents = opps + players = [h] + opps + humans = players.filter(func(p): return p.is_human) + if r == null: + r = make_rng() + rng = r + + +func current() -> Player: + return players[current_idx] + + +func say(t: String) -> void: + if not quiet: + log.append(t) + + +func emit(ev: Dictionary) -> void: + if not quiet: + events.append(ev) + + +func ref_of(target) -> Dictionary: + if target is Creature: + return {"kind": "creature", "uid": target.uid} + return {"kind": "player", "index": players.find(target)} + + +## Jeder gegen jeden: alle anderen lebenden Spieler sind Gegner. +func enemies_of(p: Player) -> Array: + return players.filter(func(o): return o != p and o.alive()) + + +func find_creature(uid: int) -> Creature: + for p in players: + for c in p.board: + if c.uid == uid: + return c + return null + + +## Unabhängige Kopie des Spielzustands (für die KI-Vorausschau). +func clone() -> Game: + var copies := [] + for p in players: + copies.append(p.clone()) + var idx := players.find(human) + var g := Game.new(copies[idx], copies.slice(0, idx) + copies.slice(idx + 1), RandomNumberGenerator.new()) + g.players = copies # gleiche Zugreihenfolge wie das Original + g.turn = turn + g.current_idx = current_idx + g.winner = winner + g.next_uid = next_uid + g.quiet = true + return g + + +## Kopie aus Sicht von p: fremde Fallen sind unbekannt und fehlen (die KI schummelt nicht). +func clone_for(p: Player) -> Game: + var g := clone() + var p2: Player = g.players[players.find(p)] + for q in g.players: + if q != p2: + q.traps = [] + return g + + +# --- Zugablauf --------------------------------------------------------------- + +func start(fixed_order: bool = false) -> void: + if not fixed_order: + shuffle(players, rng) # zufällige Zugreihenfolge + for i in players.size(): + var p: Player = players[i] + p.order = i + if "start_energie" in p.perks: + p.max_energy = 2 + shuffle(p.deck, rng) + # Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug + # einmalig 1 Energie extra (Ausgleich für den Startvorteil). + draw(p, START_HAND + (0 if i == 0 else 1)) + p.start_bonus = 0 if i == 0 else 1 + turn = 1 + current_idx = 0 + say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name))) + for p in players: + for perk in p.perks: + say("%s: %s." % [p.name, PERKS[perk]]) + begin_turn(current()) + + +func draw(p: Player, n: int = 1) -> void: + for i in n: + if p.deck.is_empty(): + p.fatigue += 1 + say("%s hat keine Karten mehr und erleidet %d Schaden." % [p.name, p.fatigue]) + damage(p, p.fatigue) + continue + var card = p.deck.pop_back() + if p.hand.size() >= MAX_HAND: + say("%s: Hand voll, %s verbrennt." % [p.name, card.name]) + else: + p.hand.append(card) + check_end() + + +func begin_turn(p: Player) -> void: + p.max_energy = mini(MAX_ENERGY, p.max_energy + 1) + p.energy = p.max_energy + if p.start_bonus > 0: + p.energy += p.start_bonus + say("%s erhält %d Energie extra für den späteren Start." % [p.name, p.start_bonus]) + p.start_bonus = 0 + for c in p.board: + c.ready = true + if c.frozen: + c.frozen = false + c.attacks_left = 0 + say("%s taut auf und setzt aus." % c.name) + else: + c.attacks_left = c.attacks_per_turn() + p.turns += 1 + say("--- Zug %d: %s ---" % [turn, p.name]) + emit({"type": "turn", "player": players.find(p)}) + draw(p, 2 if "extra_karten" in p.perks else 1) + + +## Tauscht einmalig im ersten eigenen Zug Karten der Hand: zurück ins Deck, mischen, nachziehen. +func mulligan(p: Player, cards: Array) -> String: + if p.mulligan_done: + return "Schon getauscht" + if p != current() or p.turns != 1: + return "Nur im ersten eigenen Zug" + p.mulligan_done = true + var n := 0 + for c in cards: + if c in p.hand: + p.hand.erase(c) + p.deck.append(c) + n += 1 + if n > 0: + shuffle(p.deck, rng) + draw(p, n) + say("%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}) + return "" + + +func end_turn() -> void: + if winner != "": + return + var n := players.size() + for i in range(1, n + 1): + var idx := (current_idx + i) % n + if players[idx].alive(): + if idx <= current_idx: + turn += 1 + current_idx = idx + break + begin_turn(current()) + + +# --- Karten spielen ---------------------------------------------------------- + +func can_play(p: Player, card: Cards.Card) -> Array: + if winner != "" or p != current(): + return [false, "Nicht dein Zug"] + if p.energy < card.cost: + return [false, "Nicht genug Energie"] + if card.is_creature() and p.board.size() >= MAX_BOARD: + return [false, "Dein Feld ist voll"] + if card.is_trap() and p.traps.size() >= MAX_TRAPS: + return [false, "Höchstens %d Fallen" % MAX_TRAPS] + var tt := card.target_type() + if tt != "" and not card.is_creature() and valid_targets(p, tt).is_empty(): + return [false, "Kein gültiges Ziel"] + return [true, ""] + + +func valid_targets(p: Player, target_type: String) -> Array: + var enemies := enemies_of(p) + var creatures := [] + for e in enemies: + creatures.append_array(e.board) + match target_type: + "enemy_creature": + return creatures + "enemy_any": + return creatures + enemies + "friendly_creature": + return p.board.duplicate() + return [] + + +## Spielt eine Karte. Gibt "" zurück oder eine Fehlermeldung. +func play_card(p: Player, card: Cards.Card, target = null) -> String: + var ok := can_play(p, card) + if not ok[0]: + return ok[1] + var tt := card.target_type() + if tt != "": + var valid := valid_targets(p, tt) + if not valid.is_empty() and not (target in valid): + return "Ungültiges Ziel" + if valid.is_empty(): + target = null + p.hand.erase(card) + p.energy -= card.cost + if card.is_trap(): + p.traps.append(card) + say("%s legt eine Falle." % p.name) + emit({"type": "trap_set", "player": players.find(p)}) + check_end() + return "" + say("%s spielt %s." % [p.name, card.name]) + var cr: Creature = null + var bonus := 0 + if card.is_creature(): + cr = _enter(p, card) + else: + emit({"type": "spell", "player": players.find(p), "card": card}) + if "zauber_schaden" in p.perks: + bonus = 1 + for fx in card.effects: + resolve(p, fx, target, cr, bonus) + if cr != null and cr in p.board: + for e in enemies_of(p): + if not (cr in p.board): + break + trigger_trap(e, "creature_played", cr) + if cr != null and cr in p.board: + trigger_field_combos(p, cr) + update_resonance() + cleanup() + check_end() + return "" + + +## Feldkombo: das neue Wesen bildet mit dem ersten eigenen Wesen aus anderem +## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus. +func trigger_field_combos(p: Player, cr: Creature) -> void: + for other in p.board: + if other == cr: + continue + for e1 in cr.card.elements: + for e2 in other.card.elements: + if e1 == e2: + continue + var name := Cards.combo_name([e1, e2]) + say("Feldkombo %s: %s." % [name, Cards.FIELD_COMBO_TEXT[name]]) + emit({"type": "combo", "name": name, "player": players.find(p), "uid": cr.uid}) + resolve(p, Cards.field_combo_effect(name), null, cr) + return + + +## Löst die erste passende Falle von owner aus. Gibt true zurück, wenn der +## auslösende Angriff abgebrochen wird. +func trigger_trap(owner: Player, trigger: String, source: Creature) -> bool: + for trap in owner.traps: + if trap.trigger != trigger: + continue + owner.traps.erase(trap) + say("Falle von %s: %s!" % [owner.name, trap.name]) + emit({"type": "trap", "player": players.find(owner), "card": trap, "source": ref_of(source)}) + var cancel := false + for fx in trap.effects: + if fx.kind == "cancel": + cancel = true + else: + resolve(owner, fx, null, source) + cleanup() + return cancel + return false + + +## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt. +func _enter(p: Player, card: Cards.Card) -> Creature: + var cr := Creature.new(card, p, next_uid) + next_uid += 1 + if "waechter_leben" in p.perks and cr.taunt(): + cr.health += 2 + cr.max_health += 2 + if "schild_wesen" in p.perks: + cr.shield = true + if "alle_flink" in p.perks: + cr.ready = true + cr.attacks_left = cr.attacks_per_turn() + p.board.append(cr) + emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card}) + return cr + + +## Setzt ein Wesen aufs Feld (Fallen, Effekte). +func summon(p: Player, card: Cards.Card) -> void: + if p.board.size() >= MAX_BOARD: + say("%s: kein Platz für %s." % [p.name, card.name]) + return + _enter(p, card) + say("%s beschwört %s." % [p.name, card.name]) + update_resonance() + + +## Resonanz: +1 Angriff je anderem eigenen Wesen, das ein Element teilt (höchstens +4). +func update_resonance() -> void: + for p in players: + for c in p.board: + var bonus := 0 + for o in p.board: + if o != c and o.card.elements.any(func(e): return e in c.card.elements): + bonus += 1 + c.resonance = mini(bonus, RESONANCE_MAX) * (2 if "resonanz_doppelt" in p.perks else 1) + + +func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void: + var enemies := enemies_of(p) + var enemy_creatures := [] + for e in enemies: + enemy_creatures.append_array(e.board) + var targets := [] + if fx.target in Cards.CHOSEN_TARGETS: + targets = [target] if target != null else [] + elif fx.target == "all_enemy_creatures": + targets = enemy_creatures + elif fx.target == "all_enemy_heroes": + targets = enemies.duplicate() + elif fx.target == "all_enemies": + targets = enemy_creatures + enemies + elif fx.target == "all_friendly_creatures": + targets = p.board.duplicate() + elif fx.target == "self_hero": + targets = [p] + elif fx.target == "self_creature" or fx.target == "trigger_source": + targets = [source] if source != null else [] + elif fx.target == "strongest_enemy_creature": + var best: Creature = null + for c in enemy_creatures: + if best == null or c.power() > best.power(): + best = c + targets = [best] if best != null else [] + elif fx.target == "weakest_enemy_hero": + var weakest: Player = null + for e in enemies: + if weakest == null or e.life < weakest.life: + weakest = e + targets = [weakest] if weakest != null else [] + else: + targets = [null] + for t in targets: + match fx.kind: + "damage": + damage(t, fx.amount + bonus) + "heal": + t.life = mini(t.max_life, t.life + fx.amount) + say("%s heilt sich um %d." % [t.name, fx.amount]) + emit({"type": "heal", "target": ref_of(t), "amount": fx.amount}) + "draw": + draw(p, fx.amount) + "freeze": + t.frozen = true + t.attacks_left = 0 + say("%s ist eingefroren." % t.name) + emit({"type": "freeze", "target": ref_of(t)}) + "bounce": + var o = t.owner() + o.board.erase(t) + emit({"type": "bounce", "target": ref_of(t)}) + if o.hand.size() < MAX_HAND: + o.hand.append(t.card) + say("%s kehrt auf die Hand zurück." % t.name) + else: + say("%s wird weggeweht (Hand voll)." % t.name) + "buff": + t.attack += fx.amount + t.health += fx.amount2 + t.max_health += fx.amount2 + say("%s erhält +%d/+%d." % [t.name, fx.amount, fx.amount2]) + emit({"type": "buff", "target": ref_of(t), "text": "+%d/+%d" % [fx.amount, fx.amount2]}) + "shield": + t.shield = true + say("%s erhält Schild." % t.name) + emit({"type": "buff", "target": ref_of(t), "text": "Schild"}) + "summon": + summon(p, Cards.by_id(fx.card_id)) + + +# --- Kampf ------------------------------------------------------------------- + +func can_attack(cr: Creature, target) -> Array: + if winner != "" or cr.owner() != current(): + return [false, "Nicht dein Zug"] + if not cr.can_attack(): + return [false, "%s kann nicht angreifen" % cr.name] + var tgt_owner = target.owner() if target is Creature else target + if not (tgt_owner in enemies_of(cr.owner())): + return [false, "Kein gültiges Ziel"] + var has_taunt := false + for c in tgt_owner.board: + if c.taunt(): + has_taunt = true + if has_taunt and not (target is Creature and target.taunt()): + return [false, "Ein Wächter muss zuerst angegriffen werden"] + return [true, ""] + + +## Greift an. Gibt "" zurück oder eine Fehlermeldung. +func attack(cr: Creature, target) -> String: + var ok := can_attack(cr, target) + if not ok[0]: + return ok[1] + cr.attacks_left -= 1 + say("%s greift %s an." % [cr.name, target.name]) + emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)}) + var tgt_owner: Player = target.owner() if target is Creature else target + var cancelled := trigger_trap(tgt_owner, "attacked", cr) + if cancelled or not (cr in cr.owner().board) or (target is Creature and not (target in tgt_owner.board)): + check_end() + return "" + damage(target, cr.power()) + if target is Creature: + damage(cr, target.power()) + cleanup() + check_end() + return "" + + +func damage(target, amount: int) -> void: + if amount <= 0: + return + if target is Creature: + if target.shield: + target.shield = false + say("Schild von %s blockt den Schaden." % target.name) + emit({"type": "blocked", "target": ref_of(target)}) + return + target.health -= amount + else: + target.life -= amount + say("%s erleidet %d Schaden." % [target.name, amount]) + emit({"type": "damage", "target": ref_of(target), "amount": amount}) + + +func cleanup() -> void: + var died := false + for p in players: + for c in p.board.duplicate(): + if c.health <= 0: + p.board.erase(c) + say("%s stirbt." % c.name) + emit({"type": "death", "target": ref_of(c)}) + died = true + if died: + update_resonance() + + +## Ende: nur noch ein Spieler übrig, oder kein Mensch mehr am Tisch. +func check_end() -> void: + if winner != "": + return + var alive := players.filter(func(p): return p.alive()) + var humans_alive := alive.filter(func(p): return p.is_human) + if humans_alive.is_empty(): + winner = "opponents" + winner_player = alive[0] if alive.size() == 1 else null + say("Du wurdest besiegt." if humans.size() == 1 else "Alle Spieler sind besiegt, die KI gewinnt.") + elif alive.size() == 1: + winner = "human" + winner_player = alive[0] + say("Alle Gegner sind besiegt!" if humans.size() == 1 else "%s gewinnt!" % winner_player.name) + + +# --- Zustand für das Netzwerk ------------------------------------------------- + +## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl. +func snapshot(viewer: int) -> Dictionary: + var ps := [] + 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: + var placeholder: Cards.Card = Cards.all()[0] + 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 --------------------------------------------------- + +static func shuffle(arr: Array, r: RandomNumberGenerator) -> void: + for i in range(arr.size() - 1, 0, -1): + var j := r.randi_range(0, i) + var tmp = arr[i] + arr[i] = arr[j] + arr[j] = tmp + + +static func deck_elements(cards: Array) -> Array: + var out := [] + for c in cards: + for e in c.elements: + if not (e in out): + out.append(e) + return out + + +## Prüft ein Deck. owned: Dictionary id -> Anzahl (null = keine Besitzprüfung). +static func validate_deck(cards: Array, owned = null) -> Array: + if cards.size() != DECK_SIZE: + return [false, "Deck braucht genau %d Karten (%d)" % [DECK_SIZE, cards.size()]] + var counts := {} + for c in cards: + counts[c.id] = counts.get(c.id, 0) + 1 + for cid in counts: + if counts[cid] > MAX_COPIES: + return [false, "Höchstens %dx dieselbe Karte" % MAX_COPIES] + if owned != null and owned.get(cid, 0) < counts[cid]: + return [false, "Karte nicht oft genug in der Sammlung"] + if deck_elements(cards).size() > MAX_DECK_ELEMENTS: + return [false, "Höchstens %d Elemente im Deck" % MAX_DECK_ELEMENTS] + return [true, ""] + + +## Gegnerdeck aus den Elementen. bias > 1 bevorzugt seltene Karten (Kampagne). +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 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: + var elements: Array + if r.randf() < 0.4: + elements = [Cards.ELEMENTS[r.randi_range(0, Cards.ELEMENTS.size() - 1)]] + else: + var pool: Array = Cards.ELEMENTS.duplicate() + shuffle(pool, r) + elements = [pool[0], pool[1]] + return Player.new("Gegner %d" % index, build_opponent_deck(r, elements), false, life, + Cards.combo_name(elements) + "-Magier") + + +## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int} +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") + if r == null: + r = make_rng() + var opps := [] + for i in n_opp: + opps.append(random_opponent(r, i + 1)) + return new_battle_with(deck, opps, r, false, rules) + + +static func new_battle_with(deck: Array, opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game: + if r == null: + r = make_rng() + var human := Player.new("Du", deck, true, START_LIFE) + human.perks = Array(rules.get("human_perks", [])) + var g := Game.new(human, opps, r) + var everyone: String = rules.get("everyone_perk", "") + for p in g.players: + if everyone != "" and not (everyone in p.perks): + p.perks.append(everyone) + if rules.has("start_life"): + p.life = int(rules.start_life) + p.max_life = p.life + elif "leben_35" in p.perks and p.life == START_LIFE: + p.life = 35 + p.max_life = 35 + g.start(fixed_order) + return g + + +## Hotseat: mehrere Menschen am selben Rechner, dazu KI-Gegner. +static func new_hotseat(human_decks: Array, n_ai: int, r: RandomNumberGenerator = null) -> Game: + assert(human_decks.size() >= 1 and human_decks.size() + n_ai <= MAX_OPPONENTS + 1, "höchstens 6 am Tisch") + if r == null: + r = make_rng() + var first := Player.new("Spieler 1", human_decks[0], true, START_LIFE) + var others := [] + for i in range(1, human_decks.size()): + others.append(Player.new("Spieler %d" % (i + 1), human_decks[i], true, START_LIFE)) + for i in n_ai: + others.append(random_opponent(r, i + 1)) + var g := Game.new(first, others, r) + g.start() + return g diff --git a/scripts/game.gd.uid b/scripts/game.gd.uid new file mode 100644 index 0000000..fc1eb29 --- /dev/null +++ b/scripts/game.gd.uid @@ -0,0 +1 @@ +uid://d2f2wrhtkb85h diff --git a/scripts/net.gd b/scripts/net.gd new file mode 100644 index 0000000..6037bd3 --- /dev/null +++ b/scripts/net.gd @@ -0,0 +1,279 @@ +class_name Net +extends Node +## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten +## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen). + +signal lobby_changed +signal started(seat: int) +signal snapshot_received(snap: Dictionary) +signal action_applied +signal ended(reason: String) + +const PORT := 24455 + +var peer: ENetMultiplayerPeer +var is_host := false +var connected := false +var my_name := "Spieler" +var my_deck: Array = [] # Karten-IDs +var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie) +var decks := {} # peer_id -> Karten-IDs (Host) +var seats := {} # peer_id -> Spielerindex (Host) +var my_seat := -1 +var game: Game # Host +var status := "" +var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten +const REJOIN_SECONDS := 60.0 + + +func _ready() -> void: + multiplayer.peer_connected.connect(_on_peer_connected) + multiplayer.peer_disconnected.connect(_on_peer_disconnected) + multiplayer.connected_to_server.connect(_on_connected) + multiplayer.connection_failed.connect(func(): _fail("Verbindung fehlgeschlagen")) + multiplayer.server_disconnected.connect(func(): _fail("Host hat das Spiel beendet")) + + +func host(port: int = PORT) -> Error: + peer = ENetMultiplayerPeer.new() + var err := peer.create_server(port, Game.MAX_OPPONENTS) + if err != OK: + status = "Kann nicht hosten (Port %d belegt?)" % port + return err + multiplayer.multiplayer_peer = peer + is_host = true + connected = true + names = {1: my_name} + decks = {1: my_deck} + status = "Warte auf Mitspieler …" + lobby_changed.emit() + return OK + + +func join(ip: String, port: int = PORT) -> Error: + peer = ENetMultiplayerPeer.new() + var err := peer.create_client(ip, port) + if err != OK: + status = "Adresse ungültig" + return err + multiplayer.multiplayer_peer = peer + is_host = false + status = "Verbinde mit %s …" % ip + return OK + + +func leave() -> void: + if multiplayer.multiplayer_peer != null: + multiplayer.multiplayer_peer.close() + multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() + peer = null + is_host = false + connected = false + names = {} + decks = {} + seats = {} + waiting = {} + my_seat = -1 + game = null + status = "" + + +func local_addresses() -> Array: + return Array(IP.get_local_addresses()).filter(func(a): return "." in a and not a.begins_with("127.")) + + +func _fail(reason: String) -> void: + status = reason + ended.emit(reason) + leave() + + +func _on_connected() -> void: + connected = true + status = "Verbunden" + register.rpc_id(1, my_name, my_deck) + + +func _on_peer_connected(_id: int) -> void: + pass + + +func _on_peer_disconnected(id: int) -> void: + if not is_host: + return + var pname: String = names.get(id, "") + names.erase(id) + decks.erase(id) + if game != null and seats.has(id): + var p: Game.Player = game.players[seats[id]] + if game.winner == "": + waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()} + game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(REJOIN_SECONDS)]) + seats.erase(id) + action_applied.emit() + lobby_update.rpc(names) + lobby_changed.emit() + + +## Host, je Bild: wartende Spieler überspringen; nach Ablauf der Frist scheiden sie aus. +func tick() -> void: + if not is_host or game == null or game.winner != "": + return + var now := Time.get_unix_time_from_system() + for pname in waiting.keys(): + var w: Dictionary = waiting[pname] + var p: Game.Player = game.players[w.seat] + if now - float(w.since) > REJOIN_SECONDS: + waiting.erase(pname) + game.say("%s kehrt nicht zurück und scheidet aus." % p.name) + game.damage(p, 999) + game.check_end() + action_applied.emit() + elif game.current() == p: + p.mulligan_done = true + game.say("%s ist nicht da, der Zug wird übersprungen." % p.name) + game.end_turn() + action_applied.emit() + + +@rpc("any_peer", "reliable") +func register(name: String, deck_ids: Array) -> void: + if not is_host: + return + var id := multiplayer.get_remote_sender_id() + if game != null: + # Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz + if waiting.has(name): + seats[id] = waiting[name].seat + names[id] = name + waiting.erase(name) + game.say("%s ist zurück." % name) + start_game.rpc_id(id, seats[id]) + receive_snapshot.rpc_id(id, game.snapshot(seats[id])) + action_applied.emit() + else: + status = "%s wollte beitreten, das Spiel läuft schon" % name + return + names[id] = name + decks[id] = deck_ids + lobby_update.rpc(names) + lobby_changed.emit() + + +@rpc("authority", "reliable") +func lobby_update(n: Dictionary) -> void: + names = n + lobby_changed.emit() + + +## Host: Spiel mit allen Verbundenen und n_ai KI-Gegnern starten. +func start_battle(n_ai: int) -> void: + var ids: Array = names.keys() + ids.sort() + var r := Game.make_rng() + var players := [] + seats = {} + for id in ids: + var deck_ids: Array = decks.get(id, []) + var deck := [] + for cid in deck_ids: + if Cards.has_id(cid): + deck.append(Cards.by_id(cid)) + if Game.validate_deck(deck)[0] == false: + var pool: Array = Cards.ELEMENTS.duplicate() + Game.shuffle(pool, r) + deck = Game.build_opponent_deck(r, [pool[0], pool[1]]) + players.append(Game.Player.new(names[id], deck, true, Game.START_LIFE)) + for i in n_ai: + players.append(Game.random_opponent(r, i + 1)) + game = Game.new(players[0], players.slice(1), r) + game.start() + for id in ids: + seats[id] = game.players.find(players[ids.find(id)]) + my_seat = seats[1] + for id in ids: + if id != 1: + start_game.rpc_id(id, seats[id]) + started.emit(my_seat) + + +@rpc("authority", "reliable") +func start_game(seat: int) -> void: + my_seat = seat + started.emit(seat) + + +## Host: Zustand an alle Mitspieler senden (jeder aus seiner Sicht). +func broadcast() -> void: + if not is_host or game == null: + return + for id in seats: + if id != 1: + receive_snapshot.rpc_id(id, game.snapshot(seats[id])) + + +@rpc("authority", "reliable") +func receive_snapshot(snap: Dictionary) -> void: + snapshot_received.emit(snap) + + +## Mitspieler: Aktion an den Host schicken. +func send_action(action: Dictionary) -> void: + if is_host: + _apply(1, action) + else: + request_action.rpc_id(1, action) + + +@rpc("any_peer", "reliable") +func request_action(action: Dictionary) -> void: + if not is_host: + return + _apply(multiplayer.get_remote_sender_id(), action) + + +func _apply(id: int, action: Dictionary) -> void: + if game == null or not seats.has(id): + return + var p: Game.Player = game.players[seats[id]] + if game.current() != p or game.winner != "": + return + match str(action.get("type", "")): + "play": + var idx := int(action.get("hand", -1)) + if idx < 0 or idx >= p.hand.size(): + return + game.play_card(p, p.hand[idx], resolve_ref(action.get("target"))) + "attack": + var cr := game.find_creature(int(action.get("uid", -1))) + var t = resolve_ref(action.get("target")) + if cr != null and t != null: + game.attack(cr, t) + "mulligan": + var cards := [] + for i in action.get("hand", []): + if int(i) >= 0 and int(i) < p.hand.size(): + cards.append(p.hand[int(i)]) + game.mulligan(p, cards) + "end": + game.end_turn() + _: + return + action_applied.emit() + + +func resolve_ref(ref): + if ref == null or not (ref is Dictionary): + return null + if ref.get("kind") == "creature": + return game.find_creature(int(ref.get("uid", -1))) + var i := int(ref.get("index", -1)) + if i >= 0 and i < game.players.size(): + return game.players[i] + return null + + +static func ref_dict(target) -> Dictionary: + if target is Game.Creature: + return {"kind": "creature", "uid": target.uid} + return {"kind": "player", "index": target.order} diff --git a/scripts/net.gd.uid b/scripts/net.gd.uid new file mode 100644 index 0000000..ff855be --- /dev/null +++ b/scripts/net.gd.uid @@ -0,0 +1 @@ +uid://drhh23lblvq8o diff --git a/scripts/profile.gd b/scripts/profile.gd new file mode 100644 index 0000000..4e17667 --- /dev/null +++ b/scripts/profile.gd @@ -0,0 +1,519 @@ +class_name Profile +extends RefCounted +## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Statistik, Erfolge, Tagesaufgabe, Einstellungen. + +const SAVE_VERSION := 5 +const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert +const SAVE_PATH := "user://spielstand.json" +const BOOSTER_COST := 100 +const BOOSTER_SIZE := 5 +const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5} +const START_COINS := 150 +const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300} +const REWARD_LOSS := 50 +const DECK_SLOTS := 5 +# Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub. +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 ACHIEVEMENTS := [ + {"id": "erster_sieg", "name": "Erster Sieg", "text": "Gewinne einen Kampf.", "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": "chaosmeister", "name": "Chaosmeister", "text": "Gewinne gegen 5 Gegner.", "reward": 200}, + {"id": "sammler", "name": "Sammler", "text": "Besitze 40 verschiedene Karten.", "reward": 100}, + {"id": "vollstaendig", "name": "Vollständig", "text": "Besitze jede Karte.", "reward": 500}, + {"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": "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": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500}, + {"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}, +] + +const DAILY_TASKS := [ + {"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80}, + {"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60}, + {"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40}, + {"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50}, + {"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50}, + {"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60}, + {"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50}, + {"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120}, + {"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90}, + {"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90}, + {"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90}, + {"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90}, +] + +static var today_override := "" # für Tests + +var coins := 0 +var dust := 0 +var collection := {} # id -> Anzahl +var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids} +var active_deck := 0 +var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}} +var achievements: Array = [] +var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen +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 notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe) +var card_stats := {} # id -> {"played", "games", "wins"} +var booster_since_mythic := 0 +var tutorial_done := false +var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1) +var records := {"streak": 0, "best_streak": 0, "fastest_win": 0} +var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung + +## Das aktive Deck (Kartenliste des gewählten Slots). +var deck: Array: + get: + return decks[active_deck].cards + set(value): + decks[active_deck].cards = value + + +func _init() -> void: + for i in DECK_SLOTS: + decks.append({"name": "Deck %d" % (i + 1), "cards": []}) + + +static func starter() -> Profile: + var p := Profile.new() + p.coins = START_COINS + 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_"): + p.collection[c.id] = 2 + var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche", + "e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"] + for cid in starter_ids: + p.deck.append(cid) + p.deck.append(cid) + return p + + +## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil. +static func load_or_new(path: String = SAVE_PATH) -> Profile: + for candidate in [path, path + ".bak"]: + if FileAccess.file_exists(candidate): + var json := JSON.new() + if json.parse(FileAccess.get_file_as_string(candidate)) == OK and json.data is Dictionary: + return from_dict(json.data) + if FileAccess.file_exists(path): + DirAccess.rename_absolute(ProjectSettings.globalize_path(path), ProjectSettings.globalize_path(path) + ".corrupt") + var p := starter() + p.save(path) + return p + + +## Liest jede Spielstand-Version; fehlende Felder bekommen Standardwerte (Migration). +static func from_dict(d: Dictionary) -> Profile: + var p := Profile.new() + var version := int(d.get("version", 1)) + p.coins = int(d.get("coins", 0)) + p.dust = int(d.get("dust", 0)) + var coll = d.get("collection", {}) + for cid in coll: + if Cards.has_id(cid): + p.collection[cid] = int(coll[cid]) + if version >= 3: + var list = d.get("decks", []) + for i in mini(list.size(), DECK_SLOTS): + p.decks[i].name = str(list[i].get("name", "Deck %d" % (i + 1))) + p.decks[i].cards = [] + for cid in list[i].get("cards", []): + if Cards.has_id(cid): + p.decks[i].cards.append(cid) + p.active_deck = clampi(int(d.get("active_deck", 0)), 0, DECK_SLOTS - 1) + else: + for cid in d.get("deck", []): + if Cards.has_id(cid): + p.decks[0].cards.append(cid) + if version >= 2: + var st = d.get("stats", {}) + p.stats.games = int(st.get("games", 0)) + p.stats.wins = int(st.get("wins", 0)) + p.stats.boosters = int(st.get("boosters", 0)) + for k in st.get("wins_by_opp", {}): + p.stats.wins_by_opp[str(k)] = int(st.wins_by_opp[k]) + for a in d.get("achievements", []): + p.achievements.append(str(a)) + p.campaign_stage = int(d.get("campaign_stage", 0)) + var dl = d.get("daily", {}) + p.daily = {"date": str(dl.get("date", "")), "task": str(dl.get("task", "")), + "progress": int(dl.get("progress", 0)), "done": bool(dl.get("done", false))} + var se = d.get("settings", {}) + 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)), + "language": str(se.get("language", "de"))} + if version >= 4: + for cid in d.get("card_stats", {}): + if Cards.has_id(cid): + var st = d.card_stats[cid] + p.card_stats[cid] = {"played": int(st.get("played", 0)), "games": int(st.get("games", 0)), "wins": int(st.get("wins", 0))} + p.booster_since_mythic = int(d.get("booster_since_mythic", 0)) + p.tutorial_done = bool(d.get("tutorial_done", false)) + if version >= 5: + p.perk = str(d.get("perk", "")) + if not Game.PERKS.has(p.perk): + p.perk = "" + var rc = d.get("records", {}) + p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)), + "fastest_win": int(rc.get("fastest_win", 0))} + p.challenge_week = int(d.get("challenge_week", -1)) + return p + + +func to_dict() -> Dictionary: + return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, + "decks": decks, "active_deck": active_deck, + "stats": stats, "achievements": achievements, "campaign_stage": campaign_stage, + "daily": daily, "settings": settings, "card_stats": card_stats, + "booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done, + "perk": perk, "records": records, "challenge_week": challenge_week} + + +## Speichert und sichert den vorherigen Stand als .bak. +func save(path: String = SAVE_PATH) -> void: + var abs := ProjectSettings.globalize_path(path) + if FileAccess.file_exists(path): + DirAccess.copy_absolute(abs, abs + ".bak") + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string(JSON.stringify(to_dict(), "\t")) + f.close() + + +func take_notices() -> Array[String]: + var out := notices.duplicate() + notices.clear() + return out + + +# --- Decks und Sammlung ----------------------------------------------------------- + +func deck_cards(slot: int = -1) -> Array: + var ids: Array = decks[slot if slot >= 0 else active_deck].cards + var out := [] + for cid in ids: + out.append(Cards.by_id(cid)) + return out + + +func deck_valid(slot: int = -1) -> Array: + return Game.validate_deck(deck_cards(slot), collection) + + +func deck_name(slot: int = -1) -> String: + return decks[slot if slot >= 0 else active_deck].name + + +func rename_deck(slot: int, name: String) -> String: + var n := name.strip_edges() + if n.is_empty() or n.length() > 20: + return "Name muss 1 bis 20 Zeichen haben" + decks[slot].name = n + return "" + + +## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve. +## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt. +func autofill_deck(slot: int) -> String: + var cards: Array = decks[slot].cards + var elems := Game.deck_elements(deck_cards(slot)) + if elems.is_empty(): + var count := {} + for c in owned_cards(): + for e in c.elements: + count[e] = count.get(e, 0) + 1 + var ranked: Array = count.keys() + ranked.sort_custom(func(a, b): return count[a] > count[b]) + elems = ranked.slice(0, Game.MAX_DECK_ELEMENTS) + var pool := owned_cards().filter(func(c): return c.elements.all(func(e): return e in elems)) + pool.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity)) + var counts := {} + for cid in cards: + counts[cid] = counts.get(cid, 0) + 1 + var progressed := true + while cards.size() < Game.DECK_SIZE and progressed: + progressed = false + for cost in range(1, 11): + if cards.size() >= Game.DECK_SIZE: + break + for c in pool: + if c.cost != cost: + continue + var have: int = counts.get(c.id, 0) + if have < mini(collection.get(c.id, 0), Game.MAX_COPIES): + cards.append(c.id) + counts[c.id] = have + 1 + progressed = true + break + var ok := Game.validate_deck(deck_cards(slot), collection) + return "" if ok[0] else ok[1] + + +## Bestenliste: Karten mit den meisten Siegen (mindestens eine Partie). +func top_cards(n: int) -> Array: + var rows := [] + for cid in card_stats: + var st: Dictionary = card_stats[cid] + if st.games > 0: + rows.append({"card": Cards.by_id(cid), "games": st.games, "wins": st.wins, "played": st.played, + "rate": float(st.wins) / st.games}) + rows.sort_custom(func(a, b): return a.wins > b.wins or (a.wins == b.wins and a.rate > b.rate)) + return rows.slice(0, n) + + +func owned_cards() -> Array: + return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0) + + +func distinct_owned() -> int: + var n := 0 + for cid in collection: + if collection[cid] > 0: + n += 1 + return n + + +# --- Werkstatt -------------------------------------------------------------------- + +## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung. +func dismantle(cid: String, n: int = 1) -> String: + var owned: int = collection.get(cid, 0) + if owned < n: + return "Nicht genug Kopien" + var needed := 0 + for d in decks: + needed = maxi(needed, d.cards.count(cid)) + if owned - n < needed: + return "Wird in einem Deck gebraucht" + collection[cid] = owned - n + dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n + return "" + + +## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung. +func craft(cid: String) -> String: + var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity] + if dust < cost: + return "Nicht genug Staub (%d nötig)" % cost + dust -= cost + collection[cid] = collection.get(cid, 0) + 1 + daily_event("craft") + check_achievements() + return "" + + +## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück. +func dismantle_extras() -> int: + var before := dust + for cid in collection.keys(): + var extra: int = collection[cid] - Game.MAX_COPIES + if extra > 0: + dismantle(cid, extra) + return dust - before + + +# --- Booster ---------------------------------------------------------------------- + +static func roll_booster(r: RandomNumberGenerator) -> Array: + var rarities := RARITY_WEIGHTS.keys() + var weights := PackedFloat32Array(RARITY_WEIGHTS.values()) + var cards := [] + for i in BOOSTER_SIZE: + var rarity: String = rarities[r.rand_weighted(weights)] + if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \ + and cards.all(func(c): return c.rarity == "gewöhnlich"): + rarity = "selten" # mindestens eine seltene Karte pro Booster + var pool := Cards.of_rarity(rarity) + cards.append(pool[r.randi_range(0, pool.size() - 1)]) + return cards + + +## Öffnet einen Booster. Gibt die Karten zurück, leer wenn die Münzen nicht reichen. +func open_booster(r: RandomNumberGenerator = null) -> Array: + if coins < BOOSTER_COST: + return [] + if r == null: + r = Game.make_rng() + coins -= BOOSTER_COST + var cards := roll_booster(r) + booster_since_mythic += 1 + var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch") + if booster_since_mythic >= PITY_BOOSTERS and not has_mythic: + var pool := Cards.of_rarity("mythisch") + cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)] + has_mythic = true + if has_mythic: + booster_since_mythic = 0 + for c in cards: + collection[c.id] = collection.get(c.id, 0) + 1 + stats.boosters += 1 + daily_event("booster") + check_achievements() + return cards + + +# --- Kampfergebnis, Erfolge, Tagesaufgabe ------------------------------------------- + +## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück. +## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei. +## 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 = [], + turns: int = 0, challenge: Dictionary = {}) -> int: + stats.games += 1 + if won: + records.streak += 1 + records.best_streak = maxi(records.best_streak, records.streak) + if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win): + records.fastest_win = turns + else: + records.streak = 0 + for cid in played_ids: + _stat(cid).played += 1 + var seen := {} + for c in deck_used: + if not seen.has(c.id): + seen[c.id] = true + _stat(c.id).games += 1 + if won: + _stat(c.id).wins += 1 + var reward := REWARD_LOSS + if won: + stats.wins += 1 + var key := str(n_opp) + stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1 + reward = REWARD_WIN[n_opp] + if stage >= 0 and stage == campaign_stage: + campaign_stage += 1 + reward = Campaign.stage(stage).reward + notices.append("Kampagne: %s besiegt!" % Campaign.stage(stage).name) + if not challenge.is_empty() and challenge_week != Challenge.week(): + challenge_week = Challenge.week() + reward = challenge.reward + notices.append("Herausforderung bestanden: %s (+%d Münzen)" % [challenge.name, challenge.reward]) + coins += reward + daily_event("game") + if won: + daily_event("win") + if n_opp >= 3: + daily_event("win_vs3") + for e in Game.deck_elements(deck_used): + daily_event("win_element:" + e) + check_achievements({"won": won, "n_opp": n_opp, "deck": deck_used}) + return reward + + +func _stat(cid: String) -> Dictionary: + if not card_stats.has(cid): + card_stats[cid] = {"played": 0, "games": 0, "wins": 0} + return card_stats[cid] + + +func check_achievements(ctx: Dictionary = {}) -> Array: + var unlocked := [] + for a in ACHIEVEMENTS: + if a.id in achievements: + continue + if _achieved(a.id, ctx): + achievements.append(a.id) + coins += a.reward + unlocked.append(a) + notices.append("Erfolg: %s (+%d Münzen)" % [a.name, a.reward]) + return unlocked + + +func _achieved(id: String, ctx: Dictionary) -> bool: + var won: bool = ctx.get("won", false) + var n: int = ctx.get("n_opp", 0) + var deck_used: Array = ctx.get("deck", []) + match id: + "erster_sieg": + return stats.wins >= 1 + "duellant": + return won and n == 1 + "tischherr": + return won and n >= 3 + "chaosmeister": + return won and n == 5 + "sammler": + return distinct_owned() >= 40 + "vollstaendig": + return distinct_owned() >= Cards.all().size() + "boosterfreund": + return stats.boosters >= 10 + "elementarist": + return won and Game.deck_elements(deck_used).size() == 1 + "kombokoenig": + return won and deck_used.filter(func(c): return c.is_combo()).size() >= 6 + "kampagnenheld": + return campaign_stage >= Campaign.STAGES.size() + "elementmeister": + return campaign_stage >= Campaign.total() + "serie": + return records.best_streak >= 5 + "wochenheld": + return challenge_week >= 0 + return false + + +func perk_unlocked() -> bool: + return campaign_stage >= Campaign.STAGES.size() + + +## Schreibt Statistik als CSV (Kartenwerte und Gesamtzahlen), z. B. für Spieltests. +func export_stats(path: String) -> String: + var f := FileAccess.open(path, FileAccess.WRITE) + if f == null: + return "Datei nicht schreibbar" + f.store_line("karte;seltenheit;gespielt;partien;siege") + for cid in card_stats: + var st: Dictionary = card_stats[cid] + var c := Cards.by_id(cid) + f.store_line("%s;%s;%d;%d;%d" % [c.name, c.rarity, st.played, st.games, st.wins]) + f.store_line("") + f.store_line("kaempfe;%d" % stats.games) + f.store_line("siege;%d" % stats.wins) + f.store_line("booster;%d" % stats.boosters) + f.store_line("beste_serie;%d" % records.best_streak) + f.store_line("schnellster_sieg;%d" % records.fastest_win) + f.close() + return "" + + +static func today() -> String: + return today_override if today_override != "" else Time.get_date_string_from_system() + + +## Wählt die Tagesaufgabe neu, wenn ein neuer Tag begonnen hat. +func refresh_daily() -> void: + var t := today() + if daily.date != t: + var day_number := int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 86400) + var task: Dictionary = DAILY_TASKS[day_number % DAILY_TASKS.size()] + daily = {"date": t, "task": task.id, "progress": 0, "done": false} + + +func daily_task() -> Dictionary: + refresh_daily() + for t in DAILY_TASKS: + if t.id == daily.task: + return t + return DAILY_TASKS[0] + + +## Zählt ein Ereignis (siehe DAILY_TASKS) für die Tagesaufgabe. Gibt die Belohnung zurück. +func daily_event(kind: String, n: int = 1) -> int: + var task := daily_task() + if daily.done or task.kind != kind: + return 0 + daily.progress += n + if daily.progress >= task.goal: + daily.done = true + coins += task.reward + notices.append("Tagesaufgabe erfüllt: %s (+%d Münzen)" % [task.text, task.reward]) + return task.reward + return 0 diff --git a/scripts/profile.gd.uid b/scripts/profile.gd.uid new file mode 100644 index 0000000..6373b42 --- /dev/null +++ b/scripts/profile.gd.uid @@ -0,0 +1 @@ +uid://bg5qbfgyjxsyq diff --git a/scripts/ui/achievements_screen.gd b/scripts/ui/achievements_screen.gd new file mode 100644 index 0000000..1ab7406 --- /dev/null +++ b/scripts/ui/achievements_screen.gd @@ -0,0 +1,45 @@ +class_name AchievementsScreen +extends Control + +var app + + +func _ready() -> void: + var p: Profile = app.profile + var col := Style.column(self, 900, 20, 5) + Style.clabel(col, "Erfolge", 36, Style.GOLD, "center", true) + var st: Dictionary = p.stats + 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) + var task := p.daily_task() + 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] + Style.clabel(col, daily_text, 16, Style.BLUE) + Style.spacer(col, 8) + for a in Profile.ACHIEVEMENTS: + var done: bool = a.id in p.achievements + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6)) + col.add_child(panel) + var r := Style.row(panel, 12) + r.custom_minimum_size = Vector2(0, 42) + 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.text, 15, Style.MUTED, "left", false, 380) + Style.clabel(r, "+%d" % a.reward, 16, Style.GOLD, "right", true, 90) + var top := p.top_cards(5) + if not top.is_empty(): + Style.spacer(col, 4) + var parts: Array[String] = [] + for row in top: + 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.spacer(col, 6) + var br := Style.row(col) + br.alignment = BoxContainer.ALIGNMENT_CENTER + Style.cbutton(br, "Zurück", Vector2(160, 44), 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()) diff --git a/scripts/ui/achievements_screen.gd.uid b/scripts/ui/achievements_screen.gd.uid new file mode 100644 index 0000000..a1cfb54 --- /dev/null +++ b/scripts/ui/achievements_screen.gd.uid @@ -0,0 +1 @@ +uid://cw4rtcukefdl8 diff --git a/scripts/ui/battle_fx.gd b/scripts/ui/battle_fx.gd new file mode 100644 index 0000000..cae53ce --- /dev/null +++ b/scripts/ui/battle_fx.gd @@ -0,0 +1,159 @@ +class_name BattleFx +extends RefCounted +## Animationen und Töne des Kampfes aus den Spielereignissen. + +var screen # BattleScreen +var enabled := true + + +func _init(s) -> void: + screen = s + + +func sfx(kind: String) -> void: + screen.app.sfx.play(kind) + + +## Spielt die Ereignisse einer Aktion ab. centers: vorher gemerkte Positionen (für gestorbene Wesen). +func play_events(evs: Array, centers: Dictionary) -> void: + var g: Game = screen.game + var delay := 0.0 + for ev in evs: + match ev.type: + "attack": + sfx("attack") + var atk: CreatureView = screen.tiles.get(ev.attacker.uid) + lunge(atk, center_of(ev.target, centers)) + flash_target(ev.target) + delay = 0.12 + "damage": + sfx("damage") + popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay) + "heal": + sfx("heal") + popup("+%d" % ev.amount, Style.GREEN, center_of(ev.target, centers), delay) + "buff": + popup(ev.text, Style.GREEN, center_of(ev.target, centers), delay) + "blocked": + popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay) + "freeze": + popup("Eis", Color("aaddff"), center_of(ev.target, centers), delay) + "combo": + sfx("combo") + var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid}) + if where.x < 0: + where = screen.target_center({"kind": "player", "index": ev.player}) + popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1) + "play": + sfx("play") + "spell": + sfx("play") + var caster: Game.Player = g.players[ev.player] + popup(ev.card.name, Style.GOLD, screen.target_center({"kind": "player", "index": ev.player}), 0.0) + if caster != screen.perspective: + screen.show_preview(ev.card, true) + screen.preview_timer = 1.6 + "trap_set": + sfx("click") + popup("Falle gelegt", Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0) + "trap": + sfx("combo") + popup("Falle: %s!" % ev.card.name, Style.ORANGE, screen.target_center({"kind": "player", "index": ev.player}) + Vector2(0, -20), 0.0) + screen.show_preview(ev.card, true) + screen.preview_timer = 1.8 + "mulligan": + if ev.count > 0: + popup("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: + var c: Vector2 = centers.get(screen.ref_key(ref), Vector2(-1, -1)) + if c.x < 0: + c = screen.target_center(ref) + return c + + +func popup(text: String, color: Color, at: Vector2, delay: float) -> void: + if not enabled or at.x < 0: + return + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", 22) + l.add_theme_font_override("font", Style.FONT_BOLD) + l.add_theme_color_override("font_color", color) + l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9)) + l.add_theme_constant_override("outline_size", 6) + l.mouse_filter = Control.MOUSE_FILTER_IGNORE + screen.fx_layer.add_child(l) + l.reset_size() + l.position = at - l.size / 2 + l.modulate.a = 0.0 + var tw: Tween = screen.create_tween() + tw.tween_interval(delay) + tw.tween_property(l, "modulate:a", 1.0, 0.08) + tw.tween_property(l, "position:y", l.position.y - 44, 0.8).set_ease(Tween.EASE_OUT) + tw.parallel().tween_property(l, "modulate:a", 0.0, 0.5).set_delay(0.35) + tw.tween_callback(l.queue_free) + + +func lunge(tile: CreatureView, target: Vector2) -> void: + if tile == null or not enabled or target.x < 0: + return + tile.busy = true + var start := tile.slot + var dest := start + (target - (start + tile.size / 2)) * 0.35 + var tw := tile.create_tween() + tw.tween_property(tile, "position", dest, 0.12).set_ease(Tween.EASE_OUT) + tw.tween_property(tile, "position", start, 0.15).set_ease(Tween.EASE_IN) + tw.tween_callback(func(): + tile.busy = false + tile.position = tile.slot) + + +func flash_target(ref: Dictionary) -> void: + if not enabled: + return + if ref.kind == "creature": + var tile: CreatureView = screen.tiles.get(ref.uid) + if tile == null: + return + tile.flash = true + tile.queue_redraw() + var tw := tile.create_tween() + tw.tween_interval(0.35) + tw.tween_callback(func(): + tile.flash = false + tile.queue_redraw()) + else: + screen.flash_until[int(ref.index)] = Time.get_ticks_msec() + 350 + var tw: Tween = screen.create_tween() + tw.tween_interval(0.37) + tw.tween_callback(screen.refresh) + + +func pop_in(tile: CreatureView) -> void: + if not enabled: + return + tile.scale = Vector2(0.2, 0.2) + tile.modulate.a = 0.0 + var tw := tile.create_tween().set_parallel(true) + tw.tween_property(tile, "scale", Vector2.ONE, 0.25).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + tw.tween_property(tile, "modulate:a", 1.0, 0.15) + + +func fade_out(tile: CreatureView) -> void: + tile.mouse_filter = Control.MOUSE_FILTER_IGNORE + if not enabled: + tile.queue_free() + return + var tw := tile.create_tween().set_parallel(true) + tw.tween_property(tile, "modulate:a", 0.0, 0.3) + tw.tween_property(tile, "scale", Vector2(0.6, 0.6), 0.3) + tw.chain().tween_callback(tile.queue_free) + + +func move_to(tile: CreatureView, pos: Vector2) -> void: + if enabled: + screen.create_tween().tween_property(tile, "position", pos, 0.2) + else: + tile.position = pos diff --git a/scripts/ui/battle_fx.gd.uid b/scripts/ui/battle_fx.gd.uid new file mode 100644 index 0000000..6fd67c0 --- /dev/null +++ b/scripts/ui/battle_fx.gd.uid @@ -0,0 +1 @@ +uid://defclm4nv6rkn diff --git a/scripts/ui/battle_layout.gd b/scripts/ui/battle_layout.gd new file mode 100644 index 0000000..47f4e3f --- /dev/null +++ b/scripts/ui/battle_layout.gd @@ -0,0 +1,40 @@ +class_name BattleLayout +## Feste Bildschirmaufteilung des Kampfes (1280x800). + +const SIZES := {1: [110, 110, 10], 2: [96, 110, 8], 3: [78, 100, 4], 4: [54, 72, 4], 5: [44, 62, 3]} +const HAND_W := 130 +const HAND_H := 185 +const HAND_GAP := 8 +const BAR := Rect2(335, 415, 610, 68) +const OWN_X := (1280 - 5 * 110 - 4 * 10) / 2 + + +static func compact(n_others: int) -> bool: + return n_others >= 4 + + +static func hero_box_rect(n_others: int, i: int) -> Rect2: + var col_w := 1280 / n_others + var margin := 6 if compact(n_others) else 10 + return Rect2(i * col_w + margin, 10, col_w - 2 * margin, 64) + + +static func own_slot(j: int) -> Rect2: + return Rect2(OWN_X + j * 120, 250, 110, 140) + + +static func other_slot(n_others: int, i: int, j: int) -> Rect2: + var col_w := 1280 / n_others + var sz: Array = SIZES[n_others] + var bx: int = i * col_w + (col_w - 5 * sz[0] - 4 * sz[2]) / 2 + return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1]) + + +static func hand_rect(count: int, i: int) -> Rect2: + var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP + var x := (1280 - total) / 2 + return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H) + + +static func trap_rect(i: int) -> Rect2: + return Rect2(400 + i * 78, 490, 70, 98) diff --git a/scripts/ui/battle_layout.gd.uid b/scripts/ui/battle_layout.gd.uid new file mode 100644 index 0000000..4c79ad8 --- /dev/null +++ b/scripts/ui/battle_layout.gd.uid @@ -0,0 +1 @@ +uid://5sjeymvcgfl7 diff --git a/scripts/ui/battle_screen.gd b/scripts/ui/battle_screen.gd new file mode 100644 index 0000000..55a51f5 --- /dev/null +++ b/scripts/ui/battle_screen.gd @@ -0,0 +1,945 @@ +class_name BattleScreen +extends Control +## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx. +## Modi: Einzelspieler, Kampagne, Anleitung, Hotseat (Perspektivwechsel), Netzwerk (Host oder Mitspieler). + +var app +var n_opp: int +var stage := -1 # Kampagnenstufe oder -1 +var tutorial := false +var hotseat_decks: Array = [] +var hotseat_ai := 0 +var online_seat := -1 # Netzwerk: eigener Platz, sonst -1 +var net: Net +var game: Game +var perspective: Game.Player # der Mensch, dessen Hand unten liegt +var fx: BattleFx +var selected = null # Game.Creature +var pending = null # Cards.Card, wartet auf Ziel +var msg_t := 0.0 +var ai_delay := 0.75 +var ai_timer := 0.0 +var reward := -1 +var ended := false +var zoom_player = null +var preview_timer := 0.0 +var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik) +var mulligan_selected: Array = [] +var tutorial_step := 0 + +# Ansichten (auch für Tests) +var tiles := {} +var hand_views: Array = [] +var own_views: Array = [] +var enemy_views: Array = [] +var trap_views: Array = [] +var hero_panels := {} # Player -> Panel +var hero_labels := {} +var flash_until := {} # Spielerindex -> Zeitpunkt (ms) + +var hero_layer: Control +var tile_layer: Control +var hand_layer: Control +var trap_layer: Control +var zoom_layer: Control +var fx_layer: Control +var log_box: RichTextLabel +var preview_holder: Control +var preview_hint: Label +var bar_panel: Panel +var name_label: Label +var life_label: Label +var energy_label: Label +var deck_label: Label +var status_label: Label +var msg_label: Label +var b_end: Button +var b_giveup: Button +var overlay: Control +var result_label: Label +var reward_label: Label +var notice_label: Label +var confirm: Control +var handover: Control +var handover_label: Label +var mulligan_box: Control +var mulligan_layer: Control +var b_keep: Button +var b_swap: Button +var tutorial_panel: Panel +var tutorial_text: RichTextLabel +var b_tut_next: Button +var wait_label: Label + +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.", + "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 +1 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 alle gegnerischen 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!", ""], +] + + +func _init(n: int = 2, campaign_stage: int = -1) -> void: + n_opp = n + stage = campaign_stage + + +static func hotseat(decks: Array, n_ai: int) -> BattleScreen: + var b := BattleScreen.new(0, -1) + b.hotseat_decks = decks + b.hotseat_ai = n_ai + return b + + +static func online(seat: int) -> BattleScreen: + var b := BattleScreen.new(0, -1) + b.online_seat = seat + return b + + +static func guided() -> BattleScreen: + var b := BattleScreen.new(1, -1) + b.tutorial = true + return b + + +func is_hotseat() -> bool: + return game != null and game.humans.size() > 1 and online_seat < 0 + + +func is_online() -> bool: + return online_seat >= 0 + + +func is_client() -> bool: + return is_online() and not net.is_host + + +func counts_for_profile() -> bool: + return not is_hotseat() and not is_online() + + +func _ready() -> void: + var s: Dictionary = app.profile.settings + fx = BattleFx.new(self) + fx.enabled = s.animations + ai_delay = 0.75 / float(s.ai_speed) + _build_static() + if is_online(): + net = app.net + net.ended.connect(_on_net_ended) + if net.is_host: + game = net.game + net.action_applied.connect(after_action) + else: + net.snapshot_received.connect(_on_snapshot) + wait_label.visible = true + return # der erste Zustand kommt vom Host + elif not hotseat_decks.is_empty(): + game = Game.new_hotseat(hotseat_decks, hotseat_ai) + elif tutorial: + var r := Game.make_rng() + var opps := Campaign.opponents(0, r) + opps[0].perks = [] + game = Game.new_battle_with(app.profile.deck_cards(), opps, r, true) + elif stage >= 0: + var r := Game.make_rng() + game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r), r) + else: + game = Game.new_battle(app.profile.deck_cards(), n_opp) + _begin() + + +func _begin() -> void: + n_opp = game.players.size() - 1 + if is_online(): + perspective = game.players[online_seat] + else: + game.events.clear() + perspective = game.current() if game.current().is_human else game.humans[0] + build_hero_boxes() + if is_online() and net.is_host: + net.broadcast() + game.events.clear() + refresh() + + +func _build_static() -> void: + Style.panel(self, Rect2(10, 205, 320, 340)) + Style.label(self, "Verlauf", 14, Style.MUTED, Rect2(20, 209, 200, 20)) + log_box = RichTextLabel.new() + log_box.position = Vector2(18, 230) + log_box.size = Vector2(304, 308) + log_box.bbcode_enabled = true + log_box.scroll_following = true + log_box.add_theme_font_size_override("normal_font_size", 13) + log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(log_box) + if tutorial: + tutorial_panel = Style.panel(self, Rect2(10, 205, 320, 150), Style.PANEL, 8, Style.GOLD, 2) + tutorial_panel.mouse_filter = Control.MOUSE_FILTER_STOP + Style.label(self, "Anleitung", 14, Style.GOLD, Rect2(20, 209, 200, 20), "left", true) + tutorial_text = RichTextLabel.new() + tutorial_text.position = Vector2(20, 230) + tutorial_text.size = Vector2(300, 90) + tutorial_text.bbcode_enabled = false + tutorial_text.scroll_active = false + tutorial_text.add_theme_font_size_override("normal_font_size", 13) + tutorial_text.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(tutorial_text) + b_tut_next = Style.button(self, "Weiter", Rect2(230, 318, 90, 30), tutorial_next) + b_tut_next.add_theme_font_size_override("font_size", 14) + log_box.position = Vector2(18, 365) + log_box.size = Vector2(304, 173) + b_giveup = Style.button(self, "Aufgeben", Rect2(10, 553, 150, 40), func(): confirm.visible = true) + Style.panel(self, Rect2(950, 205, 320, 380)) + preview_hint = Style.label(self, "Karte überfahren für Details", 14, Style.MUTED, Rect2(950, 345, 320, 24), "center") + Style.panel(self, Rect2(BattleLayout.OWN_X - 10, 240, 5 * 110 + 4 * 10 + 20, 160), Style.darker(Style.PANEL, 0.8), 10) + bar_panel = Style.panel(self, BattleLayout.BAR) + name_label = Style.label(self, "Du", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true) + life_label = Style.label(self, "", 24, Style.RED, Rect2(470, 418, 100, 34), "left", true) + energy_label = Style.label(self, "", 24, Style.BLUE, Rect2(570, 418, 150, 34), "left", true) + deck_label = Style.label(self, "", 16, Style.MUTED, Rect2(780, 424, 150, 24), "right") + status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24)) + msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true) + b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c")) + wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center") + wait_label.visible = false + hero_layer = _layer() + tile_layer = _layer() + trap_layer = _layer() + hand_layer = _layer() + preview_holder = _layer() + zoom_layer = _layer() + fx_layer = _layer() + # Mulligan + mulligan_box = _overlay() + Style.panel(mulligan_box, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2) + Style.label(mulligan_box, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true) + 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 + overlay = _overlay() + result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true) + reward_label = Style.label(overlay, "", 26, Style.GOLD, Rect2(0, 335, 1280, 40), "center") + notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center") + Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c")) + # Aufgeben bestätigen + confirm = _overlay() + Style.panel(confirm, Rect2(390, 300, 500, 180), Style.PANEL, 10, Style.MUTED, 2) + Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(390, 320, 500, 40), "center", true) + Style.label(confirm, "Der Kampf endet ohne Münzen.", 16, Style.MUTED, Rect2(390, 365, 500, 24), "center") + Style.button(confirm, "Ja, aufgeben", Rect2(420, 410, 200, 46), leave, Color("5a2828")) + Style.button(confirm, "Weiterspielen", Rect2(660, 410, 200, 46), func(): confirm.visible = false) + # Übergabe zwischen Menschen (Hotseat) + handover = _overlay() + Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2) + handover_label = Style.label(handover, "", 30, Style.TEXT, Rect2(340, 310, 600, 44), "center", true) + Style.label(handover, "Maus weitergeben, dann weiter. Die Hand des Vorgängers ist verdeckt.", 16, Style.MUTED, Rect2(340, 362, 600, 24), "center") + Style.button(handover, "Weiter", Rect2(540, 420, 200, 50), take_over, Color("285a3c")) + + +func _layer() -> Control: + var c := Control.new() + c.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(c) + return c + + +func _overlay() -> 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) + add_child(o) + return o + + +func leave() -> void: + if is_online(): + net.leave() + if tutorial and not app.profile.tutorial_done: + app.profile.tutorial_done = true + app.save() + app.show_screen(MenuScreen.new()) + + +# --- Layout-Helfer ------------------------------------------------------------------ + +func others() -> Array: + var out := [] + var n := game.players.size() + var start := game.players.find(perspective) + for i in range(1, n): + out.append(game.players[(start + i) % n]) + return out + + +func compact() -> bool: + return BattleLayout.compact(others().size()) + + +func slot_rect(p: Game.Player, j: int) -> Rect2: + if p == perspective: + return BattleLayout.own_slot(j) + var os := others() + return BattleLayout.other_slot(os.size(), os.find(p), j) + + +func ref_key(ref: Dictionary) -> String: + return "c%d" % ref.uid if ref.kind == "creature" else "p%d" % ref.index + + +func target_center(ref: Dictionary) -> Vector2: + if ref.kind == "creature": + if tiles.has(ref.uid): + var t: CreatureView = tiles[ref.uid] + return t.slot + t.size / 2 + return Vector2(-1, -1) + var p: Game.Player = game.players[ref.index] + if p == perspective: + return BattleLayout.BAR.position + BattleLayout.BAR.size / 2 + if hero_panels.has(p): + var panel: Panel = hero_panels[p] + return panel.position + panel.size / 2 + return Vector2(-1, -1) + + +func build_hero_boxes() -> void: + Style.clear(hero_layer) + hero_panels = {} + hero_labels = {} + var os := others() + var c := compact() + for i in os.size(): + var o: Game.Player = os[i] + var box := BattleLayout.hero_box_rect(os.size(), i) + var panel := Style.panel(hero_layer, box) + panel.gui_input.connect(func(ev): + if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT: + on_click("hero", o)) + if not o.perks.is_empty(): + panel.tooltip_text = "Merkmal: " + ", ".join(o.perks.map(func(k): return Game.PERKS[k])) + hero_panels[o] = panel + var labels := {} + var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name + if not o.perks.is_empty(): + title += " ★" + labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT, + Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (44 if c else 20), 26), "left", true) + labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED, + Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true) + labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED, + Rect2(box.position.x + 90, box.position.y + 38, box.size.x - 100, 20), "right") + hero_labels[o] = labels + if c: + var b := Style.button(hero_layer, "Lupe", Rect2(box.end.x - 44, box.position.y + 5, 38, 22), func(): + zoom_player = o + refresh()) + b.add_theme_font_size_override("font_size", 11) + + +# --- Ablauf und Eingabe ------------------------------------------------------------------ + +func my_turn() -> bool: + return game != null and game.winner == "" and game.current() == perspective + + +func any_overlay() -> bool: + return confirm.visible or handover.visible or mulligan_box.visible or overlay.visible + + +func _input(event: InputEvent) -> void: + if game == null: + return + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT: + cancel_all() + return + if not (event is InputEventKey and event.pressed and not event.echo): + return + match event.keycode: + KEY_ESCAPE: + if confirm.visible: + confirm.visible = false + elif selected != null or pending != null or zoom_player != null: + cancel_all() + elif not any_overlay(): + confirm.visible = true + KEY_SPACE: + if my_turn() and not any_overlay(): + end_turn() + KEY_ENTER, KEY_KP_ENTER: + if handover.visible: + take_over() + elif mulligan_box.visible: + do_mulligan(false) + elif overlay.visible: + leave() + elif tutorial and b_tut_next.visible: + tutorial_next() + _: + if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay(): + var idx: int = event.keycode - KEY_1 + if idx < perspective.hand.size(): + on_click("hand", perspective.hand[idx]) + + +func cancel_all() -> void: + if confirm.visible: + confirm.visible = false + cancel() + zoom_player = null + refresh() + + +func _process(delta: float) -> void: + if game == null: + return + msg_t = maxf(0.0, msg_t - delta) + if msg_t <= 0.0 and msg_label.text != "": + msg_label.text = "" + if preview_timer > 0.0: + preview_timer -= delta + if preview_timer <= 0.0: + show_preview(null, false) + if game.winner != "": + if not ended: + ended = true + _finish() + return + var cur := game.current() + if cur.is_human: + if cur != perspective and is_hotseat() and not handover.visible: + handover_label.text = "%s ist dran" % cur.name + handover.visible = true + hand_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: + show_mulligan() + return + if is_client(): + return + ai_timer -= delta + if ai_timer > 0: + return + var action := AI.choose_action(game, cur) + AI.apply_action(game, cur, action) + ai_timer = ai_delay * (0.6 if action.type == "end" else 1.0) + after_action() + + +func _finish() -> void: + var won := game.winner == "human" + if counts_for_profile(): + reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids) + if tutorial: + app.profile.tutorial_done = true + app.save() + app.sfx.play("win" if won else "lose") + else: + var wp := game.winner_player + var mine: bool = wp != null and wp == perspective + app.sfx.play("win" if mine else "lose") + refresh() + + +func take_over() -> void: + handover.visible = false + hand_layer.visible = true + trap_layer.visible = true + perspective = game.current() + cancel() + zoom_player = null + build_hero_boxes() + refresh() + + +func say(m: String) -> void: + msg_t = 2.5 + msg_label.text = m + + +func cancel() -> void: + selected = null + pending = null + + +func play(card: Cards.Card, target) -> void: + if is_client(): + net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null}) + return + var err := game.play_card(perspective, card, target) + if err != "": + say(err) + + +func end_turn() -> void: + if not my_turn(): + return + cancel() + zoom_player = null + if is_client(): + net.send_action({"type": "end"}) + return + game.end_turn() + ai_timer = ai_delay + after_action() + + +func show_mulligan() -> void: + mulligan_selected = [] + 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: + if idx in mulligan_selected: + 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(): + net.send_action({"type": "mulligan", "hand": idxs}) + return + var cards := [] + for i in idxs: + cards.append(perspective.hand[i]) + game.mulligan(perspective, cards) + after_action() + + +func on_click(kind: String, obj) -> void: + var g := game + if not my_turn() or any_overlay(): + return + if pending != null: + var card = pending + pending = null + if obj in g.valid_targets(perspective, card.target_type()): + play(card, obj) + else: + say("Abgebrochen") + zoom_player = null + after_action() + return + if selected != null: + var cr = selected + selected = null + if kind == "enemy" or kind == "hero": + if is_client(): + net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)}) + else: + var err := g.attack(cr, obj) + if err != "": + say(err) + zoom_player = null + after_action() + return + match kind: + "hand": + var ok := g.can_play(perspective, obj) + if not ok[0]: + say(ok[1]) + elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty(): + pending = obj + say("Wähle ein Ziel") + else: + play(obj, null) + "own": + if obj.can_attack(): + selected = obj + say("Wähle ein Angriffsziel") + else: + say("%s kann nicht angreifen" % obj.name) + after_action() + + +func on_tile_click(cr: Game.Creature) -> void: + var o = cr.owner() + if o == null: + return + if o == perspective: + on_click("own", cr) + elif o.alive(): + on_click("enemy", cr) + + +func show_preview(card, entered: bool) -> void: + Style.clear(preview_holder) + if entered and card != null: + var v := CardView.new(card, Vector2(210, 294)) + v.position = Vector2(1005, 215) + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + preview_holder.add_child(v) + preview_hint.visible = not (entered and card != null) + + +## Vorschau einer Handkarte mit Hinweisen auf Resonanz und Feldkombos mit dem eigenen Feld. +func show_hand_preview(card: Cards.Card, entered: bool) -> void: + Style.clear(preview_holder) + preview_hint.visible = not entered + if not entered: + return + var hints: Array[String] = combo_hints(card) + var v := CardView.new(card, Vector2(190, 266) if not hints.is_empty() else Vector2(210, 294)) + v.position = Vector2(1015, 212) if not hints.is_empty() else Vector2(1005, 215) + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + preview_holder.add_child(v) + if not hints.is_empty(): + var box := RichTextLabel.new() + box.position = Vector2(958, 480) + box.size = Vector2(304, 46) + box.bbcode_enabled = true + box.scroll_active = false + box.fit_content = false + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + box.add_theme_font_size_override("normal_font_size", 11) + box.add_theme_color_override("default_color", Style.GOLD) + box.text = "[center]" + "\n".join(hints.slice(0, 2)) + "[/center]" + preview_holder.add_child(box) + + +func combo_hints(card: Cards.Card) -> Array[String]: + var out: Array[String] = [] + if not card.is_creature(): + return out + var partners: Array[String] = [] + var combos := {} + for other in perspective.board: + if other.card.elements.any(func(e): return e in card.elements): + partners.append(other.name) + for e1 in card.elements: + for e2 in other.card.elements: + if e1 != e2: + var name := Cards.combo_name([e1, e2]) + if not combos.has(name): + combos[name] = other.name + if not partners.is_empty(): + out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)]) + for name in combos: + out.append("Feldkombo %s mit %s: %s" % [name, combos[name], Cards.FIELD_COMBO_TEXT[name]]) + return out + + +# --- Netzwerk ------------------------------------------------------------------------------- + +func _on_snapshot(snap: Dictionary) -> void: + var first := game == null + game = Game.from_snapshot(snap) + wait_label.visible = false + if first: + _begin() + return + perspective = game.players[online_seat] + build_hero_boxes() + after_action() + + +func _on_net_ended(reason: String) -> void: + if game == null or game.winner == "": + say(reason) + await get_tree().create_timer(1.5).timeout + if is_inside_tree(): + app.show_screen(MenuScreen.new()) + + +# --- Ereignisse ---------------------------------------------------------------------------- + +## Nach jeder Aktion: Ereignisse sichern, Ansicht angleichen, Animationen abspielen. +func after_action() -> void: + if is_online() and net.is_host: + net.broadcast() + var evs := game.events.duplicate() + game.events.clear() + if counts_for_profile(): + _note_events(evs) + var centers := {} + for ev in evs: + for key in ["target", "attacker", "source"]: + if ev.has(key): + centers[ref_key(ev[key])] = target_center(ev[key]) + refresh() + fx.play_events(evs, centers) + + +func _note_events(evs: Array) -> void: + var hi := game.players.find(game.human) + for ev in evs: + if ev.get("player", -1) != hi: + continue + match ev.type: + "play", "spell": + played_ids.append(ev.card.id) + if ev.type == "play": + app.profile.daily_event("creature_played") + "trap_set": + app.profile.daily_event("trap_set") + "combo": + app.profile.daily_event("combo") + + +# --- 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: + if tutorial_step < TUTORIAL_STEPS.size() - 1: + tutorial_step += 1 + app.sfx.play("click") + refresh() + + +func _tutorial_refresh() -> void: + while tutorial_step < TUTORIAL_STEPS.size() - 1 and tutorial_condition(TUTORIAL_STEPS[tutorial_step][1]): + tutorial_step += 1 + tutorial_text.text = "%d/%d %s" % [tutorial_step + 1, TUTORIAL_STEPS.size(), TUTORIAL_STEPS[tutorial_step][0]] + b_tut_next.visible = tutorial_step < TUTORIAL_STEPS.size() - 1 + + +# --- Ansicht angleichen -------------------------------------------------------------------- + +func refresh() -> void: + if game == null: + return + var g := game + var h := perspective + var now := Time.get_ticks_msec() + var mine := my_turn() + var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else [] + hand_views = [] + own_views = [] + enemy_views = [] + trap_views = [] + + for o in others(): + var panel: Panel = hero_panels[o] + var bcol = null + var bw := 0 + if g.current() == o: + bcol = Style.BLUE + bw = 3 + if o in valid or (selected != null and o.alive() and g.can_attack(selected, o)[0]): + bcol = Style.ORANGE + bw = 3 + if int(flash_until.get(g.players.find(o), 0)) > now: + bcol = Style.RED + bw = 4 + panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if o.alive() else Style.darker(Style.PANEL, 0.6), 8, bcol, bw)) + panel.mouse_filter = Control.MOUSE_FILTER_STOP if o.alive() else Control.MOUSE_FILTER_IGNORE + var labels: Dictionary = hero_labels[o] + labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED) + if o.alive(): + labels.life.text = "♥ %d" % maxi(o.life, 0) + var traps_txt := (" F %d" if compact() else " Fallen %d") % o.traps.size() if not o.traps.is_empty() else "" + labels.stats.text = ("H %d D %d%s ◆ %d" if compact() else "Hand %d Deck %d%s ◆ %d") % [o.hand.size(), o.deck.size(), traps_txt, o.max_energy] + else: + labels.life.text = "besiegt" + labels.life.add_theme_color_override("font_color", Style.MUTED) + labels.stats.text = "" + + var seen := {} + for p in g.players: + for j in p.board.size(): + var cr: Game.Creature = p.board[j] + var rect := slot_rect(p, j) + seen[cr.uid] = true + var tile: CreatureView + if tiles.has(cr.uid): + tile = tiles[cr.uid] + tile.creature = cr + if tile.size != rect.size: + tile.size = rect.size + tile.custom_minimum_size = rect.size + tile.pivot_offset = rect.size / 2 + else: + tile = CreatureView.new(cr, rect.size) + tile.position = rect.position + tile.slot = rect.position + tiles[cr.uid] = tile + tile_layer.add_child(tile) + tile.hover.connect(show_preview) + var uid := cr.uid + tile.clicked.connect(func(_v): + var c := game.find_creature(uid) + if c != null: + on_tile_click(c)) + fx.pop_in(tile) + tile.slot = rect.position + if not tile.busy and tile.position != rect.position: + fx.move_to(tile, rect.position) + var b = null + var w := 2 + if cr == selected: + b = Style.YELLOW + w = 4 + elif cr in valid or (selected != null and g.can_attack(selected, cr)[0]): + b = Style.ORANGE + w = 3 + elif p == h and mine and cr.can_attack(): + b = Style.GREEN + w = 3 + tile.border = b + tile.width = w + tile.queue_redraw() + if p == h: + own_views.append(tile) + else: + enemy_views.append(tile) + for uid in tiles.keys(): + if not seen.has(uid): + var t: CreatureView = tiles[uid] + tiles.erase(uid) + fx.fade_out(t) + + Style.clear(trap_layer) + if not h.traps.is_empty(): + Style.label(trap_layer, "Fallen", 13, Style.MUTED, Rect2(335, 492, 60, 20)) + for i in h.traps.size(): + var tv := CardView.new(h.traps[i], BattleLayout.trap_rect(i).size) + tv.position = BattleLayout.trap_rect(i).position + tv.hover.connect(show_preview) + trap_layer.add_child(tv) + trap_views.append(tv) + + Style.clear(hand_layer) + for i in h.hand.size(): + var card: Cards.Card = h.hand[i] + var playable: bool = mine and g.can_play(h, card)[0] + var r := BattleLayout.hand_rect(h.hand.size(), i) + var v := CardView.new(card, r.size) + v.position = r.position + if card == pending: + v.border = Style.YELLOW + elif playable: + v.border = Style.GREEN + v.dim = not playable + v.hover.connect(show_hand_preview) + v.clicked.connect(func(_view): on_click("hand", card)) + hand_layer.add_child(v) + hand_views.append(v) + + var bar_border = Style.BLUE if mine else null + if int(flash_until.get(g.players.find(h), 0)) > now: + bar_border = Style.RED + bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0)) + name_label.text = h.name + life_label.text = "♥ %d" % maxi(h.life, 0) + energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy] + 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 …"] + b_end.disabled = not mine + var lines: Array[String] = [] + var start := maxi(0, g.log.size() - 60) + for i in range(start, g.log.size()): + var line := g.log[i] + if line.begins_with("---"): + lines.append("[color=#6eaaf0]%s[/color]" % line) + elif line.begins_with("Feldkombo") or line.begins_with("Falle von"): + lines.append("[color=#f5c850]%s[/color]" % line) + else: + lines.append(line) + log_box.text = "\n".join(lines) + if tutorial: + _tutorial_refresh() + + Style.clear(zoom_layer) + if zoom_player != null and zoom_player.alive(): + build_zoom(valid) + + if g.winner != "": + var wp := g.winner_player + if counts_for_profile(): + var won := g.winner == "human" + result_label.text = "Sieg!" if won else "Niederlage" + result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED) + reward_label.text = "+%d Münzen" % maxi(reward, 0) + var notes: Array[String] = app.profile.take_notices() + if not notes.is_empty(): + notice_label.text = "\n".join(notes) + else: + var mine_win: bool = wp != null and wp == perspective + result_label.text = ("%s gewinnt!" % wp.name) if wp != null and wp.is_human else "Die KI gewinnt" + result_label.add_theme_color_override("font_color", Style.GREEN if mine_win else Style.RED) + reward_label.text = "Gemeinsame Partie, keine Münzen" + overlay.visible = true + confirm.visible = false + handover.visible = false + mulligan_box.visible = false + + +func build_zoom(valid: Array) -> void: + var o: Game.Player = zoom_player + var g := game + var box := Rect2(190, 190, 900, 300) + var back := Style.panel(zoom_layer, box, Style.PANEL, 10, Style.GOLD, 2) + back.mouse_filter = Control.MOUSE_FILTER_STOP + Style.label(zoom_layer, "%s · %s ♥ %d Hand %d Deck %d Fallen %d ◆ %d" % [o.name, o.title, o.life, o.hand.size(), o.deck.size(), o.traps.size(), o.max_energy], + 18, Style.TEXT, Rect2(box.position.x + 20, box.position.y + 12, 800, 30), "left", true) + for j in o.board.size(): + var cr: Game.Creature = o.board[j] + var b = null + if cr in valid or (selected != null and g.can_attack(selected, cr)[0]): + b = Style.ORANGE + var v := CreatureView.new(cr, Vector2(110, 140), b, 3 if b != null else 2) + v.position = Vector2(box.position.x + 20 + j * 122, box.position.y + 56) + v.hover.connect(show_preview) + v.clicked.connect(func(_x): on_tile_click(cr)) + zoom_layer.add_child(v) + if o.board.is_empty(): + Style.label(zoom_layer, "Keine Wesen auf dem Feld", 16, Style.MUTED, Rect2(box.position.x + 20, box.position.y + 110, 600, 30)) + var hero_ok: bool = (o in valid) or (selected != null and g.can_attack(selected, o)[0]) + var bh := Style.button(zoom_layer, "Held als Ziel", Rect2(box.position.x + 20, box.end.y - 60, 180, 44), func(): on_click("hero", o), Color("5a3c28")) + bh.disabled = not hero_ok + Style.button(zoom_layer, "Schließen", Rect2(box.end.x - 180, box.end.y - 60, 160, 44), func(): + zoom_player = null + refresh()) diff --git a/scripts/ui/battle_screen.gd.uid b/scripts/ui/battle_screen.gd.uid new file mode 100644 index 0000000..66b8101 --- /dev/null +++ b/scripts/ui/battle_screen.gd.uid @@ -0,0 +1 @@ +uid://bmf82cuhs7h1a diff --git a/scripts/ui/booster_screen.gd b/scripts/ui/booster_screen.gd new file mode 100644 index 0000000..4f50f69 --- /dev/null +++ b/scripts/ui/booster_screen.gd @@ -0,0 +1,122 @@ +class_name BoosterScreen +extends Control + +const CARD_W := 200 +const CARD_H := 280 + +var app +var cards: Array = [] +var revealed := {} +var new_ids := {} +var coins_label: Label +var msg_label: Label +var b_buy: Button +var b_all: Button +var slots: Control + + +class CardBack extends Control: + signal clicked + + func _init(sz: Vector2) -> void: + size = sz + custom_minimum_size = sz + mouse_filter = Control.MOUSE_FILTER_STOP + + func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + accept_event() + clicked.emit() + + func _draw() -> void: + var rect := Rect2(Vector2.ZERO, size) + draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 3), rect) + Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6) + Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true) + + +func slot_rect(i: int) -> Rect2: + var gap := 20 + var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2 + return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H) + + +func _ready() -> void: + Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true) + coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center") + Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.", + 16, Style.MUTED, Rect2(0, 115, 1280, 24), "center") + slots = Control.new() + slots.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(slots) + msg_label = Style.label(self, "", 18, Style.RED, Rect2(0, 610, 1280, 30), "center") + b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(360, 720, 260, 48), buy, Color("5a4628")) + b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 140, 48), reveal_all) + Style.button(self, "Zurück", Rect2(800, 720, 120, 48), func(): app.show_screen(MenuScreen.new())) + refresh() + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + app.show_screen(MenuScreen.new()) + + +func buy() -> void: + var p: Profile = app.profile + var before: Dictionary = p.collection.duplicate() + var got: Array = p.open_booster() + if got.is_empty(): + msg_label.text = "Nicht genug Münzen" + msg_label.add_theme_color_override("font_color", Style.RED) + return + app.save() + app.sfx.play("play") + cards = got + revealed = {} + new_ids = {} + for c in cards: + if before.get(c.id, 0) == 0: + new_ids[c.id] = true + var notes: Array[String] = p.take_notices() + msg_label.text = " · ".join(notes) + msg_label.add_theme_color_override("font_color", Style.GREEN) + refresh() + + +func reveal(i: int) -> void: + revealed[i] = true + app.sfx.play("click") + refresh() + + +func reveal_all() -> void: + for i in 5: + revealed[i] = true + refresh() + + +func refresh() -> void: + var p: Profile = app.profile + coins_label.text = "Münzen: %d" % p.coins + b_buy.disabled = p.coins < Profile.BOOSTER_COST + b_all.disabled = cards.is_empty() or revealed.size() >= 5 + Style.clear(slots) + for i in 5: + var r := slot_rect(i) + if cards.is_empty(): + Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2) + elif revealed.has(i): + var c: Cards.Card = cards[i] + var v := CardView.new(c, r.size) + v.position = r.position + 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) + 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) + else: + Style.label(slots, "jetzt %dx" % p.collection[c.id], 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center") + else: + var back := CardBack.new(r.size) + back.position = r.position + back.clicked.connect(reveal.bind(i)) + slots.add_child(back) diff --git a/scripts/ui/booster_screen.gd.uid b/scripts/ui/booster_screen.gd.uid new file mode 100644 index 0000000..aae5d28 --- /dev/null +++ b/scripts/ui/booster_screen.gd.uid @@ -0,0 +1 @@ +uid://d3irimwei0s4d diff --git a/scripts/ui/campaign_screen.gd b/scripts/ui/campaign_screen.gd new file mode 100644 index 0000000..5c406d7 --- /dev/null +++ b/scripts/ui/campaign_screen.gd @@ -0,0 +1,59 @@ +class_name CampaignScreen +extends Control + +var app + + +func _ready() -> void: + var p: Profile = app.profile + var col := Style.column(self, 1000, 24, 6) + Style.clabel(col, "Kampagne", 36, Style.GOLD, "center", true) + 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(): + var st: Dictionary = Campaign.STAGES[i] + var done := i < p.campaign_stage + var next := i == p.campaign_stage + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6, + Style.GOLD if next else null, 2 if next else 0)) + col.add_child(panel) + var r := Style.row(panel, 12) + 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, "%s · %s" % [st.name, st.title], 17, Style.TEXT if not done else Style.MUTED, "left", true, 300) + var dots := Control.new() + dots.custom_minimum_size = Vector2(60, 0) + dots.mouse_filter = Control.MOUSE_FILTER_IGNORE + var elems: Array = st.elements + dots.draw.connect(func(): + for k in elems.size(): + dots.draw_circle(Vector2(10 + k * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[k]])) + r.add_child(dots) + var table: String = "Duell" if st.extra == 0 else "%d am Tisch" % (st.extra + 2) + Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, Game.PERKS[st.perk]], 13, Style.MUTED, "left", false, 300) + if done: + Style.clabel(r, "abgeschlossen", 14, Style.GREEN, "right", false, 110) + Style.cbutton(r, "Wiederholen", Vector2(140, 38), _start.bind(i)) + elif next: + Style.clabel(r, "", 14, Style.GREEN, "right", false, 110) + Style.cbutton(r, "Kämpfen", Vector2(140, 38), _start.bind(i), Color("285a3c")) + else: + Style.clabel(r, "", 14, Style.GREEN, "right", false, 110) + var b := Style.cbutton(r, "Gesperrt", Vector2(140, 38), func(): pass) + b.disabled = true + if p.campaign_stage >= Campaign.STAGES.size(): + Style.clabel(col, "Kampagne abgeschlossen. Der Archimagier ist besiegt!", 18, Style.GREEN, "center", true) + Style.spacer(col, 4) + var br := Style.row(col) + br.alignment = BoxContainer.ALIGNMENT_CENTER + Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) + + +func _start(stage: int) -> void: + app.show_screen(BattleScreen.new(0, stage)) + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + app.show_screen(MenuScreen.new()) diff --git a/scripts/ui/campaign_screen.gd.uid b/scripts/ui/campaign_screen.gd.uid new file mode 100644 index 0000000..0b3690d --- /dev/null +++ b/scripts/ui/campaign_screen.gd.uid @@ -0,0 +1 @@ +uid://bk50mk881o2hr diff --git a/scripts/ui/card_grid.gd b/scripts/ui/card_grid.gd new file mode 100644 index 0000000..690c1dd --- /dev/null +++ b/scripts/ui/card_grid.gd @@ -0,0 +1,50 @@ +class_name CardGrid +extends ScrollContainer +## Scrollbares Kartenraster mit Beschriftung unter jeder Karte. + +signal card_clicked(card: Cards.Card) +signal card_hover(card: Cards.Card, entered: bool) + +const COLS := 7 +const CW := 100 +const CH := 140 +const GAP := 12 + +var grid: GridContainer + + +func _init(rect: Rect2) -> void: + position = rect.position + size = rect.size + horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + grid = GridContainer.new() + grid.columns = COLS + grid.add_theme_constant_override("h_separation", GAP) + grid.add_theme_constant_override("v_separation", GAP) + add_child(grid) + + +## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool +func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void: + Style.clear(grid) + for c in cards: + var cell := Control.new() + cell.custom_minimum_size = Vector2(CW, CH + 16) + cell.mouse_filter = Control.MOUSE_FILTER_IGNORE + var v := CardView.new(c, Vector2(CW, CH)) + v.dim = dim_fn.call(c) + v.clicked.connect(func(_view): card_clicked.emit(c)) + v.hover.connect(func(card, entered): card_hover.emit(card, entered)) + cell.add_child(v) + var text: String = label_fn.call(c) + Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center") + grid.add_child(cell) + + +func views() -> Array: + var out := [] + for cell in grid.get_children(): + for v in cell.get_children(): + if v is CardView: + out.append(v) + return out diff --git a/scripts/ui/card_grid.gd.uid b/scripts/ui/card_grid.gd.uid new file mode 100644 index 0000000..9a0c7ec --- /dev/null +++ b/scripts/ui/card_grid.gd.uid @@ -0,0 +1 @@ +uid://bil3i64hn7rfd diff --git a/scripts/ui/card_view.gd b/scripts/ui/card_view.gd new file mode 100644 index 0000000..37fb056 --- /dev/null +++ b/scripts/ui/card_view.gd @@ -0,0 +1,65 @@ +class_name CardView +extends Control +## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau). + +signal clicked(view: CardView) +signal hover(card: Cards.Card, entered: bool) + +var card: Cards.Card +var border = null # Color oder null (dann Seltenheitsfarbe) +var dim := false + + +func _init(c: Cards.Card, sz: Vector2) -> void: + card = c + custom_minimum_size = sz + size = sz + mouse_filter = Control.MOUSE_FILTER_STOP + mouse_entered.connect(func(): hover.emit(card, true)) + mouse_exited.connect(func(): hover.emit(card, false)) + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + accept_event() + clicked.emit(self) + + +func _draw() -> void: + var rect := Rect2(Vector2.ZERO, size) + var big := size.x >= 180 + var mid := size.x >= 115 + var f_name := 18 if big else (14 if mid else 12) + var f_text := 14 if big else (11 if mid else 10) + var f_stat := 24 if big else (18 if mid else 15) + Style.fill_elements(self, rect, card.elements, 8) + var bcol: Color = border if border != null else Cards.RARITY_COLORS[card.rarity] + Style.border(self, rect, bcol, 3 if big else 2, 8) + # Kosten + var r := 16 if big else 12 + var cc := Vector2(r + 4, r + 4) + draw_circle(cc, r, Color("14141c")) + Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true) + # Name und Kombo + var nm := Style.fit(card.name, true, f_name, size.x - 2 * r - 12) + Style.text(self, nm, f_name, Style.TEXT, Vector2(size.x / 2 + r / 2.0, 4), "midtop", true) + if mid: + var sub: String = (card.combo() + " · " + card.kind) if card.is_combo() else card.kind + Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop") + # Bild + var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0) + var band_bottom: float = size.y * 0.42 - 4 + if band_bottom - band_top > 14: + var gsize: float = minf(band_bottom - band_top, size.x * 0.42) + Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize) + # 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_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(f_text)), + card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, f_text, 6 if big else 4, Style.TEXT) + # Werte + 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.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true) + if dim: + draw_rect(rect, Color(0, 0, 0, 0.47)) diff --git a/scripts/ui/card_view.gd.uid b/scripts/ui/card_view.gd.uid new file mode 100644 index 0000000..5126bf1 --- /dev/null +++ b/scripts/ui/card_view.gd.uid @@ -0,0 +1 @@ +uid://oom648upeavd diff --git a/scripts/ui/creature_view.gd b/scripts/ui/creature_view.gd new file mode 100644 index 0000000..38895aa --- /dev/null +++ b/scripts/ui/creature_view.gd @@ -0,0 +1,92 @@ +class_name CreatureView +extends Control +## Zeichnet ein Wesen auf dem Feld. Bleibt über Zustandsänderungen bestehen (für Animationen). + +signal clicked(view: CreatureView) +signal hover(card: Cards.Card, entered: bool) + +var creature: Game.Creature +var border = null +var width := 2 +var flash := false # kurz rot: wurde gerade angegriffen +var slot: Vector2 # Zielposition im Raster +var busy := false # wird gerade animiert (kein Neupositionieren) + + +func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void: + creature = cr + border = b + width = w + custom_minimum_size = sz + size = sz + pivot_offset = sz / 2 + mouse_filter = Control.MOUSE_FILTER_STOP + mouse_entered.connect(func(): hover.emit(creature.card, true)) + mouse_exited.connect(func(): hover.emit(creature.card, false)) + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + accept_event() + clicked.emit(self) + + +func _draw() -> void: + var rect := Rect2(Vector2.ZERO, size) + var small := size.x < 100 + var mini := size.x < 60 + Style.fill_elements(self, rect, creature.card.elements, 6) + var bcol: Color = border if border != null else Cards.RARITY_COLORS[creature.card.rarity] + if flash: + bcol = Style.RED + Style.border(self, rect, bcol, 4 if flash else width, 6) + if mini: + _draw_mini() + return + var fs := 11 if small else 13 + var nm := Style.fit(creature.name, true, fs, size.x - 6) + Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true) + var badges: Array[String] = [] + if creature.taunt(): + badges.append("Wächter") + if creature.shield: + badges.append("Schild") + if creature.attacks_per_turn() == 2: + badges.append("x2") + if creature.frozen: + badges.append("Eis") + if creature.resonance > 0: + badges.append("Resonanz") + var y := 18.0 if small else 22.0 + for b in badges: + Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop") + y += 12 if small else 15 + var f := 16 if small else 20 + var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW + Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true) + var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8") + Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true) + if creature.frozen: + draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35)) + + +## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe. +func _draw_mini() -> void: + var letters: Array[String] = [] + if creature.taunt(): + letters.append("W") + if creature.shield: + letters.append("S") + if creature.attacks_per_turn() == 2: + letters.append("2") + if creature.frozen: + letters.append("E") + if creature.resonance > 0: + letters.append("R") + Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true) + var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW + Style.text(self, str(creature.power()), 13, atk_col, Vector2(4, size.y - 2), "bottomleft", true) + var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8") + Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true) + if creature.frozen: + draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35)) diff --git a/scripts/ui/creature_view.gd.uid b/scripts/ui/creature_view.gd.uid new file mode 100644 index 0000000..c13b242 --- /dev/null +++ b/scripts/ui/creature_view.gd.uid @@ -0,0 +1 @@ +uid://bcnj0sdt2jbjb diff --git a/scripts/ui/deck_screen.gd b/scripts/ui/deck_screen.gd new file mode 100644 index 0000000..1117a29 --- /dev/null +++ b/scripts/ui/deck_screen.gd @@ -0,0 +1,351 @@ +class_name DeckScreen +extends Control +## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten. + +const SORTS := ["Kosten", "Name", "Element", "Seltenheit"] + +var app +var slot := 0 +var deck: Array = [] +var filter := "" +var search := "" +var sort_mode := 0 +var show_missing := false +var msg_label: Label +var grid: CardGrid +var deck_list: Control +var deck_label: Label +var elems_label: Label +var valid_label: Label +var active_label: Label +var curve: Control +var preview: CardView +var search_box: LineEdit +var sort_box: OptionButton +var missing_box: CheckButton +var slot_buttons: Array = [] +var b_activate: Button +var b_autofill: Button +var name_box: LineEdit + + +class DeckRow extends Control: + signal clicked(card) + signal hover(card, entered: bool) + var card: Cards.Card + var count: int + var lit := false + + func _init(c: Cards.Card, n: int, sz: Vector2) -> void: + card = c + count = n + size = sz + custom_minimum_size = sz + mouse_filter = Control.MOUSE_FILTER_STOP + mouse_entered.connect(func(): + lit = true + queue_redraw() + hover.emit(card, true)) + mouse_exited.connect(func(): + lit = false + queue_redraw() + hover.emit(card, false)) + + func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + accept_event() + clicked.emit(card) + + func _draw() -> void: + if lit: + draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size)) + for k in card.elements.size(): + 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, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true) + + +class EnergyCurve extends Control: + var cards: Array = [] + + func _draw() -> void: + Style.text(self, "Energiekurve", 12, Style.MUTED, Vector2(0, 0)) + var counts := {} + for c in cards: + var k: int = mini(c.cost, 8) + counts[k] = counts.get(k, 0) + 1 + for i in range(1, 9): + var n: int = counts.get(i, 0) + var x := (i - 1) * 48 + draw_rect(Rect2(x, 104 - n * 5, 36, n * 5), Style.BLUE) + if n > 0: + Style.text(self, str(n), 11, Style.TEXT, Vector2(x + 18, 102 - n * 5), "midbottom") + Style.text(self, str(i) + ("+" if i == 8 else ""), 12, Style.MUTED, Vector2(x + 18, 108), "midtop") + + +func _ready() -> void: + slot = app.profile.active_deck + deck = app.profile.decks[slot].cards.duplicate() + Style.label(self, "Deck bauen", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true) + Style.label(self, "Klick: Karte ins Deck. Klick in der Liste: Karte entfernen. Mausrad: scrollen.", + 14, Style.MUTED, Rect2(300, 18, 700, 24)) + Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) + for i in Cards.ELEMENTS.size(): + 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)) + search_box = LineEdit.new() + search_box.position = Vector2(400, 55) + search_box.size = Vector2(160, 34) + search_box.placeholder_text = "Suche" + search_box.text_changed.connect(func(t: String): + search = t + refresh()) + add_child(search_box) + sort_box = OptionButton.new() + sort_box.position = Vector2(570, 55) + sort_box.size = Vector2(130, 34) + for s in SORTS: + sort_box.add_item(s) + sort_box.item_selected.connect(func(i: int): + sort_mode = i + refresh()) + add_child(sort_box) + missing_box = CheckButton.new() + missing_box.position = Vector2(710, 58) + missing_box.text = "Fehlende" + missing_box.toggled.connect(func(on: bool): + show_missing = on + refresh()) + add_child(missing_box) + grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690)) + grid.card_clicked.connect(add) + grid.card_hover.connect(show_preview) + add_child(grid) + # Rechte Seite: Slots, Deckliste, Kurve + Style.panel(self, Rect2(845, 50, 420, 680)) + for i in Profile.DECK_SLOTS: + slot_buttons.append(Style.button(self, str(i + 1), Rect2(860 + i * 46, 58, 40, 32), select_slot.bind(i))) + b_activate = Style.button(self, "Aktiv setzen", Rect2(1100, 58, 150, 32), activate) + name_box = LineEdit.new() + name_box.position = Vector2(860, 98) + name_box.size = Vector2(200, 30) + name_box.max_length = 20 + name_box.text_submitted.connect(func(t: String): rename(t)) + name_box.focus_exited.connect(func(): rename(name_box.text)) + add_child(name_box) + deck_label = Style.label(self, "", 22, Style.TEXT, Rect2(1070, 98, 120, 30), "left", true) + active_label = Style.label(self, "", 14, Style.GREEN, Rect2(1180, 104, 75, 24), "right") + b_autofill = Style.button(self, "Auto-Ausfüllen", Rect2(1100, 154, 150, 26), autofill) + b_autofill.add_theme_font_size_override("font_size", 13) + elems_label = Style.label(self, "", 16, Style.MUTED, Rect2(860, 130, 390, 24)) + valid_label = Style.label(self, "", 14, Style.GREEN, Rect2(860, 156, 235, 22)) + deck_list = Control.new() + deck_list.position = Vector2(860, 182) + deck_list.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(deck_list) + curve = EnergyCurve.new() + curve.position = Vector2(860, 604) + curve.size = Vector2(400, 125) + curve.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(curve) + 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, "Leeren", Rect2(1050, 740, 90, 44), clear_deck) + Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new())) + preview = CardView.new(Cards.all()[0], Vector2(200, 280)) + preview.mouse_filter = Control.MOUSE_FILTER_IGNORE + preview.visible = false + add_child(preview) + refresh() + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus() and not name_box.has_focus(): + app.show_screen(MenuScreen.new()) + + +func _process(_delta: float) -> void: + if preview.visible: + var pos := get_local_mouse_position() + Vector2(16, 16) + pos.x = clampf(pos.x, 0, 1280 - preview.size.x) + pos.y = clampf(pos.y, 0, 800 - preview.size.y) + preview.position = pos + + +func set_filter(e: String) -> void: + filter = e + refresh() + + +func select_slot(i: int) -> void: + slot = i + deck = app.profile.decks[slot].cards.duplicate() + say("", true) + refresh() + + +func rename(t: String) -> void: + if t.strip_edges() == app.profile.deck_name(slot): + return + var err: String = app.profile.rename_deck(slot, t) + if err == "": + app.save() + say("Umbenannt in %s" % app.profile.deck_name(slot), true) + else: + say(err, false) + refresh() + + +func autofill() -> void: + app.profile.decks[slot].cards = deck.duplicate() + var err: String = app.profile.autofill_deck(slot) + deck = app.profile.decks[slot].cards.duplicate() + if err == "": + app.save() + say("Aufgefüllt und gespeichert", true) + else: + say(err, false) + refresh() + + +func activate() -> void: + var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection) + if not ok[0]: + say("Erst ein spielbares Deck speichern", false) + return + app.profile.decks[slot].cards = deck.duplicate() + app.profile.active_deck = slot + app.save() + say("%s ist jetzt das aktive Deck" % app.profile.deck_name(slot), true) + refresh() + + +## Karten der Sammlung (oder alle, wenn Fehlende gezeigt werden), gefiltert und sortiert. +func cards() -> Array: + var out: Array = Cards.all().duplicate() if show_missing else app.profile.owned_cards() + if filter != "": + out = out.filter(func(c): return filter in c.elements) + if search != "": + 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()) + match sort_mode: + 0: + out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name)) + 1: + out.sort_custom(func(a, b): return a.name < b.name) + 3: + out.sort_custom(func(a, b): + var ra := Cards.RARITIES.find(a.rarity) + var rb := Cards.RARITIES.find(b.rarity) + return ra > rb or (ra == rb and a.cost < b.cost)) + return out + + +func say(m: String, ok: bool) -> void: + msg_label.text = m + msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED) + + +func add(card: Cards.Card) -> void: + var owned: int = app.profile.collection.get(card.id, 0) + var have := deck.count(card.id) + if owned == 0: + say("%s fehlt in deiner Sammlung (Werkstatt)" % card.name, false) + elif deck.size() >= Game.DECK_SIZE: + say("Das Deck ist voll (%d)" % Game.DECK_SIZE, false) + elif have >= mini(owned, Game.MAX_COPIES): + say("Nicht mehr Kopien möglich (max. %d)" % mini(owned, Game.MAX_COPIES), false) + else: + 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: + say("Höchstens %d Elemente im Deck" % Game.MAX_DECK_ELEMENTS, false) + else: + deck.append(card.id) + app.sfx.play("click") + say("", true) + refresh() + + +func remove(card: Cards.Card) -> void: + deck.erase(card.id) + say("", true) + refresh() + + +func clear_deck() -> void: + deck = [] + refresh() + + +func save_deck() -> void: + var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection) + if ok[0]: + app.profile.decks[slot].cards = deck.duplicate() + app.save() + say("%s gespeichert" % app.profile.deck_name(slot), true) + else: + say(ok[1], false) + refresh() + + +func deck_cards() -> Array: + var out := [] + for cid in deck: + out.append(Cards.by_id(cid)) + return out + + +func show_preview(card: Cards.Card, entered: bool) -> void: + if entered: + preview.card = card + preview.visible = true + preview.queue_redraw() + elif preview.card == card: + preview.visible = false + + +func refresh() -> void: + var p: Profile = app.profile + grid.fill(cards(), + func(c): + var owned: int = p.collection.get(c.id, 0) + return "fehlt" if owned == 0 else "%d/%d im Deck" % [deck.count(c.id), owned], + func(c): + var owned: int = p.collection.get(c.id, 0) + return owned == 0 or deck.count(c.id) >= mini(owned, Game.MAX_COPIES)) + for i in slot_buttons.size(): + var b: Button = slot_buttons[i] + var col: Color = Style.GOLD if i == slot else (Style.GREEN if p.deck_valid(i)[0] else Style.MUTED) + b.add_theme_stylebox_override("normal", Style.box(Style.PANEL_LIGHT if i != slot else Style.darker(Style.GOLD, 0.45), 6, col, 2)) + b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Style.PANEL_LIGHT), 6, col, 2)) + active_label.text = "aktiv" if slot == p.active_deck else "" + # Deckliste + Style.clear(deck_list) + var counts := {} + for cid in deck: + counts[cid] = counts.get(cid, 0) + 1 + var listed: Array = counts.keys().map(func(cid): return Cards.by_id(cid)) + listed.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name)) + for i in listed.size(): + var c: Cards.Card = listed[i] + var row := DeckRow.new(c, counts[c.id], Vector2(400, 20)) + row.position = Vector2(0, i * 21) + row.clicked.connect(remove) + row.hover.connect(show_preview) + deck_list.add_child(row) + var dc := deck_cards() + if not name_box.has_focus(): + name_box.text = p.deck_name(slot) + deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE] + 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) + 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 "–") + var ok: Array = Game.validate_deck(dc, p.collection) + 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) + curve.cards = dc + curve.queue_redraw() diff --git a/scripts/ui/deck_screen.gd.uid b/scripts/ui/deck_screen.gd.uid new file mode 100644 index 0000000..972d8f1 --- /dev/null +++ b/scripts/ui/deck_screen.gd.uid @@ -0,0 +1 @@ +uid://yg8pbj56mib5 diff --git a/scripts/ui/hotseat_screen.gd b/scripts/ui/hotseat_screen.gd new file mode 100644 index 0000000..bf6bb6e --- /dev/null +++ b/scripts/ui/hotseat_screen.gd @@ -0,0 +1,111 @@ +class_name HotseatScreen +extends Control +## Lokal zu mehreren am selben Rechner: Spielerzahl, KI-Zahl und Decks wählen. + +var app +var n_humans := 2 +var n_ai := 0 +var deck_choice: Array = [] # je Mensch: Slot-Index oder -1 für Zufallsdeck +var human_buttons: Array = [] +var ai_buttons: Array = [] +var deck_rows: VBoxContainer +var b_start: Button +var hint: Label + + +func _ready() -> void: + var col := Style.column(self, 760, 40, 10) + Style.clabel(col, "Lokal zu mehreren", 36, Style.GOLD, "center", true) + Style.clabel(col, "Alle spielen am selben Rechner und reichen die Maus weiter. Jeder gegen jeden, ohne Münzen.", 16, Style.MUTED) + Style.spacer(col, 10) + Style.clabel(col, "Spieler", 20, Style.TEXT, "center", true) + var hr := Style.row(col, 10) + hr.alignment = BoxContainer.ALIGNMENT_CENTER + for n in range(2, 5): + human_buttons.append(Style.cbutton(hr, "%d Spieler" % n, Vector2(150, 44), func(): set_humans(n))) + Style.clabel(col, "KI-Gegner", 20, Style.TEXT, "center", true) + var ar := Style.row(col, 10) + ar.alignment = BoxContainer.ALIGNMENT_CENTER + for n in range(0, 5): + ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 44), func(): set_ai(n))) + Style.spacer(col, 6) + Style.clabel(col, "Decks", 20, Style.TEXT, "center", true) + deck_rows = VBoxContainer.new() + deck_rows.add_theme_constant_override("separation", 6) + col.add_child(deck_rows) + Style.spacer(col, 6) + hint = Style.clabel(col, "", 15, Style.ORANGE) + var br := Style.row(col, 20) + br.alignment = BoxContainer.ALIGNMENT_CENTER + b_start = Style.cbutton(br, "Start", Vector2(200, 48), start, Color("285a3c")) + Style.cbutton(br, "Zurück", Vector2(160, 48), func(): app.show_screen(MenuScreen.new())) + for i in 4: + deck_choice.append(-1) + var p: Profile = app.profile + if p.deck_valid()[0]: + deck_choice[0] = p.active_deck + refresh() + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + app.show_screen(MenuScreen.new()) + + +func set_humans(n: int) -> void: + n_humans = n + n_ai = mini(n_ai, Game.MAX_OPPONENTS + 1 - n_humans) + refresh() + + +func set_ai(n: int) -> void: + n_ai = n + refresh() + + +func valid_slots() -> Array: + var out := [] + for i in Profile.DECK_SLOTS: + if app.profile.deck_valid(i)[0]: + out.append(i) + return out + + +func refresh() -> void: + var p: Profile = app.profile + for i in human_buttons.size(): + human_buttons[i].disabled = (i + 2) == n_humans + for i in ai_buttons.size(): + ai_buttons[i].disabled = i == n_ai or n_humans + i > Game.MAX_OPPONENTS + 1 + Style.clear(deck_rows) + var slots := valid_slots() + for h in n_humans: + var r := Style.row(deck_rows, 12) + r.alignment = BoxContainer.ALIGNMENT_CENTER + Style.clabel(r, "Spieler %d" % (h + 1), 16, Style.TEXT, "right", true, 140) + var ob := OptionButton.new() + ob.custom_minimum_size = Vector2(340, 36) + ob.add_item("Zufallsdeck", -1) + for slot in slots: + var elems := Game.deck_elements(p.deck_cards(slot)) + ob.add_item("%s (%s)" % [p.deck_name(slot), ", ".join(elems)], slot) + ob.select(ob.get_item_index(deck_choice[h]) if deck_choice[h] >= 0 and slots.has(deck_choice[h]) else 0) + var idx := h + ob.item_selected.connect(func(i: int): deck_choice[idx] = ob.get_item_id(i)) + r.add_child(ob) + hint.text = "%d am Tisch" % (n_humans + n_ai) + + +func start() -> void: + var p: Profile = app.profile + var r := Game.make_rng() + var decks := [] + for h in n_humans: + var slot: int = deck_choice[h] + if slot >= 0 and p.deck_valid(slot)[0]: + decks.append(p.deck_cards(slot)) + else: + var pool: Array = Cards.ELEMENTS.duplicate() + Game.shuffle(pool, r) + decks.append(Game.build_opponent_deck(r, [pool[0], pool[1]])) + app.show_screen(BattleScreen.hotseat(decks, n_ai)) diff --git a/scripts/ui/hotseat_screen.gd.uid b/scripts/ui/hotseat_screen.gd.uid new file mode 100644 index 0000000..6b9ec77 --- /dev/null +++ b/scripts/ui/hotseat_screen.gd.uid @@ -0,0 +1 @@ +uid://crlne4y0uiygl diff --git a/scripts/ui/lobby_screen.gd b/scripts/ui/lobby_screen.gd new file mode 100644 index 0000000..f601fb3 --- /dev/null +++ b/scripts/ui/lobby_screen.gd @@ -0,0 +1,112 @@ +class_name LobbyScreen +extends Control +## Netzwerkspiel im lokalen Netz: Spiel eröffnen oder beitreten. + +var app +var net: Net +var status_label: Label +var list_label: Label +var ip_box: LineEdit +var b_host: Button +var b_join: Button +var b_start: Button +var ai_buttons: Array = [] +var n_ai := 0 +var addr_label: Label + + +func _ready() -> void: + net = app.net + net.my_name = "Spieler" + net.my_deck = app.profile.deck.duplicate() + var col := Style.column(self, 760, 40, 10) + Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true) + Style.clabel(col, "Im selben Netz: einer eröffnet, die anderen treten mit seiner Adresse bei. Jeder gegen jeden, ohne Münzen.", 16, Style.MUTED) + Style.spacer(col, 10) + var hr := Style.row(col, 12) + hr.alignment = BoxContainer.ALIGNMENT_CENTER + b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c")) + ip_box = LineEdit.new() + ip_box.custom_minimum_size = Vector2(220, 46) + ip_box.placeholder_text = "Adresse des Hosts" + ip_box.text = "127.0.0.1" + hr.add_child(ip_box) + b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join) + addr_label = Style.clabel(col, "", 14, Style.MUTED) + status_label = Style.clabel(col, "", 16, Style.BLUE) + Style.spacer(col, 6) + Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true) + list_label = Style.clabel(col, "", 16, Style.TEXT) + Style.spacer(col, 6) + Style.clabel(col, "KI-Gegner (nur Host)", 16, Style.MUTED) + var ar := Style.row(col, 10) + ar.alignment = BoxContainer.ALIGNMENT_CENTER + for n in range(0, 5): + ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 40), func(): _set_ai(n))) + Style.spacer(col, 10) + var br := Style.row(col, 20) + br.alignment = BoxContainer.ALIGNMENT_CENTER + b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c")) + Style.cbutton(br, "Zurück", Vector2(160, 48), _back) + net.lobby_changed.connect(refresh) + net.started.connect(_started) + net.ended.connect(func(_reason): refresh()) + refresh() + + +func _exit_tree() -> void: + if net.lobby_changed.is_connected(refresh): + net.lobby_changed.disconnect(refresh) + if net.started.is_connected(_started): + net.started.disconnect(_started) + + +func _host() -> void: + net.host() + refresh() + + +func _join() -> void: + net.join(ip_box.text.strip_edges()) + refresh() + + +func _set_ai(n: int) -> void: + n_ai = n + refresh() + + +func _start() -> void: + net.start_battle(n_ai) + + +func _started(seat: int) -> void: + app.show_screen(BattleScreen.online(seat)) + + +func _back() -> void: + net.leave() + app.show_screen(MenuScreen.new()) + + +func refresh() -> void: + status_label.text = net.status + var lines: Array[String] = [] + var ids: Array = net.names.keys() + ids.sort() + for id in ids: + lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""]) + list_label.text = "\n".join(lines) if not lines.is_empty() else "–" + b_host.disabled = net.connected + b_join.disabled = net.connected + ip_box.editable = not net.connected + var humans := ids.size() + b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1) + for i in ai_buttons.size(): + ai_buttons[i].disabled = not net.is_host or i == n_ai or humans + i > Game.MAX_OPPONENTS + 1 + addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else "" + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not ip_box.has_focus(): + _back() diff --git a/scripts/ui/lobby_screen.gd.uid b/scripts/ui/lobby_screen.gd.uid new file mode 100644 index 0000000..5b3846c --- /dev/null +++ b/scripts/ui/lobby_screen.gd.uid @@ -0,0 +1 @@ +uid://bhnnxjh6rsxnf diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd new file mode 100644 index 0000000..09ffe48 --- /dev/null +++ b/scripts/ui/main.gd @@ -0,0 +1,52 @@ +extends Control +## Einstieg: hält den Spielstand und wechselt zwischen den Bildschirmen. + +var profile: Profile +var screen: Control +var save_path: String = Profile.SAVE_PATH +var sfx: Sfx +var net: Net + + +func _ready() -> void: + theme = Style.make_theme() + var bg := Style.Backdrop.new() + bg.set_anchors_preset(Control.PRESET_FULL_RECT) + bg.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(bg) + sfx = Sfx.new() + add_child(sfx) + # Fester Pfad /root/Net: Netzwerkaufrufe werden über den Knotenpfad zugestellt, + # der bei Host und Mitspielern gleich sein muss. + net = Net.new() + net.name = "Net" + get_tree().root.add_child.call_deferred(net) + profile = Profile.load_or_new(save_path) + profile.refresh_daily() + sfx.set_volume(profile.settings.volume) + sfx.set_music_volume(profile.settings.music) + set_language(profile.settings.language) + show_screen(MenuScreen.new()) + + +func set_language(code: String) -> void: + TranslationServer.set_locale(code) + + +func show_screen(s: Control) -> void: + if screen != null: + screen.queue_free() + screen = s + s.app = self + s.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(s) + + +func save() -> void: + profile.save(save_path) + + +func _exit_tree() -> void: + if net != null and net.is_inside_tree(): + net.leave() + net.queue_free() diff --git a/scripts/ui/main.gd.uid b/scripts/ui/main.gd.uid new file mode 100644 index 0000000..084b29f --- /dev/null +++ b/scripts/ui/main.gd.uid @@ -0,0 +1 @@ +uid://bkltqeiif4e0j diff --git a/scripts/ui/menu_screen.gd b/scripts/ui/menu_screen.gd new file mode 100644 index 0000000..4616291 --- /dev/null +++ b/scripts/ui/menu_screen.gd @@ -0,0 +1,84 @@ +class_name MenuScreen +extends Control + +var app +var b_single: Button +var b_campaign: Button +var b_hotseat: Button +var b_online: Button +var b_tutorial: Button +var coins_label: Label +var coll_label: Label +var deck_label: Label +var daily_label: Label +var warn_label: Label +var notice_label: Label + + +func _ready() -> void: + var col := Style.column(self, 640, 24, 5) + Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true) + Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED) + Style.spacer(col, 8) + coins_label = Style.clabel(col, "", 20, Style.GOLD) + coll_label = Style.clabel(col, "", 16, Style.MUTED) + deck_label = Style.clabel(col, "", 16, Style.MUTED) + daily_label = Style.clabel(col, "", 16, Style.BLUE) + Style.spacer(col, 8) + var grid := GridContainer.new() + grid.columns = 2 + grid.add_theme_constant_override("h_separation", 12) + grid.add_theme_constant_override("v_separation", 8) + var center := CenterContainer.new() + center.add_child(grid) + col.add_child(center) + var sz := Vector2(270, 44) + var p0: Profile = app.profile + b_tutorial = Style.cbutton(grid, "Anleitung", sz, func(): app.show_screen(BattleScreen.guided()), + Color("5a4628") if not p0.tutorial_done else null) + 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_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")) + 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, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new())) + Style.cbutton(grid, "Erfolge", sz, func(): app.show_screen(AchievementsScreen.new())) + Style.cbutton(grid, "Regeln", sz, func(): app.show_screen(RulesScreen.new())) + Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new())) + Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit()) + Style.spacer(col, 10) + warn_label = Style.clabel(col, "", 16, Style.RED) + notice_label = Style.clabel(col, "", 16, Style.GREEN) + notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD + refresh() + + +func refresh() -> void: + var p: Profile = app.profile + var ok: Array = p.deck_valid() + b_single.disabled = not ok[0] + b_campaign.disabled = not ok[0] + b_tutorial.disabled = not ok[0] + b_online.disabled = not ok[0] + var total := 0 + for cid in p.collection: + total += p.collection[cid] + coins_label.text = "Münzen: %d · Staub: %d" % [p.coins, p.dust] + coll_label.text = "Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d" % [ + p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.STAGES.size()] + 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"] + var task := p.daily_task() + if p.daily.done: + daily_label.text = "Tagesaufgabe erledigt: %s ✓" % task.text + else: + daily_label.text = "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] + if ok[0] and not p.tutorial_done: + warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos." + warn_label.add_theme_color_override("font_color", Style.GOLD) + var notes := p.take_notices() + notice_label.text = " · ".join(notes) + if not notes.is_empty(): + app.save() diff --git a/scripts/ui/menu_screen.gd.uid b/scripts/ui/menu_screen.gd.uid new file mode 100644 index 0000000..3c67cf5 --- /dev/null +++ b/scripts/ui/menu_screen.gd.uid @@ -0,0 +1 @@ +uid://3ib6kcyl21f diff --git a/scripts/ui/opponent_select_screen.gd b/scripts/ui/opponent_select_screen.gd new file mode 100644 index 0000000..d47725b --- /dev/null +++ b/scripts/ui/opponent_select_screen.gd @@ -0,0 +1,29 @@ +class_name OpponentSelectScreen +extends Control +## Einzelspieler: Auswahl, gegen wie viele Gegner gekämpft wird. + +var app + + +func _ready() -> void: + var col := Style.column(self, 900, 50, 14) + 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, "Du gewinnst, wenn alle anderen besiegt sind. Mehr Gegner, mehr Chaos, mehr Münzen.", 16, Style.MUTED) + Style.spacer(col, 10) + for i in range(1, Game.MAX_OPPONENTS + 1): + var n := i + var r := Style.row(col, 20) + r.alignment = BoxContainer.ALIGNMENT_CENTER + Style.cbutton(r, "%d Gegner" % n, Vector2(200, 56), func(): app.show_screen(BattleScreen.new(n)), Color("285a3c")) + var info := ("Duell" if n == 1 else "%d Spieler am Tisch" % (n + 1)) + " · Sieg: %d Münzen" % Profile.REWARD_WIN[n] + Style.clabel(r, info, 16, Style.MUTED, "left", false, 420) + Style.spacer(col, 20) + var br := Style.row(col) + br.alignment = BoxContainer.ALIGNMENT_CENTER + Style.cbutton(br, "Zurück", Vector2(160, 44), 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()) diff --git a/scripts/ui/opponent_select_screen.gd.uid b/scripts/ui/opponent_select_screen.gd.uid new file mode 100644 index 0000000..ea2ee81 --- /dev/null +++ b/scripts/ui/opponent_select_screen.gd.uid @@ -0,0 +1 @@ +uid://cjpjji3gxmxvf diff --git a/scripts/ui/rules_screen.gd b/scripts/ui/rules_screen.gd new file mode 100644 index 0000000..2a4ddbd --- /dev/null +++ b/scripts/ui/rules_screen.gd @@ -0,0 +1,25 @@ +class_name RulesScreen +extends Control + +var app + + +func _ready() -> void: + Style.label(self, "Regeln", 36, Style.GOLD, Rect2(0, 18, 1280, 50), "center", true) + var scroll := ScrollContainer.new() + scroll.position = Vector2(60, 78) + scroll.size = Vector2(1160, 640) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(scroll) + var text := Label.new() + text.text = Style.rules_text() + text.autowrap_mode = TextServer.AUTOWRAP_WORD + text.custom_minimum_size = Vector2(1140, 0) + text.add_theme_font_size_override("font_size", 15) + scroll.add_child(text) + Style.button(self, "Zurück", Rect2(560, 735, 160, 44), 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()) diff --git a/scripts/ui/rules_screen.gd.uid b/scripts/ui/rules_screen.gd.uid new file mode 100644 index 0000000..e54ee77 --- /dev/null +++ b/scripts/ui/rules_screen.gd.uid @@ -0,0 +1 @@ +uid://bkemrytc2pjem diff --git a/scripts/ui/settings_screen.gd b/scripts/ui/settings_screen.gd new file mode 100644 index 0000000..05556d7 --- /dev/null +++ b/scripts/ui/settings_screen.gd @@ -0,0 +1,121 @@ +class_name SettingsScreen +extends Control + +var app +var volume: HSlider +var music: HSlider +var speed: HSlider +var language: OptionButton +var music_label: Label +var anim: CheckButton +var volume_label: Label +var speed_label: Label + + +func _ready() -> void: + var s: Dictionary = app.profile.settings + var col := Style.column(self, 700, 50, 24) + Style.clabel(col, "Einstellungen", 36, Style.GOLD, "center", true) + Style.spacer(col, 30) + var grid := GridContainer.new() + grid.columns = 3 + grid.add_theme_constant_override("h_separation", 24) + grid.add_theme_constant_override("v_separation", 30) + col.add_child(grid) + Style.clabel(grid, "Lautstärke", 20, Style.TEXT, "left", false, 200) + volume = HSlider.new() + volume.custom_minimum_size = Vector2(280, 24) + volume.min_value = 0 + volume.max_value = 1 + volume.step = 0.05 + volume.value = s.volume + volume.value_changed.connect(_on_volume) + grid.add_child(volume) + volume_label = Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) + Style.clabel(grid, "Musik", 20, Style.TEXT, "left", false, 200) + music = HSlider.new() + music.custom_minimum_size = Vector2(280, 24) + music.min_value = 0 + music.max_value = 1 + music.step = 0.05 + music.value = s.music + music.value_changed.connect(_on_music) + grid.add_child(music) + music_label = Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) + Style.clabel(grid, "KI-Tempo", 20, Style.TEXT, "left", false, 200) + speed = HSlider.new() + speed.custom_minimum_size = Vector2(280, 24) + speed.min_value = 0.5 + speed.max_value = 3.0 + speed.step = 0.25 + speed.value = s.ai_speed + speed.value_changed.connect(_on_speed) + grid.add_child(speed) + speed_label = Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) + Style.clabel(grid, "Animationen", 20, Style.TEXT, "left", false, 200) + anim = CheckButton.new() + anim.button_pressed = s.animations + anim.toggled.connect(_on_anim) + grid.add_child(anim) + Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) + Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200) + language = OptionButton.new() + language.custom_minimum_size = Vector2(280, 36) + language.add_item("Deutsch", 0) + language.add_item("English (Oberfläche)", 1) + language.select(1 if s.language == "en" else 0) + language.item_selected.connect(_on_language) + grid.add_child(language) + Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) + Style.spacer(col, 20) + Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED) + Style.spacer(col, 60) + var r := Style.row(col) + r.alignment = BoxContainer.ALIGNMENT_CENTER + Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) + _labels() + + +func _labels() -> void: + volume_label.text = "%d %%" % int(round(volume.value * 100)) + music_label.text = "%d %%" % int(round(music.value * 100)) + speed_label.text = "%.2fx" % speed.value + + +func _on_music(v: float) -> void: + app.profile.settings.music = v + app.sfx.set_music_volume(v) + app.save() + _labels() + + +func _on_language(i: int) -> void: + var code := "en" if i == 1 else "de" + app.profile.settings.language = code + app.save() + app.set_language(code) + app.show_screen(SettingsScreen.new()) + + +func _on_volume(v: float) -> void: + app.profile.settings.volume = v + app.sfx.set_volume(v) + app.sfx.play("click") + app.save() + _labels() + + +func _on_speed(v: float) -> void: + app.profile.settings.ai_speed = v + app.save() + _labels() + + +func _on_anim(on: bool) -> void: + app.profile.settings.animations = on + app.save() + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + app.show_screen(MenuScreen.new()) diff --git a/scripts/ui/settings_screen.gd.uid b/scripts/ui/settings_screen.gd.uid new file mode 100644 index 0000000..cc359d9 --- /dev/null +++ b/scripts/ui/settings_screen.gd.uid @@ -0,0 +1 @@ +uid://i4f1frwa2cmm diff --git a/scripts/ui/sfx.gd b/scripts/ui/sfx.gd new file mode 100644 index 0000000..067a088 --- /dev/null +++ b/scripts/ui/sfx.gd @@ -0,0 +1,107 @@ +class_name Sfx +extends Node +## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig). + +static var inst: Sfx + +var streams := {} +var players: Array[AudioStreamPlayer] = [] +var volume := 0.8 +var music_player: AudioStreamPlayer +var music_volume := 0.5 + + +func _ready() -> void: + inst = self + streams["click"] = _tone(880, 0.05, "sine") + streams["play"] = _tone(440, 0.14, "sine", 660) + streams["attack"] = _tone(220, 0.16, "square", 90) + streams["damage"] = _tone(140, 0.12, "square") + streams["heal"] = _tone(523, 0.22, "sine", 784) + streams["combo"] = _tone(660, 0.2, "sine", 1100) + streams["win"] = _tone(400, 0.6, "sine", 1200) + streams["lose"] = _tone(320, 0.7, "square", 90) + for i in 4: + var p := AudioStreamPlayer.new() + add_child(p) + players.append(p) + music_player = AudioStreamPlayer.new() + music_player.stream = _music() + add_child(music_player) + set_music_volume(music_volume) + + +## Leise Hintergrundmusik: Arpeggio über vier Akkorde, als Schleife erzeugt. +func _music() -> AudioStreamWAV: + var rate := 22050 + var chords := [[220.0, 261.63, 329.63], [174.61, 220.0, 261.63], [130.81, 164.81, 196.0], [196.0, 246.94, 293.66]] + var note_len := 0.25 + var pattern := [0, 1, 2, 1, 0, 2, 1, 2] + var total := chords.size() * pattern.size() * note_len + var n := int(total * rate) + var data := PackedByteArray() + data.resize(n * 2) + for i in n: + var t := float(i) / rate + var step := int(t / note_len) + var chord: Array = chords[(step / pattern.size()) % chords.size()] + var f: float = chord[pattern[step % pattern.size()]] + var lt := t - step * note_len + var env := minf(1.0, lt * 30.0) * maxf(0.0, 1.0 - lt / note_len) + var v := sin(TAU * f * t) * 0.6 + sin(TAU * f * 2.0 * t) * 0.15 + sin(TAU * chord[0] * 0.5 * t) * 0.25 + data.encode_s16(i * 2, int(clampf(v * env * 0.25, -1.0, 1.0) * 32767)) + var wav := AudioStreamWAV.new() + wav.format = AudioStreamWAV.FORMAT_16_BITS + wav.mix_rate = rate + wav.stereo = false + wav.data = data + wav.loop_mode = AudioStreamWAV.LOOP_FORWARD + wav.loop_begin = 0 + wav.loop_end = n + return wav + + +func set_music_volume(v: float) -> void: + music_volume = clampf(v, 0.0, 1.0) + if music_volume <= 0.0: + music_player.stop() + else: + music_player.volume_db = linear_to_db(music_volume * 0.6) + if not music_player.playing: + music_player.play() + + +func set_volume(v: float) -> void: + volume = clampf(v, 0.0, 1.0) + + +func play(kind: String) -> void: + if volume <= 0.0 or not streams.has(kind): + return + for p in players: + if not p.playing: + p.stream = streams[kind] + p.volume_db = linear_to_db(volume) + p.play() + return + + +func _tone(freq: float, dur: float, shape: String, freq_end: float = -1.0) -> AudioStreamWAV: + var rate := 22050 + var n := int(dur * rate) + var data := PackedByteArray() + data.resize(n * 2) + for i in n: + var t := float(i) / rate + var f := freq if freq_end < 0 else lerpf(freq, freq_end, t / dur) + var env := 1.0 - t / dur + var v := sin(TAU * f * t) + if shape == "square": + v = 1.0 if v >= 0 else -1.0 + data.encode_s16(i * 2, int(clampf(v * env * 0.4, -1.0, 1.0) * 32767)) + var wav := AudioStreamWAV.new() + wav.format = AudioStreamWAV.FORMAT_16_BITS + wav.mix_rate = rate + wav.stereo = false + wav.data = data + return wav diff --git a/scripts/ui/sfx.gd.uid b/scripts/ui/sfx.gd.uid new file mode 100644 index 0000000..0c912d4 --- /dev/null +++ b/scripts/ui/sfx.gd.uid @@ -0,0 +1 @@ +uid://bprwiyidhn7ek diff --git a/scripts/ui/style.gd b/scripts/ui/style.gd new file mode 100644 index 0000000..c620fc9 --- /dev/null +++ b/scripts/ui/style.gd @@ -0,0 +1,365 @@ +class_name Style +## Farben, Schriften, Theme und Zeichenhilfen. + +const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf") +const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf") + +const BG := Color("1a1c24") +const PANEL := Color("282b36") +const PANEL_LIGHT := Color("3a3e4c") +const TEXT := Color("ececf0") +const MUTED := Color("9698a5") +const GOLD := Color("f5c850") +const GREEN := Color("5ad282") +const RED := Color("e65555") +const BLUE := Color("6eaaf0") +const YELLOW := Color("ffe65a") +const ORANGE := Color("ff963c") + + +static func darker(c: Color, f: float = 0.65) -> Color: + return Color(c.r * f, c.g * f, c.b * f, c.a) + + +static func lighter(c: Color, d: float = 0.1) -> Color: + return Color(minf(1, c.r + d), minf(1, c.g + d), minf(1, c.b + d), c.a) + + +static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg + sb.set_corner_radius_all(radius) + if border_col != null: + sb.border_color = border_col + sb.set_border_width_all(border_w) + return sb + + +static func make_theme() -> Theme: + var t := Theme.new() + t.default_font = FONT + t.default_font_size = 16 + 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("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("focus", "Button", StyleBoxEmpty.new()) + 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("font_disabled_color", "Button", MUTED) + t.set_font_size("font_size", "Button", 18) + t.set_color("font_color", "Label", TEXT) + t.set_stylebox("panel", "Panel", box(PANEL, 8)) + t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new()) + t.set_stylebox("focus", "RichTextLabel", StyleBoxEmpty.new()) + t.set_stylebox("panel", "ScrollContainer", StyleBoxEmpty.new()) + t.set_stylebox("normal", "LineEdit", box(PANEL_LIGHT, 6, MUTED, 1)) + t.set_stylebox("focus", "LineEdit", box(PANEL_LIGHT, 6, BLUE, 2)) + t.set_color("font_color", "LineEdit", TEXT) + t.set_color("font_placeholder_color", "LineEdit", MUTED) + t.set_stylebox("normal", "OptionButton", box(PANEL_LIGHT, 6, MUTED, 1)) + t.set_stylebox("hover", "OptionButton", box(lighter(PANEL_LIGHT), 6, MUTED, 1)) + t.set_stylebox("pressed", "OptionButton", box(darker(PANEL_LIGHT), 6, MUTED, 1)) + t.set_stylebox("focus", "OptionButton", StyleBoxEmpty.new()) + t.set_color("font_color", "OptionButton", TEXT) + t.set_color("font_color", "CheckButton", TEXT) + t.set_stylebox("focus", "CheckButton", StyleBoxEmpty.new()) + return t + + +# --- Knoten-Hilfen ------------------------------------------------------------- + +static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8, border_col = null, border_w: int = 0) -> Panel: + var p := Panel.new() + p.position = rect.position + p.size = rect.size + p.add_theme_stylebox_override("panel", box(bg, radius, border_col, border_w)) + p.mouse_filter = Control.MOUSE_FILTER_IGNORE + parent.add_child(p) + return p + + +static func label(parent: Node, text: String, size: int, color: Color, rect: Rect2, align: String = "left", bold: bool = false) -> Label: + var l := Label.new() + l.text = text + l.position = rect.position + l.size = rect.size + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", color) + if bold: + l.add_theme_font_override("font", FONT_BOLD) + match align: + "center": + l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + "right": + l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + l.mouse_filter = Control.MOUSE_FILTER_IGNORE + parent.add_child(l) + return l + + +static func button(parent: Node, text: String, rect: Rect2, on_pressed: Callable, color = null) -> Button: + var b := Button.new() + b.text = text + b.position = rect.position + b.size = rect.size + b.pressed.connect(on_pressed) + if color != null: + b.add_theme_stylebox_override("normal", box(color, 8, MUTED, 2)) + b.add_theme_stylebox_override("hover", box(lighter(color), 8, MUTED, 2)) + b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2)) + parent.add_child(b) + return b + + +## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme). +static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer: + var v := VBoxContainer.new() + v.anchor_left = 0.5 + v.anchor_right = 0.5 + v.anchor_top = 0 + v.anchor_bottom = 0 + v.offset_left = -width / 2 + v.offset_right = width / 2 + v.offset_top = top + v.add_theme_constant_override("separation", sep) + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + parent.add_child(v) + return v + + +static func row(parent: Node, sep: int = 12) -> HBoxContainer: + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", sep) + h.mouse_filter = Control.MOUSE_FILTER_IGNORE + parent.add_child(h) + return h + + +## Label in einem Container. +static func clabel(parent: Node, text: String, size: int, color: Color, align: String = "center", bold: bool = false, min_w: float = 0) -> Label: + var l := label(parent, text, size, color, Rect2(0, 0, 0, 0), align, bold) + l.size_flags_horizontal = Control.SIZE_EXPAND_FILL if min_w == 0 else Control.SIZE_FILL + if min_w > 0: + l.custom_minimum_size = Vector2(min_w, 0) + return l + + +## Knopf in einem Container. +static func cbutton(parent: Node, text: String, size: Vector2, on_pressed: Callable, color = null) -> Button: + var b := button(parent, text, Rect2(Vector2.ZERO, size), on_pressed, color) + b.custom_minimum_size = size + return b + + +static func spacer(parent: Node, h: float) -> Control: + var c := Control.new() + c.custom_minimum_size = Vector2(0, h) + c.mouse_filter = Control.MOUSE_FILTER_IGNORE + parent.add_child(c) + return c + + +static func clear(node: Node) -> void: + for c in node.get_children(): + node.remove_child(c) + c.queue_free() + + +# --- Zeichenhilfen für _draw() --------------------------------------------------- + +static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void: + var font: Font = FONT_BOLD if bold else FONT + var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size) + var x := pos.x + var y := pos.y + match anchor: + "center": + x -= dim.x / 2 + y -= dim.y / 2 + "midtop": + x -= dim.x / 2 + "topright": + x -= dim.x + "midleft": + y -= dim.y / 2 + "midright": + x -= dim.x + y -= dim.y / 2 + "bottomleft": + y -= dim.y + "bottomright": + x -= dim.x + y -= dim.y + "midbottom": + x -= dim.x / 2 + y -= dim.y + ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color) + + +static func fit(s: String, bold: bool, size: int, max_w: float) -> String: + var font: Font = FONT_BOLD if bold else FONT + while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3: + s = s.substr(0, s.length() - 2) + "…" + return s + + +static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius: int) -> void: + var sb := StyleBoxFlat.new() + sb.draw_center = false + sb.border_color = color + sb.set_border_width_all(width) + sb.set_corner_radius_all(radius) + ci.draw_style_box(sb, rect) + + +static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void: + var a: Color = Cards.ELEMENT_COLORS[elements[0]] + if elements.size() == 1: + ci.draw_style_box(box(a, radius), rect) + elif elements[0] == elements[1]: + # Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen + ci.draw_style_box(box(a, radius), rect) + border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3)) + else: + var b: Color = Cards.ELEMENT_COLORS[elements[1]] + var half := floorf(rect.size.x / 2) + var left := StyleBoxFlat.new() + left.bg_color = a + left.corner_radius_top_left = radius + left.corner_radius_bottom_left = radius + var right := StyleBoxFlat.new() + right.bg_color = b + right.corner_radius_top_right = radius + right.corner_radius_bottom_right = radius + ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y))) + ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y))) + + +static func rules_text() -> String: + var lines := [ + "Ziel: Besiege alle anderen. Du verlierst, wenn dein Leben auf 0 fällt.", + "Du spielst gegen 1 bis 5 Magier, jeder gegen jeden, alle nach denselben Regeln:", + "%d Leben, pro Zug 1 Energie mehr (höchstens 10) und 1 Karte. Die Zugreihenfolge wird ausgelost." % Game.START_LIFE, + "Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug 1 Energie extra.", + "Fallen liegen verdeckt (höchstens 2) und lösen aus, wenn dich jemand angreift oder ein Wesen ausspielt.", + "", + "Karten kosten Energie. Wesen kommen aufs Feld (max. 5) und greifen ab dem nächsten Zug an.", + "Zauber wirken sofort. Ein Wesen greift ein gegnerisches Wesen oder direkt den Gegner an;", + "beide Seiten verursachen ihren Angriffswert als Schaden.", + "", + "Elemente: Feuer (Schaden), Wasser (Kontrolle), Erde (Zähigkeit), Luft (Tempo).", + "Kombos verbinden zwei Elemente, auch das gleiche doppelt (Feuer+Feuer = Inferno).", + "Kombokarten sind seltener und stärker, Dreifach-Kombos sind mythisch. Ein Deck darf höchstens 2 Elemente enthalten.", + Cards.RESONANCE_TEXT, + "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", + " an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen Schild.", + "", + "Schlüsselwörter:", + ] + for k in Cards.KEYWORDS: + lines.append(" %s: %s" % [k, Cards.KEYWORD_HELP[k]]) + lines.append("") + 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("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.") + lines.append("") + 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("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.") + 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.") + return "\n".join(lines) + + +# --- Kartensymbole (prozedural, je Element und Kartenart) -------------------------- + +static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void: + var variant := absi(hash(card.id)) % 3 + var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38) + col.a = 0.95 + if card.is_trap(): + # Falle: Kreuz im Kreis + ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08)) + var d := size * 0.28 + ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1)) + ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1)) + return + if card.kind == "Zauber": + _star(ci, center, size * 0.55, Color(col, 0.35)) + var elems: Array = card.elements + if elems.size() >= 2 and elems[0] != elems[1]: + _element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col) + var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38) + col2.a = 0.95 + _element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2) + else: + _element_glyph(ci, elems[0], center, size, variant, col) + if elems.size() >= 2: + ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2) + if elems.size() >= 3: + ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2) + + +static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void: + var pts := PackedVector2Array() + for i in 8: + var a := TAU * i / 8 - PI / 2 + var rr := r if i % 2 == 0 else r * 0.45 + pts.append(c + Vector2(cos(a), sin(a)) * rr) + ci.draw_colored_polygon(pts, col) + + +static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void: + var h := size / 2 + match element: + "Feuer": + var n := 1 + variant + for i in n: + var off := Vector2((i - (n - 1) / 2.0) * size * 0.32, 0) + var s := 1.0 if i == n / 2 else 0.7 + _flame(ci, c + off, h * s, col) + "Wasser": + for i in 2 + variant % 2: + var y := c.y + (i - 0.5) * size * 0.3 + for k in 3: + var x := c.x - size * 0.4 + k * size * 0.4 + ci.draw_arc(Vector2(x, y), size * 0.2, PI, TAU, 12, col, maxf(2, size * 0.09)) + "Erde": + var n := 1 + variant + for i in n: + var off := (i - (n - 1) / 2.0) * size * 0.4 + var s := 1.0 if i == n / 2 else 0.65 + var pts := PackedVector2Array([c + Vector2(off - h * s, h * 0.7), c + Vector2(off, -h * s), c + Vector2(off + h * s, h * 0.7)]) + ci.draw_colored_polygon(pts, col) + ci.draw_line(c + Vector2(-h, h * 0.7), c + Vector2(h, h * 0.7), col, maxf(2, size * 0.08)) + "Luft": + for i in 2 + variant: + var r := h * (1.0 - i * 0.28) + ci.draw_arc(c + Vector2(i * size * 0.06, 0), r, PI * 0.2 + i * 0.4, PI * 1.5 + i * 0.4, 20, col, maxf(2, size * 0.09)) + + +static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void: + var w := h * 0.75 + var pts := PackedVector2Array([ + c + Vector2(0, -h), c + Vector2(w * 0.55, -h * 0.25), c + Vector2(w * 0.4, h * 0.45), + c + Vector2(0, h), c + Vector2(-w * 0.4, h * 0.45), c + Vector2(-w * 0.55, -h * 0.25)]) + ci.draw_colored_polygon(pts, col) + var inner := PackedVector2Array([ + c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)]) + ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75)) + + +## Dezenter Hintergrund: Farbverlauf und schräge Linien. +class Backdrop extends Control: + func _draw() -> void: + var sz := size + draw_rect(Rect2(Vector2.ZERO, sz), Style.BG) + var c := Vector2(sz.x * 0.5, sz.y * 0.35) + for i in range(8, 0, -1): + var r := sz.x * 0.09 * i + draw_circle(c, r, Color(0.16, 0.18, 0.26, 0.05)) + for k in range(-20, 40): + var x := k * 80.0 + draw_line(Vector2(x, 0), Vector2(x + sz.y * 0.6, sz.y), Color(1, 1, 1, 0.012), 1.0) diff --git a/scripts/ui/style.gd.uid b/scripts/ui/style.gd.uid new file mode 100644 index 0000000..7e72ec3 --- /dev/null +++ b/scripts/ui/style.gd.uid @@ -0,0 +1 @@ +uid://diaenhpjc2b3i diff --git a/scripts/ui/workshop_screen.gd b/scripts/ui/workshop_screen.gd new file mode 100644 index 0000000..fccf5ff --- /dev/null +++ b/scripts/ui/workshop_screen.gd @@ -0,0 +1,151 @@ +class_name WorkshopScreen +extends Control +## Werkstatt: Karten zerlegen (Staub) und herstellen. + +var app +var filter := "" +var search := "" +var selected: Cards.Card = null +var grid: CardGrid +var search_box: LineEdit +var dust_label: Label +var msg_label: Label +var preview_holder: Control +var info_label: Label +var b_dismantle: Button +var b_craft: Button +var b_extras: Button + + +func _ready() -> void: + Style.label(self, "Werkstatt", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true) + Style.label(self, "Karte anklicken, dann zerlegen oder herstellen. Überschuss: alles über %d Kopien." % Game.MAX_COPIES, + 14, Style.MUTED, Rect2(240, 18, 600, 24)) + Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) + for i in Cards.ELEMENTS.size(): + 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)) + search_box = LineEdit.new() + search_box.position = Vector2(400, 55) + search_box.size = Vector2(200, 34) + search_box.placeholder_text = "Suche" + search_box.text_changed.connect(func(t: String): + search = t + refresh()) + add_child(search_box) + grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690)) + grid.card_clicked.connect(select) + add_child(grid) + Style.panel(self, Rect2(845, 50, 420, 680)) + dust_label = Style.label(self, "", 22, Style.BLUE, Rect2(860, 58, 390, 30), "left", true) + preview_holder = Control.new() + preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(preview_holder) + info_label = Style.label(self, "", 14, Style.MUTED, Rect2(860, 396, 390, 66), "center") + b_dismantle = Style.button(self, "", Rect2(860, 470, 390, 44), dismantle, Color("5a3c28")) + b_craft = Style.button(self, "", Rect2(860, 524, 390, 44), craft, Color("285a3c")) + b_extras = Style.button(self, "", Rect2(860, 600, 390, 44), dismantle_extras) + msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 660, 390, 48), "center") + Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new())) + refresh() + + +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus(): + app.show_screen(MenuScreen.new()) + + +func set_filter(e: String) -> void: + filter = e + refresh() + + +func cards() -> Array: + var out: Array = Cards.all().duplicate() + if filter != "": + out = out.filter(func(c): return filter in c.elements) + if search != "": + 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()) + return out + + +func select(card: Cards.Card) -> void: + selected = card + msg_label.text = "" + refresh() + + +func say(m: String, ok: bool) -> void: + msg_label.text = m + msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED) + + +func dismantle() -> void: + if selected == null: + return + var err: String = app.profile.dismantle(selected.id) + if err == "": + app.sfx.play("click") + app.save() + say("%s zerlegt (+%d Staub)" % [selected.name, Profile.DUST_DISMANTLE[selected.rarity]], true) + else: + say(err, false) + refresh() + + +func craft() -> void: + if selected == null: + return + var err: String = app.profile.craft(selected.id) + if err == "": + app.sfx.play("play") + app.save() + var notes: Array[String] = app.profile.take_notices() + say("%s hergestellt" % selected.name + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true) + else: + say(err, false) + refresh() + + +func dismantle_extras() -> void: + var got: int = app.profile.dismantle_extras() + if got > 0: + app.save() + say("Überschuss zerlegt: +%d Staub" % got, true) + else: + say("Kein Überschuss vorhanden", false) + refresh() + + +func refresh() -> void: + var p: Profile = app.profile + dust_label.text = "Staub: %d" % p.dust + grid.fill(cards(), + func(c): return "%dx" % p.collection.get(c.id, 0) if p.collection.get(c.id, 0) > 0 else "fehlt", + func(c): return p.collection.get(c.id, 0) == 0) + Style.clear(preview_holder) + if selected == null: + info_label.text = "Keine Karte gewählt" + b_dismantle.text = "Zerlegen" + b_craft.text = "Herstellen" + b_dismantle.disabled = true + b_craft.disabled = true + else: + var v := CardView.new(selected, Vector2(200, 280)) + v.position = Vector2(955, 100) + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + preview_holder.add_child(v) + var owned: int = p.collection.get(selected.id, 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" % [ + selected.combo(), selected.rarity, owned, st.played, st.games, st.wins] + b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity] + b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity] + b_dismantle.disabled = owned == 0 + b_craft.disabled = p.dust < Profile.DUST_CRAFT[selected.rarity] + var extras := 0 + for cid in p.collection: + extras += maxi(0, p.collection[cid] - Game.MAX_COPIES) + b_extras.text = "Überschuss zerlegen (%d Karten)" % extras + b_extras.disabled = extras == 0 diff --git a/scripts/ui/workshop_screen.gd.uid b/scripts/ui/workshop_screen.gd.uid new file mode 100644 index 0000000..6ea27a9 --- /dev/null +++ b/scripts/ui/workshop_screen.gd.uid @@ -0,0 +1 @@ +uid://cf42h6bsh8lvg diff --git a/tests/dump_cards.gd b/tests/dump_cards.gd new file mode 100644 index 0000000..362bf4d --- /dev/null +++ b/tests/dump_cards.gd @@ -0,0 +1,11 @@ +extends SceneTree +## Schreibt den Kartenpool als JSON: godot --headless --path . -s tests/dump_cards.gd + + +func _initialize() -> void: + var list := Cards.all().map(func(c): return c.to_dict()) + var f := FileAccess.open("res://data/karten.json", FileAccess.WRITE) + f.store_string(JSON.stringify({"karten": list}, "\t", false)) + f.close() + print("%d Karten nach data/karten.json geschrieben" % list.size()) + quit() diff --git a/tests/dump_cards.gd.uid b/tests/dump_cards.gd.uid new file mode 100644 index 0000000..c664318 --- /dev/null +++ b/tests/dump_cards.gd.uid @@ -0,0 +1 @@ +uid://bdywlyx7noq6 diff --git a/tests/matchup.gd b/tests/matchup.gd new file mode 100644 index 0000000..19058f3 --- /dev/null +++ b/tests/matchup.gd @@ -0,0 +1,35 @@ +extends SceneTree +## Misst Deckarchetypen im Duell (beide gierig): wer beginnt, Mono gegen Paar. + +func deck(r: RandomNumberGenerator, mono: bool) -> Array: + var pool: Array = Cards.ELEMENTS.duplicate() + Game.shuffle(pool, r) + return Game.build_opponent_deck(r, [pool[0]] if mono else [pool[0], pool[1]]) + + +func duel(r: RandomNumberGenerator, first_mono: bool, second_mono: bool) -> bool: + var second := Game.Player.new("Gegner 1", deck(r, second_mono), false, Game.START_LIFE, "x") + var g := Game.new_battle_with(deck(r, first_mono), [second], r) + var guard := 0 + while g.winner == "" and guard < 300: + AI.take_turn(g, g.current(), true) + guard += 1 + return g.winner == "human" + + +func run(label: String, a_mono: bool, b_mono: bool, games: int, r: RandomNumberGenerator) -> void: + var wins := 0 + for i in games: + if duel(r, a_mono, b_mono): + wins += 1 + print("%-28s Startspieler gewinnt %3d%%" % [label, wins * 100 / games]) + + +func _initialize() -> void: + var r := RandomNumberGenerator.new() + r.seed = 5 + run("Paar gegen Paar", false, false, 200, r) + run("Mono gegen Mono", true, true, 200, r) + run("Paar (startet) gegen Mono", false, true, 200, r) + run("Mono (startet) gegen Paar", true, false, 200, r) + quit() diff --git a/tests/matchup.gd.uid b/tests/matchup.gd.uid new file mode 100644 index 0000000..828baba --- /dev/null +++ b/tests/matchup.gd.uid @@ -0,0 +1 @@ +uid://bu5hjcfuinhy8 diff --git a/tests/net_bot.gd b/tests/net_bot.gd new file mode 100644 index 0000000..9fb0798 --- /dev/null +++ b/tests/net_bot.gd @@ -0,0 +1,65 @@ +extends Node +## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge. +## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot + +var net: Net +var seat := -1 +var game: Game +var t := 0.0 + + +func _ready() -> void: + var ip := "127.0.0.1" + var bot_name := "Bot" + for a in OS.get_cmdline_user_args(): + if a.begins_with("--ip="): + ip = a.substr(5) + if a.begins_with("--name="): + bot_name = a.substr(7) + net = Net.new() + net.name = "Net" + get_tree().root.add_child.call_deferred(net) + net.my_name = bot_name + net.my_deck = Profile.starter().deck.duplicate() + net.started.connect(func(s): seat = s) + net.snapshot_received.connect(_on_snapshot) + net.ended.connect(func(_r): get_tree().quit()) + await get_tree().process_frame + net.join(ip) + + +func _process(delta: float) -> void: + t += delta + if t > 180.0 or FileAccess.file_exists("user://bot_quit"): + net.leave() + get_tree().quit() + + +func _on_snapshot(snap: Dictionary) -> void: + game = Game.from_snapshot(snap) + if game.winner != "": + await get_tree().create_timer(1.0).timeout + get_tree().quit() + return + if game.current_idx != seat: + return + var me: Game.Player = game.players[seat] + if not me.mulligan_done and me.turns == 1: + net.send_action({"type": "mulligan", "hand": []}) + return + for i in me.hand.size(): + var card: Cards.Card = me.hand[i] + if game.can_play(me, card)[0] and card.target_type() == "": + net.send_action({"type": "play", "hand": i, "target": null}) + return + for cr in me.board: + if cr.can_attack(): + for e in game.enemies_of(me): + if game.can_attack(cr, e)[0]: + net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "player", "index": game.players.find(e)}}) + return + for c in e.board: + if game.can_attack(cr, c)[0]: + net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}}) + return + net.send_action({"type": "end"}) diff --git a/tests/net_bot.gd.uid b/tests/net_bot.gd.uid new file mode 100644 index 0000000..9ffe962 --- /dev/null +++ b/tests/net_bot.gd.uid @@ -0,0 +1 @@ +uid://dcbqt8ybdkhrb diff --git a/tests/net_bot.tscn b/tests/net_bot.tscn new file mode 100644 index 0000000..ae3b356 --- /dev/null +++ b/tests/net_bot.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/net_bot.gd" id="1"] + +[node name="NetBot" type="Node"] +script = ExtResource("1") diff --git a/tests/run_headless.sh b/tests/run_headless.sh new file mode 100755 index 0000000..ba4be31 --- /dev/null +++ b/tests/run_headless.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Führt Import und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary] +set -euo pipefail +GODOT="${1:-godot}" +cd "$(dirname "$0")/.." +"$GODOT" --headless --path . --import > /dev/null 2>&1 || 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 diff --git a/tests/simulate.gd b/tests/simulate.gd new file mode 100644 index 0000000..85b7b88 --- /dev/null +++ b/tests/simulate.gd @@ -0,0 +1,43 @@ +extends SceneTree +## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln +## ist etwa 1/(Gegner+1) Siege zu erwarten: +## godot --headless --path . -s tests/simulate.gd + + +func _initialize() -> void: + var games := 100 + var r := RandomNumberGenerator.new() + r.seed = 0 + var starter := Profile.starter().deck_cards() + var random_deck := func(rr: RandomNumberGenerator) -> Array: + var pool: Array = Cards.ELEMENTS.duplicate() + Game.shuffle(pool, rr) + return Game.build_opponent_deck(rr, [pool[0], pool[1]]) + var t0 := Time.get_ticks_msec() + print("Alle gierig (Erwartung etwa 1/(Gegner+1)):") + for n in range(1, Game.MAX_OPPONENTS + 1): + _run("Zufallsdeck", random_deck, n, games, r, true, true) + print("Du mit Vorausschau, Gegner gierig:") + for n in [1, 2, 3]: + _run("Zufallsdeck", random_deck, n, games / 2, r, false, true) + print("Alle mit Vorausschau:") + _run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false) + _run("Zufallsdeck", random_deck, 2, games / 4, r, false, false) + print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0)) + quit() + + +func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNumberGenerator, + human_greedy: bool, opp_greedy: bool) -> void: + var wins := 0 + var turns := 0 + for i in games: + var g := Game.new_battle(deck_fn.call(r), n_opp, r) + while g.winner == "": + var p := g.current() + AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy) + assert(g.turn < 300, "Spiel endet nicht") + if g.winner == "human": + wins += 1 + turns += g.turn + print("%-26s vs %d: Siege %3d%% Ø Züge %.1f" % [label, n_opp, wins * 100 / games, float(turns) / games]) diff --git a/tests/simulate.gd.uid b/tests/simulate.gd.uid new file mode 100644 index 0000000..cf2706a --- /dev/null +++ b/tests/simulate.gd.uid @@ -0,0 +1 @@ +uid://bnbiq4krauuyq diff --git a/tests/test_runner.gd b/tests/test_runner.gd new file mode 100644 index 0000000..ee4944f --- /dev/null +++ b/tests/test_runner.gd @@ -0,0 +1,1198 @@ +extends SceneTree +## Regel-, KI- und Spielstand-Tests ohne Fenster: +## godot --headless --path . -s tests/test_runner.gd + +var passed := 0 +var failed := 0 + + +func check(cond: bool, msg: String) -> void: + if cond: + passed += 1 + else: + failed += 1 + printerr("FEHLER: " + msg) + + +func C(id: String) -> Cards.Card: + return Cards.by_id(id) + + +func setup(human_hand: Array = [], opp_hand: Array = [], n_opp: int = 1) -> Array: + var deck := [] + for i in 10: + deck.append(C("f_kobold")) + var human := Game.Player.new("Du", deck, true, 30) + var opps := [] + for i in n_opp: + opps.append(Game.Player.new("Gegner %d" % (i + 1), deck, false, 15)) + var r := RandomNumberGenerator.new() + r.seed = 0 + var g := Game.new(human, opps, r) + g.turn = 1 + human.max_energy = 10 + human.energy = 10 + for cid in human_hand: + human.hand.append(C(cid)) + for o in opps: + o.max_energy = 10 + o.energy = 10 + for cid in opp_hand: + o.hand.append(C(cid)) + return [g, human, opps] + + +var next_uid := 9000 + + +func put(p: Game.Player, cid: String, ready: bool = true) -> Game.Creature: + next_uid += 1 + var cr := Game.Creature.new(C(cid), p, next_uid) + cr.ready = ready + cr.attacks_left = cr.attacks_per_turn() if ready else 0 + p.board.append(cr) + return cr + + +func _initialize() -> void: + test_kartenpool() + test_kombos() + test_wesen_spielen() + test_flink() + test_waechter() + test_waechter_nur_eigener_besitzer() + test_kampf_beidseitig() + test_schild() + test_doppelschlag() + test_einfrieren() + test_zauber_ziel() + test_flaechenschaden() + test_zurueck_auf_hand() + test_buff_heilung() + test_limits() + test_leeres_deck() + test_gleiche_regeln() + test_zugreihenfolge() + test_tote_gegner() + test_jeder_gegen_jeden() + test_gegnerzahl() + test_sieg_niederlage() + test_deckregeln() + test_gegnerdeck() + test_booster() + test_spielstand() + test_resonanz() + test_feldkombos() + test_schild_effekt_und_dreifach() + test_kopie() + test_ki_lethal() + test_ki_besser_als_gierig() + test_ki_spiele_enden() + test_spielstand_migration() + test_erfolge_und_tagesaufgabe() + test_kampagne() + test_fallen() + test_ki_sieht_fallen_nicht() + test_hotseat() + test_zufallssamen() + test_deck_slots_und_werkstatt() + test_spielstand_sicherung() + test_mulligan() + test_merkmale() + test_ki_zwei_schritte() + test_snapshot() + test_statistik_pity_tagesaufgaben() + test_deck_umbenennen_und_auffuellen() + test_neue_fallen() + test_kapitel_zwei_und_merkmalwahl() + test_herausforderung_und_bestenliste() + print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed]) + quit(1 if failed > 0 else 0) + + +func test_kartenpool() -> void: + check(Cards.all().size() == 106, "106 Karten") + for key in Cards.COMBOS: + var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key) + check(found, "Kombo vorhanden: " + key) + for e in Cards.ELEMENTS: + check(Cards.all().any(func(c): return c.elements == [e]), "Element vorhanden: " + e) + for c in Cards.all(): + check(c.rarity in Cards.RARITIES, "Seltenheit " + c.id) + check(not c.is_creature() or c.health > 0, "Leben " + c.id) + check(C("el_koenig").text() == "Wächter, Flink, Schild. Beim Ausspielen: Alle befreundeten Wesen erhalten +1/+1.", "Kartentext") + check(C("f_funke").text() == "2 Schaden an ein Ziel.", "Zaubertext") + check(C("ww_eiszeit").text() == "Friere alle gegnerischen Wesen ein, Ziehe 1 Karte.", "Eiszeit-Text") + + +func test_kombos() -> void: + check(Cards.combo_name(["Feuer", "Feuer"]) == "Inferno", "Feuer+Feuer = Inferno") + check(Cards.combo_name(["Luft", "Feuer"]) == "Blitz", "Luft+Feuer = Blitz") + check(C("ff_phoenix").is_combo() and C("ff_phoenix").combo() == "Inferno", "Phönix ist Inferno") + check(not C("f_funke").is_combo(), "Funke keine Kombo") + + +func test_wesen_spielen() -> void: + var s := setup(["f_wolf"]) + var g: Game = s[0] + var h: Game.Player = s[1] + check(g.play_card(h, C("f_wolf")) == "", "Wolf spielen") + check(h.energy == 8 and h.board.size() == 1, "Energie bezahlt") + check(not h.board[0].can_attack(), "Erschöpfung") + g.end_turn() + g.end_turn() + check(h.board[0].can_attack(), "bereit im nächsten Zug") + + +func test_flink() -> void: + var s := setup(["l_falke"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + g.play_card(h, C("l_falke")) + check(g.attack(h.board[0], o) == "", "Flink greift sofort an") + check(o.life == 12, "Falke 3 Schaden") + + +func test_waechter() -> void: + var s := setup() + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var a := put(h, "f_wolf") + var w := put(o, "e_wall") + check(g.attack(a, o) != "", "Wächter blockt Angriff auf Held") + check(g.attack(a, w) == "", "Wächter angreifen") + check(w.health == 1 and a.health == 2, "Schaden verrechnet") + + +func test_waechter_nur_eigener_besitzer() -> void: + var s := setup([], [], 2) + var g: Game = s[0] + var h: Game.Player = s[1] + var a := put(h, "f_wolf") + put(s[2][0], "e_wall") + check(g.attack(a, s[2][1]) == "" and s[2][1].life == 12, "Wächter schützt nur Besitzer") + + +func test_kampf_beidseitig() -> void: + var s := setup() + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var a := put(h, "f_asche") + var d := put(o, "w_reiter") + g.attack(a, d) + check(not (d in o.board) and not (a in h.board), "beide sterben") + check("Aschekrieger stirbt." in g.log, "Log") + + +func test_schild() -> void: + var s := setup() + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var sh := put(o, "ee_bastion") + var a := put(h, "f_asche") + g.attack(a, sh) + check(sh.health == 7 and not sh.shield, "Schild blockt einmal") + var b := put(h, "f_wolf") + g.attack(b, sh) + check(sh.health == 4, "zweiter Schaden zählt") + + +func test_doppelschlag() -> void: + var s := setup() + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var a := put(h, "l_schlange") + g.attack(a, o) + g.attack(a, o) + check(o.life == 11, "zwei Angriffe") + check(g.attack(a, o) != "", "kein dritter Angriff") + + +func test_einfrieren() -> void: + var s := setup([], ["w_eis"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var a := put(h, "f_wolf") + g.end_turn() + check(g.play_card(o, C("w_eis"), a) == "", "Eis spielen") + check(a.frozen, "eingefroren") + g.end_turn() + check(not a.frozen and not a.can_attack(), "taut auf, setzt aus") + g.end_turn() + g.end_turn() + check(a.can_attack(), "wieder bereit") + + +func test_zauber_ziel() -> void: + var s := setup(["w_eis", "f_funke"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + check(not g.can_play(h, C("w_eis"))[0], "Eis ohne Ziel") + check(g.can_play(h, C("f_funke"))[0], "Funke mit Held als Ziel") + g.play_card(h, C("f_funke"), o) + check(o.life == 13, "2 Schaden") + + +func test_flaechenschaden() -> void: + var s := setup(["ff_phoenix"], [], 2) + var g: Game = s[0] + var h: Game.Player = s[1] + var o1: Game.Player = s[2][0] + var o2: Game.Player = s[2][1] + put(o1, "f_kobold") + put(o2, "e_golem") + g.play_card(h, C("ff_phoenix")) + check(o1.life == 11 and o2.life == 11, "Helden 4 Schaden") + check(o1.board.is_empty() and o2.board[0].health == 1, "Wesen getroffen") + + +func test_zurueck_auf_hand() -> void: + var s := setup(["l_stoss"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var d := put(o, "e_troll") + g.play_card(h, C("l_stoss"), d) + check(o.board.is_empty() and C("e_troll") in o.hand, "zurück auf Hand") + + +func test_buff_heilung() -> void: + var s := setup(["e_haerten", "e_baum"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var a := put(h, "f_wolf") + h.life = 20 + g.play_card(h, C("e_haerten"), a) + check(a.attack == 5 and a.health == 4, "+2/+2") + g.play_card(h, C("e_baum")) + check(h.life == 25, "Heilung") + + +func test_limits() -> void: + var s := setup(["f_kobold"]) + var g: Game = s[0] + var h: Game.Player = s[1] + for i in Game.MAX_BOARD: + put(h, "f_kobold") + check(g.can_play(h, C("f_kobold"))[1] == "Dein Feld ist voll", "Feld voll") + h.hand = [] + for i in Game.MAX_HAND: + h.hand.append(C("f_kobold")) + g.draw(h) + check(h.hand.size() == Game.MAX_HAND, "Hand voll verbrennt") + + +func test_leeres_deck() -> void: + var s := setup() + var g: Game = s[0] + var h: Game.Player = s[1] + h.deck = [] + g.draw(h, 3) + check(h.life == 30 - 6, "Ermüdung 1+2+3") + + +func test_gleiche_regeln() -> void: + var r := RandomNumberGenerator.new() + r.seed = 0 + var g := Game.new_battle(Profile.starter().deck_cards(), 3, r) + var first: Game.Player = g.players[0] + check(g.current() == first and first.max_energy == 1 and first.energy == 1, "Startspieler: 1 Energie, kein Bonus") + check(first.hand.size() == Game.START_HAND + 1, "Startspieler 5 Handkarten") + for p in g.players: + check(p.life == Game.START_LIFE, "gleiches Leben") + if p != first: + check(p.hand.size() == Game.START_HAND + 1 and p.start_bonus == 1, "Nachzieher: Karte mehr, Bonus offen") + g.end_turn() + var second: Game.Player = g.players[1] + check(g.current() == second and second.max_energy == 1 and second.energy == 2, "Nachzieher: 1 Energie plus Ausgleich") + check(second.hand.size() == Game.START_HAND + 2, "Nachzieher 6 Handkarten nach dem Ziehen") + for i in 4: + g.end_turn() + check(g.current() == second and second.energy == 2 and second.max_energy == 2, "Ausgleich nur einmal") + + +func test_zugreihenfolge() -> void: + var starters := {} + for seed in 12: + var r := RandomNumberGenerator.new() + r.seed = seed + var g := Game.new_battle(Profile.starter().deck_cards(), 2, r) + starters[g.players[0].name] = true + check(g.players.size() == 3 and g.human in g.players and g.opponents.size() == 2, "alle am Tisch") + for i in g.players.size(): + check(g.players[i].order == i, "Reihenfolge nummeriert") + check(g.log[0].begins_with("Zugreihenfolge:"), "Reihenfolge im Verlauf") + var g2 := g.clone() + check(g2.players.map(func(p): return p.name) == g.players.map(func(p): return p.name), "Kopie behält Reihenfolge") + check(starters.size() > 1, "Startspieler wechselt (%d verschiedene)" % starters.size()) + + +func test_tote_gegner() -> void: + var s := setup([], [], 2) + var g: Game = s[0] + s[2][0].life = 0 + g.end_turn() + check(g.current() == s[2][1], "toter Gegner übersprungen") + g.end_turn() + check(g.current() == s[1] and g.turn == 2, "zurück beim Spieler, Zug 2") + + +func test_jeder_gegen_jeden() -> void: + var s := setup([], [], 2) + var g: Game = s[0] + var h: Game.Player = s[1] + var o1: Game.Player = s[2][0] + var o2: Game.Player = s[2][1] + check(g.enemies_of(o1) == [h, o2], "Gegner bekämpft Spieler und anderen Gegner") + check(g.enemies_of(h) == [o1, o2], "Spieler bekämpft alle Gegner") + g.end_turn() + o1.hand = [] + o1.mulligan_done = true + var a := put(o1, "f_wolf") + check(g.can_attack(a, o2)[0], "Gegner darf Gegner angreifen") + check(g.attack(a, o2) == "" and o2.life == 12, "Gegner trifft Gegner") + var act := AI.choose_action(g, o1) + check(act.type == "end", "KI-Zug endet ohne weitere Aktion") + o2.life = 2 + var b := put(o1, "f_kobold") + check(AI.choose_action(g, o1).target == o2, "KI nutzt Lethal gegen anderen Gegner") + + +func test_gegnerzahl() -> void: + var r := RandomNumberGenerator.new() + r.seed = 2 + for n in range(1, Game.MAX_OPPONENTS + 1): + var g := Game.new_battle(Profile.starter().deck_cards(), n, r) + check(g.opponents.size() == n, "%d Gegner" % n) + check(g.players.all(func(o): return o.life == Game.START_LIFE), "gleiches Leben bei %d" % n) + check(g.players[0].max_energy == 1 and g.current() == g.players[0], "Startspieler bei %d" % n) + check(g.human.hand.size() == Game.START_HAND + 1, "Handkarten bei %d" % n) + check(Profile.REWARD_WIN.has(n), "Belohnung bei %d" % n) + + +func test_sieg_niederlage() -> void: + var s := setup([], [], 2) + var g: Game = s[0] + g.damage(s[2][0], 99) + g.check_end() + check(g.winner == "", "noch offen") + g.damage(s[2][1], 99) + g.check_end() + check(g.winner == "human", "Sieg") + var s2 := setup() + var g2: Game = s2[0] + g2.damage(s2[1], 30) + g2.check_end() + check(g2.winner == "opponents", "Niederlage") + + +func test_deckregeln() -> void: + var starter := Profile.starter().deck_cards() + check(Game.validate_deck(starter)[0], "Starterdeck gültig") + var d := [] + for i in 19: + d.append(C("f_kobold")) + check(not Game.validate_deck(d)[0], "19 Karten ungültig") + d = [] + for i in 4: + d.append(C("f_kobold")) + for i in 16: + d.append(C("f_wolf")) + check(not Game.validate_deck(d)[0], "4 Kopien ungültig") + d = [] + for cid in ["f_kobold", "f_wolf", "f_asche", "f_funke", "w_geist", "w_reiter"]: + for i in 3: + d.append(C(cid)) + d.append(C("e_wall")) + d.append(C("e_wall")) + check("Elemente" in Game.validate_deck(d)[1], "3 Elemente ungültig") + check(not Game.validate_deck(starter, {"f_kobold": 1})[0], "nicht besessen") + + +func test_gegnerdeck() -> void: + var r := RandomNumberGenerator.new() + r.seed = 1 + for e in Cards.ELEMENTS: + var deck := Game.build_opponent_deck(r, [e]) + check(Game.validate_deck(deck)[0], "Mono-Gegnerdeck gültig: " + e) + check(Game.deck_elements(deck) == [e], "nur Element " + e) + check(Game.validate_deck(Game.build_opponent_deck(r, ["Feuer", "Luft"]))[0], "Zwei-Element-Gegnerdeck") + + +func test_booster() -> void: + var r := RandomNumberGenerator.new() + r.seed = 3 + for i in 50: + var cards := Profile.roll_booster(r) + check(cards.size() == 5, "5 Karten") + check(cards.any(func(c): return c.rarity != "gewöhnlich"), "mindestens eine seltene") + var p := Profile.new() + p.coins = Profile.BOOSTER_COST + var got := p.open_booster(r) + check(p.coins == 0 and got.size() == 5, "Booster kostet 100") + var total := 0 + for cid in p.collection: + total += p.collection[cid] + check(total == 5, "Sammlung +5") + check(p.open_booster(r).is_empty(), "ohne Münzen kein Booster") + + +func test_spielstand() -> void: + var path := "user://test_spielstand.json" + var abs := ProjectSettings.globalize_path(path) + for suffix in ["", ".bak"]: + if FileAccess.file_exists(path + suffix): + DirAccess.remove_absolute(abs + suffix) + var p := Profile.load_or_new(path) + check(p.deck_valid()[0], "Starterprofil gültig") + check(FileAccess.file_exists(path), "Datei angelegt") + p.coins = 999 + p.save(path) + var p2 := Profile.load_or_new(path) + check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == 39, "geladen") + for suffix in ["", ".bak"]: + if FileAccess.file_exists(path + suffix): + DirAccess.remove_absolute(abs + suffix) + + +func test_ki_lethal() -> void: + var s := setup() + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + o.life = 3 + var a := put(h, "f_asche") + put(o, "w_reiter") + var act := AI.choose_action(g, h) + check(act.type == "attack" and act.creature == a and act.target == o, "KI nutzt Lethal") + + +func test_ki_spiele_enden() -> void: + var r := RandomNumberGenerator.new() + r.seed = 7 + for n in [1, 3, 5]: + for i in 6: + var pool: Array = Cards.ELEMENTS.duplicate() + Game.shuffle(pool, r) + var g := Game.new_battle(Game.build_opponent_deck(r, [pool[0], pool[1]]), n, r) + var guard := 0 + while g.winner == "" and guard < 300: + AI.take_turn(g, g.current(), i % 2 == 0) + guard += 1 + check(g.winner != "", "Spiel endet (%d Gegner)" % n) + + +func test_resonanz() -> void: + var s := setup(["f_wolf", "f_kobold", "e_wall"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + g.play_card(h, C("f_wolf")) + check(h.board[0].power() == 3, "allein keine Resonanz") + g.play_card(h, C("f_kobold")) + check(h.board[0].power() == 4 and h.board[1].power() == 3, "Feuer+Feuer: beide +1") + g.play_card(h, C("e_wall")) + check(h.board[2].power() == 0, "Erde allein: kein Bonus") + check(C("f_wolf").text() == "" and Cards.RESONANCE_TEXT.begins_with("Resonanz"), "Resonanz ist Regel, kein Kartentext") + var third := put(h, "f_asche") + g.update_resonance() + check(h.board[0].power() == 5 and third.power() == 6, "drei Feuer-Wesen: +2") + put(h, "f_kobold") + put(h, "f_kobold") + put(h, "f_kobold") + g.update_resonance() + check(h.board[0].power() == 7, "Resonanz höchstens +4") + for i in [1, 3, 4, 5, 6]: + h.board[i].health = 0 + g.cleanup() + check(h.board[0].power() == 3, "Resonanz sinkt mit den Partnern") + + +func test_feldkombos() -> void: + # Dampf: Feuer neben Wasser friert das stärkste gegnerische Wesen ein + var s := setup(["w_reiter"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + put(h, "f_wolf") + var weak := put(o, "f_kobold") + var strong := put(o, "e_troll") + g.play_card(h, C("w_reiter")) + 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") + # Lava: 1 Schaden an alle gegnerischen Wesen + s = setup(["e_golem"]) + g = s[0] + h = s[1] + o = s[2][0] + put(h, "f_wolf") + var k := put(o, "f_kobold") + g.play_card(h, C("e_golem")) + check(not (k in o.board), "Lava tötet den 2/1 Kobold") + # Blitz: 2 Schaden an den schwächsten Gegner + s = setup(["l_wind"], [], 2) + g = s[0] + h = s[1] + put(h, "f_wolf") + s[2][1].life = 5 + g.play_card(h, C("l_wind")) + check(s[2][1].life == 3 and s[2][0].life == 15, "Blitz trifft den schwächsten Gegner") + # Schlamm heilt, Sturm zieht, Sand gibt Schild + s = setup(["e_wall", "l_wind", "w_reiter"]) + g = s[0] + h = s[1] + h.life = 20 + g.play_card(h, C("w_reiter")) + g.play_card(h, C("e_wall")) + check(h.life == 23, "Schlamm heilt um 3") + var hand_before := h.hand.size() + g.play_card(h, C("l_wind")) + check(h.hand.size() == hand_before, "Sturm zieht 1 (eine gespielt, eine gezogen)") + check(not h.board[2].shield, "nur eine Feldkombo je Ausspielen") + s = setup(["l_wind"]) + g = s[0] + h = s[1] + put(h, "e_wall") + g.play_card(h, C("l_wind")) + check(h.board[1].shield, "Sand: neues Wesen hat Schild") + # Gleiche Elemente lösen keine Feldkombo aus + s = setup(["f_kobold"]) + g = s[0] + h = s[1] + put(h, "f_wolf") + g.play_card(h, C("f_kobold")) + check(not g.log.any(func(l): return l.begins_with("Feldkombo")), "Feuer+Feuer ist Resonanz, keine Feldkombo") + # Kombokarte bringt ihr zweites Element mit + s = setup(["fw_wolke", "fe_golem"]) + g = s[0] + h = s[1] + o = s[2][0] + put(h, "w_reiter") + var t := put(o, "e_troll") + g.play_card(h, C("fe_golem")) + check(t.frozen, "Lava-Wesen neben Wasser löst Dampf aus") + + +func test_schild_effekt_und_dreifach() -> void: + var s := setup(["e_steinhaut", "fff_sonne"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var a := put(h, "f_wolf") + g.play_card(h, C("e_steinhaut"), a) + check(a.shield, "Steinhaut gibt Schild") + var sonne := C("fff_sonne") + check(sonne.rarity == "mythisch" and sonne.combo() == "Sonne" and sonne.is_combo(), "Dreifach-Kombo Sonne") + check(Cards.of_rarity("mythisch").size() == 4, "vier mythische Karten") + var troll := put(o, "e_troll") + g.play_card(h, C("fff_sonne")) + check(o.life == 10 and troll.health == 1, "Kern der Sonne: 5 Schaden an alle") + check(Cards.combo_name(["Erde", "Erde", "Erde"]) == "Gebirge", "Dreifach-Name") + + +func test_kopie() -> void: + var s := setup(["f_wolf"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + var a := put(h, "f_asche") + var g2 := g.clone() + var a2 := g2.find_creature(a.uid) + check(a2 != null and a2 != a and a2.owner() == g2.players[0], "Wesen kopiert mit neuem Besitzer") + g2.attack(a2, g2.players[1]) + check(g2.players[1].life == 11 and o.life == 15, "Kopie verändert das Original nicht") + g2.play_card(g2.players[0], C("f_wolf")) + check(h.hand.size() == 1 and g2.players[0].hand.is_empty(), "Hand der Kopie unabhängig") + check(g2.log.is_empty() and g2.events.is_empty(), "Kopie schreibt keinen Verlauf") + + +func test_ki_besser_als_gierig() -> void: + var r := RandomNumberGenerator.new() + r.seed = 11 + var smart := 0 + var games := 40 + for i in games: + var pool: Array = Cards.ELEMENTS.duplicate() + Game.shuffle(pool, r) + var g := Game.new_battle(Game.build_opponent_deck(r, [pool[0], pool[1]]), 1, r) + # abwechselnd beginnt die schlaue oder die gierige KI + var smart_is_human := i % 2 == 0 + var guard := 0 + while g.winner == "" and guard < 300: + var p := g.current() + AI.take_turn(g, p, p.is_human != smart_is_human) + guard += 1 + if (g.winner == "human") == smart_is_human: + smart += 1 + check(smart >= games / 2, "Vorausschau-KI ist mindestens so gut wie die gierige (%d von %d)" % [smart, games]) + + +func test_spielstand_migration() -> void: + var alt := {"coins": 300, "collection": {"f_kobold": 2, "unbekannt": 1}, "deck": ["f_kobold", "f_kobold"]} + var p := Profile.from_dict(alt) + check(p.coins == 300 and p.collection == {"f_kobold": 2} and p.deck.size() == 2, "Version 1 gelesen") + check(p.stats.games == 0 and p.achievements.is_empty() and p.campaign_stage == 0, "Standardwerte ergänzt") + check(p.settings.volume == 0.8 and p.settings.animations, "Einstellungen Standard") + p.stats.wins = 3 + p.achievements.append("erster_sieg") + p.campaign_stage = 2 + p.settings.volume = 0.3 + var d := p.to_dict() + check(d.version == Profile.SAVE_VERSION, "Version gespeichert") + var p2 := Profile.from_dict(JSON.parse_string(JSON.stringify(d))) + check(p2.stats.wins == 3 and p2.achievements == ["erster_sieg"] and p2.campaign_stage == 2, "Version 2 gelesen") + check(is_equal_approx(p2.settings.volume, 0.3), "Einstellungen gelesen") + + +func test_erfolge_und_tagesaufgabe() -> void: + Profile.today_override = "2026-09-16" + var p := Profile.starter() + var coins := p.coins + var reward := p.record_battle(true, 1, p.deck_cards()) + check(reward == Profile.REWARD_WIN[1], "Duell-Belohnung") + check("erster_sieg" in p.achievements and "duellant" in p.achievements, "Erfolge Erster Sieg und Duellant") + check(not ("tischherr" in p.achievements), "Herr des Tisches noch offen") + check(p.coins == coins + reward + 100 + (p.daily_task().reward if p.daily.done else 0), "Münzen inklusive Erfolge") + check(p.notices.size() >= 2, "Meldungen vorhanden") + p.take_notices() + check(p.notices.is_empty(), "Meldungen abgeholt") + check(p.daily.date == "2026-09-16" and p.daily.task != "", "Tagesaufgabe gesetzt") + # Tagesaufgabe erfüllen (je nach Aufgabe: Sieg, zwei Kämpfe oder Booster) + var task := p.daily_task() + if not p.daily.done: + if task.kind == "game": + p.record_battle(false, 2, p.deck_cards()) + elif task.kind == "booster": + p.coins = 100 + p.open_booster() + check(p.daily.done, "Tagesaufgabe erfüllt: " + task.text) + var progress_before: int = p.daily.progress + p.record_battle(true, 1, p.deck_cards()) + check(p.daily.progress == progress_before, "erfüllte Aufgabe zählt nicht weiter") + Profile.today_override = "2026-09-17" + p.refresh_daily() + check(p.daily.date == "2026-09-17" and not p.daily.done and p.daily.progress == 0, "neuer Tag, neue Aufgabe") + Profile.today_override = "" + # Mono-Deck-Erfolg + var mono := [] + for cid in ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche", "f_glutpfeil", "f_elementar"]: + for i in 3: + mono.append(Cards.by_id(cid)) + mono = mono.slice(0, 20) + p.record_battle(true, 3, mono) + check("elementarist" in p.achievements and "tischherr" in p.achievements, "Elementarist und Herr des Tisches") + check(p.stats.wins == 3 and p.stats.wins_by_opp["1"] == 2, "Statistik") + + +func test_kampagne() -> void: + var r := RandomNumberGenerator.new() + r.seed = 4 + check(Campaign.STAGES.size() == 10, "zehn Stufen") + var opps := Campaign.opponents(4, r) + check(opps.size() == 3 and opps[0].name == "Vulkar" and opps[0].title == "Herr der Lava", "Stufe 5: Vulkar mit 2 Begleitern") + check(Game.deck_elements(opps[0].deck).all(func(e): return e in ["Feuer", "Erde"]), "Boss-Deck aus seinen Elementen") + var g := Game.new_battle_with(Profile.starter().deck_cards(), opps, r) + check(g.opponents.size() == 3 and g.human.life == Game.START_LIFE, "Kampagnenkampf gestartet") + var p := Profile.starter() + var reward := p.record_battle(true, 1, p.deck_cards(), 0) + check(p.campaign_stage == 1 and reward == Campaign.STAGES[0].reward, "Stufe 1 abgeschlossen") + reward = p.record_battle(true, 1, p.deck_cards(), 0) + check(p.campaign_stage == 1 and reward == Profile.REWARD_WIN[1], "Wiederholung gibt normale Belohnung") + reward = p.record_battle(false, 2, p.deck_cards(), 1) + check(p.campaign_stage == 1 and reward == Profile.REWARD_LOSS, "Niederlage schaltet nichts frei") + p.campaign_stage = 9 + p.record_battle(true, 5, p.deck_cards(), 9) + check(p.campaign_stage == 10 and "kampagnenheld" in p.achievements, "Kampagne abgeschlossen") + var sim := Game.new_battle_with(Profile.starter().deck_cards(), Campaign.opponents(0, r), r) + var guard := 0 + while sim.winner == "" and guard < 300: + AI.take_turn(sim, sim.current(), true) + guard += 1 + check(sim.winner != "", "Kampagnenduell endet") + + +func test_fallen() -> void: + var s := setup(["f_falle"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + check(C("f_falle").is_trap() and C("f_falle").text().begins_with("Falle. Wenn ein gegnerisches Wesen"), "Fallentext") + check(g.play_card(h, C("f_falle")) == "" and h.traps.size() == 1 and h.hand.is_empty() and h.energy == 8, "Falle gelegt") + check(g.log.back() == "Du legt eine Falle.", "Falle bleibt im Verlauf verdeckt") + g.end_turn() + var a := put(o, "e_troll") + 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") + # Frostfalle bricht den Angriff ab + s = setup(["w_falle"]) + g = s[0] + h = s[1] + o = s[2][0] + g.play_card(h, C("w_falle")) + g.end_turn() + a = put(o, "e_troll") + g.attack(a, h) + check(h.life == 30 and a.frozen and a.attacks_left == 0, "Frostfalle: Angriff verpufft, Angreifer eingefroren") + # Erdwall beschwört einen Steinwall + s = setup(["e_falle"]) + g = s[0] + h = s[1] + o = s[2][0] + g.play_card(h, C("e_falle")) + g.end_turn() + a = put(o, "f_wolf") + g.attack(a, h) + check(h.board.size() == 1 and h.board[0].name == "Steinwall" and h.life == 27, "Erdwall beschwört Steinwall") + # Windfalle schickt ein ausgespieltes Wesen zurück + s = setup(["l_falle"], ["f_wolf"]) + g = s[0] + h = s[1] + o = s[2][0] + g.play_card(h, C("l_falle")) + g.end_turn() + g.play_card(o, C("f_wolf")) + check(o.board.is_empty() and C("f_wolf") in o.hand and o.energy == 8, "Windfalle: Wesen zurück, Energie weg") + # Höchstens zwei Fallen + s = setup(["f_falle", "f_falle", "f_falle"]) + g = s[0] + h = s[1] + g.play_card(h, C("f_falle")) + g.play_card(h, C("f_falle")) + check(not g.can_play(h, C("f_falle"))[0] and "Fallen" in g.can_play(h, C("f_falle"))[1], "höchstens 2 Fallen") + # Falle schützt auch Wesen: toter Angreifer trifft nicht mehr + s = setup(["f_falle"]) + g = s[0] + h = s[1] + o = s[2][0] + g.play_card(h, C("f_falle")) + var w := put(h, "e_wall") + g.end_turn() + a = put(o, "f_wolf") + g.attack(a, w) + check(not (a in o.board) and w.health == 4, "Feuerfalle tötet den Wolf, Steinwall bleibt heil") + + +func test_ki_sieht_fallen_nicht() -> void: + var s := setup(["w_falle"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + g.play_card(h, C("w_falle")) + var hi := g.players.find(h) + var oi := g.players.find(o) + check(g.clone().players[hi].traps.size() == 1, "volle Kopie kennt Fallen") + var g2 := g.clone_for(o) + check(g2.players[hi].traps.is_empty() and g2.players[oi].traps == o.traps, "KI-Kopie kennt fremde Fallen nicht") + g.end_turn() + o.hand = [] + o.mulligan_done = true + var a := put(o, "e_troll") + var act := AI.choose_action(g, o) + check(act.type == "attack" and act.target == h, "KI greift trotz verdeckter Falle an") + check(g.clone_for(h).players[hi].traps.size() == 1, "eigene Fallen bleiben in der eigenen Kopie") + + +func test_hotseat() -> void: + var r := RandomNumberGenerator.new() + r.seed = 3 + var d := Profile.starter().deck_cards() + var g := Game.new_hotseat([d, d], 1, r) + check(g.players.size() == 3 and g.humans.size() == 2, "zwei Menschen und eine KI") + check(g.humans[0].name == "Spieler 1" and g.humans[1].name == "Spieler 2", "Spielernamen") + var ai = g.players.filter(func(p): return not p.is_human)[0] + g.damage(ai, 99) + g.check_end() + check(g.winner == "", "zwei Menschen übrig: weiter") + g.damage(g.humans[1], 99) + g.check_end() + check(g.winner == "human" and g.winner_player == g.humans[0], "Spieler 1 gewinnt") + var g2 := Game.new_hotseat([d, d], 1, r) + for hu in g2.humans: + g2.damage(hu, 99) + g2.check_end() + check(g2.winner == "opponents" and g2.winner_player != null and not g2.winner_player.is_human, "KI gewinnt, wenn alle Menschen tot sind") + var g3 := Game.new_hotseat([d, d, d], 0, r) + check(g3.players.size() == 3 and g3.opponents.size() == 2, "drei Menschen ohne KI") + var guard := 0 + while g3.winner == "" and guard < 300: + AI.take_turn(g3, g3.current(), true) + guard += 1 + check(g3.winner == "human" and g3.winner_player != null, "Hotseat-Partie endet mit Sieger") + + +func test_zufallssamen() -> void: + Game.test_seed = 77 + var a := Game.make_rng().randi() + Game.test_seed = 77 + var b := Game.make_rng().randi() + Game.test_seed = 0 + check(a == b, "fester Samen wiederholbar") + var c := Game.make_rng().randi() + var d := Game.make_rng().randi() + check(c != d, "ohne Samen echter Zufall") + + +func test_deck_slots_und_werkstatt() -> void: + var p := Profile.starter() + check(p.decks.size() == Profile.DECK_SLOTS and p.deck.size() == 20 and p.decks[1].cards.is_empty(), "fünf Slots, Deck 1 gefüllt") + p.active_deck = 1 + check(p.deck.is_empty() and not p.deck_valid()[0] and p.deck_valid(0)[0], "aktives Deck wechselt") + p.deck = ["f_kobold"] + check(p.decks[1].cards == ["f_kobold"], "Zuweisung geht in den Slot") + p.active_deck = 0 + # Migration aus Version 2 (ein Deck) + var v2 := {"version": 2, "coins": 5, "collection": {"f_kobold": 3}, "deck": ["f_kobold", "f_kobold"]} + var m := Profile.from_dict(v2) + check(m.decks[0].cards == ["f_kobold", "f_kobold"] and m.active_deck == 0 and m.dust == 0, "Version 2 wird zu Slot 1") + var v3 := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) + check(v3.decks[1].cards == ["f_kobold"] and v3.deck_name(1) == "Deck 2", "Version 3 gelesen") + # Werkstatt + p.collection["f_kobold"] = 5 + check(p.dismantle("f_kobold") == "" and p.collection["f_kobold"] == 4 and p.dust == 5, "Zerlegen bringt Staub") + check(p.dismantle("f_kobold", 3) != "" and p.collection["f_kobold"] == 4, "Deckbedarf schützt Karten") + check(p.dismantle("ff_phoenix") != "", "nichts zu zerlegen") + p.dust = 100 + check(p.craft("ff_phoenix") != "" and p.dust == 100, "zu wenig Staub") + p.dust = 1600 + check(p.craft("ff_phoenix") == "" and p.collection["ff_phoenix"] == 1 and p.dust == 0, "Herstellen") + p.collection["f_wolf"] = 6 + var got := p.dismantle_extras() + check(got == 20 and p.collection["f_wolf"] == 3 and p.collection["f_kobold"] == 3, "Überschuss zerlegt") + + +func test_spielstand_sicherung() -> void: + var path := "user://test_sicherung.json" + var abs := ProjectSettings.globalize_path(path) + for suffix in ["", ".bak", ".corrupt"]: + if FileAccess.file_exists(path + suffix): + DirAccess.remove_absolute(abs + suffix) + var p := Profile.load_or_new(path) + p.coins = 123 + p.save(path) + p.coins = 456 + p.save(path) + check(FileAccess.file_exists(path + ".bak"), "Sicherung angelegt") + check(Profile.load_or_new(path).coins == 456 and Profile.load_or_new(path + ".bak").coins == 123, "Sicherung ist der Vorgänger") + var f := FileAccess.open(path, FileAccess.WRITE) + f.store_string("{kaputt") + f.close() + check(Profile.load_or_new(path).coins == 123, "kaputter Stand: Sicherung greift") + for suffix in ["", ".bak", ".corrupt"]: + if FileAccess.file_exists(path + suffix): + DirAccess.remove_absolute(abs + suffix) + + +func test_mulligan() -> void: + var r := RandomNumberGenerator.new() + r.seed = 9 + var g := Game.new_battle(Profile.starter().deck_cards(), 1, r) + var first: Game.Player = g.players[0] + var hand_before := first.hand.duplicate() + var swap := [hand_before[0], hand_before[1]] + check(g.mulligan(first, swap) == "", "Mulligan erlaubt im ersten Zug") + check(first.hand.size() == hand_before.size() and first.deck.size() == 20 - hand_before.size(), "gleich viele Karten nachgezogen") + check(g.mulligan(first, [first.hand[0]]) != "", "nur einmal") + check(g.log.any(func(l): return "tauscht 2 Karten" in l), "Mulligan im Verlauf") + g.end_turn() + var second: Game.Player = g.players[1] + check(g.mulligan(first, []) != "", "nicht im fremden Zug") + check(g.mulligan(second, []) == "" and second.mulligan_done, "Hand behalten zählt als Mulligan") + for i in 2: + g.end_turn() + check(g.mulligan(g.current(), []) != "", "nicht mehr im zweiten Zug") + # KI tauscht teure Karten + var s := setup([], ["ff_phoenix", "f_kobold", "ww_leviathan"]) + var g2: Game = s[0] + var o: Game.Player = s[2][0] + g2.end_turn() + var act := AI.choose_action(g2, o) + check(act.type == "mulligan" and act.cards.size() == 2 and not (C("f_kobold") in act.cards), "KI legt teure Karten zurück") + AI.apply_action(g2, o, act) + check(o.mulligan_done and AI.choose_action(g2, o).type != "mulligan", "danach normale Züge") + + +func test_merkmale() -> void: + var s := setup(["f_wolf", "l_falke", "e_wall", "f_funke"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + h.perks = ["feuer_angriff", "flink_angriff", "waechter_leben", "schild_wesen", "alle_flink", "zauber_schaden", "resonanz_doppelt"] + g.play_card(h, C("f_wolf")) + var wolf: Game.Creature = h.board[0] + check(wolf.power() == 4 and wolf.shield and wolf.can_attack(), "Feuer +1, Schild, sofort bereit") + g.play_card(h, C("l_falke")) + check(h.board[1].power() == 4, "Flink +1") + g.play_card(h, C("e_wall")) + check(h.board[2].health == 6 and h.board[2].max_health == 6, "Wächter +2 Leben") + var life_before: int = o.life + g.play_card(h, C("f_funke"), o) + check(o.life == life_before - 3, "Zauber +1 Schaden") + put(h, "f_kobold") + g.update_resonance() + check(wolf.resonance == 2 and wolf.power() == 6, "Resonanz doppelt") + for perk in Game.PERKS: + check(Campaign.STAGES.any(func(st): return st.perk == perk), "Merkmal vergeben: " + perk) + var r := RandomNumberGenerator.new() + r.seed = 2 + var mara: Game.Player = Campaign.opponents(1, r)[0] + check(mara.life == 35 and mara.perks == ["leben_35"], "Mara hat 35 Leben") + var g3 := Game.new_battle_with(Profile.starter().deck_cards(), Campaign.opponents(9, r), r, true) + var archi: Game.Player = g3.opponents[0] + check(g3.players[0] == g3.human, "feste Reihenfolge: Mensch beginnt") + g3.end_turn() + check(g3.current() == archi and archi.max_energy == 3, "Archimagier beginnt mit 3 Energie") + var nyx: Game.Player = Campaign.opponents(5, r)[0] + var g4 := Game.new_battle_with(Profile.starter().deck_cards(), [nyx], r, true) + var before: int = nyx.hand.size() + g4.end_turn() + check(nyx.hand.size() == before + 2, "Nyx zieht 2 Karten") + + +func test_ki_zwei_schritte() -> void: + # Gegner hat 5 Leben, eigenes Wesen 3 Angriff, Verhärten (+2) auf der Hand: erst verstärken, dann töten + var s := setup(["e_haerten"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + h.mulligan_done = true + h.turns = 2 + o.life = 5 + var a := put(h, "f_wolf") + var act := AI.choose_action(g, h) + check(act.type == "play" and act.card == C("e_haerten") and act.target == a, "KI verstärkt zuerst") + AI.apply_action(g, h, act) + var act2 := AI.choose_action(g, h) + check(act2.type == "attack" and act2.target == o, "dann tödlicher Angriff") + AI.apply_action(g, h, act2) + check(g.winner == "human", "Zwei-Schritt-Plan gewinnt") + + +func test_snapshot() -> void: + var s := setup(["f_falle", "f_wolf"], ["w_eis"], 2) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + g.play_card(h, C("f_falle")) + g.play_card(h, C("f_wolf")) + put(o, "e_troll") + var hi := g.players.find(h) + var oi := g.players.find(o) + var snap := g.snapshot(hi) + var json: Dictionary = JSON.parse_string(JSON.stringify(snap)) + var g2 := Game.from_snapshot(json) + check(g2.players.size() == 3 and g2.turn == g.turn and g2.current_idx == g.current_idx, "Grunddaten") + check(g2.players[hi].hand.is_empty() and g2.players[hi].traps.size() == 1 and g2.players[hi].traps[0].id == "f_falle", "eigene Hand und Fallen sichtbar") + check(g2.players[oi].hand.size() == 1 and g2.players[oi].hand[0] != C("w_eis"), "fremde Hand nur als Anzahl") + check(g2.players[oi].deck.size() == o.deck.size(), "fremdes Deck nur als Anzahl") + var cr2 := g2.find_creature(h.board[0].uid) + check(cr2 != null and cr2.card.id == "f_wolf" and cr2.owner() == g2.players[hi] and not cr2.ready, "Wesen mit Besitzer und Zustand") + check(g2.events.size() == g.events.size() and g2.events.back().type == g.events.back().type, "Ereignisse übertragen") + check(not g2.can_play(g2.players[hi], C("fff_sonne"))[0] and g2.valid_targets(g2.players[hi], "enemy_creature").size() == 1, "Kopie kennt die Regeln") + var snap_o := g.snapshot(oi) + check(snap_o.players[hi].hand is int and snap_o.players[hi].traps is int and snap_o.players[oi].hand is Array, "Sicht des Gegners") + + +func test_statistik_pity_tagesaufgaben() -> void: + Profile.today_override = "2026-09-20" + var p := Profile.starter() + var deck := p.deck_cards() + p.record_battle(true, 3, deck, -1, ["f_kobold", "f_kobold", "f_funke"]) + check(p.card_stats["f_kobold"].played == 2 and p.card_stats["f_funke"].played == 1, "gespielte Karten gezählt") + check(p.card_stats["f_kobold"].games == 1 and p.card_stats["f_kobold"].wins == 1 and p.card_stats["e_wall"].games == 1, "Partien und Siege je Deckkarte") + p.record_battle(false, 1, deck, -1, []) + check(p.card_stats["f_kobold"].games == 2 and p.card_stats["f_kobold"].wins == 1, "Niederlage zählt Partie") + var top := p.top_cards(3) + check(top.size() == 3 and top[0].wins == 1, "Bestenliste der Karten") + # Pity-Zähler + p.coins = 100000 + var r := RandomNumberGenerator.new() + r.seed = 1 + p.booster_since_mythic = Profile.PITY_BOOSTERS - 1 + var got := p.open_booster(r) + check(got.any(func(c): return c.rarity == "mythisch"), "nach %d Boostern garantiert mythisch" % Profile.PITY_BOOSTERS) + check(p.booster_since_mythic == 0, "Zähler zurückgesetzt") + var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) + check(v.card_stats["f_kobold"].played == 2 and v.booster_since_mythic == 0 and v.settings.has("music"), "Version 4 gelesen") + # Zwölf Tagesaufgaben, elementbezogen + check(Profile.DAILY_TASKS.size() == 12, "zwölf Tagesaufgaben") + var seen := {} + for day in range(1, 13): + Profile.today_override = "2026-10-%02d" % day + p.refresh_daily() + seen[p.daily.task] = true + check(seen.size() == 12, "jede Aufgabe kommt vor") + for t in Profile.DAILY_TASKS: + var q := Profile.starter() + q.daily = {"date": Profile.today(), "task": t.id, "progress": 0, "done": false} + var mono := [] + for i in 20: + mono.append(Cards.by_id("w_reiter")) + match t.kind: + "win": + q.record_battle(true, 1, mono) + "game": + for i in t.goal: + q.record_battle(false, 1, mono) + "booster": + q.coins = 1000 + for i in t.goal: + q.open_booster(r) + "craft": + q.dust = 10000 + q.craft("f_kobold") + "win_vs3": + q.record_battle(true, 3, mono) + "win_element:Wasser": + q.record_battle(true, 1, mono) + "win_element:Feuer": + q.record_battle(true, 1, q.deck_cards()) + "win_element:Erde": + q.record_battle(true, 1, q.deck_cards()) + "win_element:Luft": + var luft := [] + for i in 20: + luft.append(Cards.by_id("l_falke")) + q.record_battle(true, 1, luft) + _: + for i in t.goal: + q.daily_event(t.kind) + check(q.daily.done, "Tagesaufgabe erfüllbar: " + t.id) + Profile.today_override = "" + + +func test_deck_umbenennen_und_auffuellen() -> void: + var p := Profile.starter() + check(p.rename_deck(1, " Feuerdeck ") == "" and p.deck_name(1) == "Feuerdeck", "umbenannt") + check(p.rename_deck(1, "") != "" and p.rename_deck(1, "x".repeat(40)) != "", "Name geprüft") + check(p.autofill_deck(1) == "" and p.deck_valid(1)[0], "leerer Slot aufgefüllt und spielbar") + check(Game.deck_elements(p.deck_cards(1)).size() <= 2, "höchstens zwei Elemente") + p.decks[2].cards = ["l_falke", "l_wind"] + check(p.autofill_deck(2) == "" and p.deck_valid(2)[0], "Luft-Slot aufgefüllt") + check(p.deck_cards(2).all(func(c): return c.elements.all(func(e): return e in ["Luft", Game.deck_elements(p.deck_cards(2))[1] if Game.deck_elements(p.deck_cards(2)).size() > 1 else "Luft"])), "Auffüllen bleibt bei den Elementen") + var q := Profile.new() + q.collection = {"f_kobold": 1} + check(q.autofill_deck(0) != "", "zu wenig Karten: Fehlermeldung") + + +func test_neue_fallen() -> void: + check(Cards.all().filter(func(c): return c.is_trap()).size() == 10, "zehn Fallen") + 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") + # Böenfalle: Angreifer zurück auf die Hand, Angriff verpufft + var s := setup(["l_boenfalle"]) + var g: Game = s[0] + var h: Game.Player = s[1] + var o: Game.Player = s[2][0] + g.play_card(h, C("l_boenfalle")) + g.end_turn() + var a := put(o, "e_troll") + g.attack(a, h) + check(o.board.is_empty() and C("e_troll") in o.hand and h.life == 30, "Böenfalle: Angreifer zurück, kein Schaden") + # Aschefalle: 3 Schaden an das ausgespielte Wesen + s = setup(["f_aschefalle"], ["f_wolf"]) + g = s[0] + h = s[1] + o = s[2][0] + g.play_card(h, C("f_aschefalle")) + g.end_turn() + g.play_card(o, C("f_wolf")) + 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 + s = setup(["e_sandfalle", "l_spiegel"], ["e_troll"]) + g = s[0] + h = s[1] + o = s[2][0] + g.play_card(h, C("e_sandfalle")) + g.play_card(h, C("l_spiegel")) + g.end_turn() + g.play_card(o, C("e_troll")) + check(o.board.size() == 1 and o.board[0].frozen, "Sandfalle friert ein") + check(h.board.size() == 1 and h.board[0].name == "Windgeist", "Spiegelfalle beschwört Windgeist") + check(h.traps.is_empty(), "beide Fallen verbraucht") + # Nebelfalle bricht ab und zieht, Dornenfalle verletzt und heilt + s = setup(["w_nebelfalle", "e_dornen"]) + g = s[0] + h = s[1] + o = s[2][0] + g.play_card(h, C("w_nebelfalle")) + g.play_card(h, C("e_dornen")) + h.life = 20 + g.end_turn() + a = put(o, "e_troll") + var hand_before := h.hand.size() + g.attack(a, h) + check(h.life == 20 and h.hand.size() == hand_before + 1 and a.health == 6, "Nebelfalle zuerst: verpufft, Karte gezogen") + var b := put(o, "f_wolf") + g.attack(b, h) + check(b.health <= 0 or not (b in o.board), "Dornenfalle: 2 Schaden töten den Wolf") + check(h.life == 23, "Dornenfalle heilt 3, Angriff entfällt") + + +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") + for i in Campaign.total(): + var st := Campaign.stage(i) + check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1)) + var r := RandomNumberGenerator.new() + r.seed = 8 + var opps := Campaign.opponents(19, r) + check(opps.size() == 5 and opps[0].name == "Archimagier" and opps[0].perks.size() == 3, "letzte Stufe: Archimagier mit drei Merkmalen, voller Tisch") + var p := Profile.starter() + check(not p.perk_unlocked(), "Merkmalwahl erst nach Kapitel 1") + p.campaign_stage = 10 + check(p.perk_unlocked(), "nach Kapitel 1 freigeschaltet") + p.perk = "feuer_angriff" + var g := Game.new_battle(p.deck_cards(), 1, r, {"human_perks": [p.perk]}) + check(g.human.perks == ["feuer_angriff"] and g.log.any(func(l): return l.begins_with("Du: Feuerwesen")), "eigenes Merkmal wirkt und steht im Verlauf") + p.campaign_stage = 19 + p.record_battle(true, 5, p.deck_cards(), 19) + check(p.campaign_stage == 20 and "elementmeister" in p.achievements, "Kapitel 2 abgeschlossen") + var v := Profile.from_dict(JSON.parse_string(JSON.stringify(p.to_dict()))) + check(v.perk == "feuer_angriff" and v.campaign_stage == 20, "Version 5 gelesen") + + +func test_herausforderung_und_bestenliste() -> void: + Profile.today_override = "2026-09-16" + var ch := Challenge.current() + check(ch.has("rules") and ch.n_opp >= 1, "Herausforderung der Woche") + var weeks := {} + for d in range(1, 43, 7): + Profile.today_override = "2026-%02d-%02d" % [1 + d / 28, 1 + d % 28] + weeks[Challenge.current().id] = true + check(weeks.size() >= 4, "Herausforderungen wechseln wöchentlich") + Profile.today_override = "2026-09-16" + var mono := [] + for i in 20: + mono.append(Cards.by_id("w_reiter")) + check(Challenge.deck_allowed(Challenge.LIST[5], Profile.starter().deck_cards()) != "", "Reine Lehre lehnt Zwei-Element-Deck ab") + check(Challenge.deck_allowed(Challenge.LIST[5], mono) == "", "Mono-Deck erlaubt") + var r := RandomNumberGenerator.new() + r.seed = 4 + var g := Game.new_battle(mono, 3, r, Challenge.LIST[0].rules) + check(g.players.all(func(p): return p.life == 20 and "start_energie" in p.perks), "Eile: 20 Leben und Startenergie für alle") + check(g.players[0].max_energy == 3, "Startspieler hat 3 Energie") + var p := Profile.starter() + var coins := p.coins + var reward := p.record_battle(true, 3, mono, -1, [], 7, Challenge.LIST[0]) + check(reward == Challenge.LIST[0].reward and p.challenge_week == Challenge.week() and "wochenheld" in p.achievements, "Herausforderung belohnt") + reward = p.record_battle(true, 3, mono, -1, [], 9, Challenge.LIST[0]) + check(reward == Profile.REWARD_WIN[3], "nur einmal pro Woche") + check(p.records.streak == 2 and p.records.best_streak == 2 and p.records.fastest_win == 7, "Serie und schnellster Sieg") + p.record_battle(false, 1, mono, -1, [], 12) + check(p.records.streak == 0 and p.records.best_streak == 2, "Niederlage bricht die Serie") + for i in 5: + p.record_battle(true, 1, mono, -1, [], 10) + check("serie" in p.achievements, "Siegesserie-Erfolg") + var path := "user://test_statistik.csv" + check(p.export_stats(path) == "", "Statistik exportiert") + var text := FileAccess.get_file_as_string(path) + check(text.begins_with("karte;seltenheit;gespielt;partien;siege") and "beste_serie;5" in text, "CSV-Inhalt") + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + Profile.today_override = "" diff --git a/tests/test_runner.gd.uid b/tests/test_runner.gd.uid new file mode 100644 index 0000000..784a024 --- /dev/null +++ b/tests/test_runner.gd.uid @@ -0,0 +1 @@ +uid://cgjm40u037dnl diff --git a/tests/ui_test.gd b/tests/ui_test.gd new file mode 100644 index 0000000..48ddb3e --- /dev/null +++ b/tests/ui_test.gd @@ -0,0 +1,674 @@ +extends Node +## Oberflächentest mit echtem Fenster und Screenshots: +## DINGER_SHOTS=/pfad/zum/ordner godot --path . tests/ui_test.tscn + +const SAVE := "user://test_ui_spielstand.json" + +var main: Control +var shots := "" +var passed := 0 +var failed := 0 + + +func check(cond: bool, msg: String) -> void: + if cond: + passed += 1 + else: + failed += 1 + printerr("FEHLER: " + msg) + + +func frames(n: int = 2) -> void: + for i in n: + await get_tree().process_frame + + +func shot(name: String) -> void: + if shots == "": + return + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png(shots.path_join(name + ".png")) + + +func click_at(pos: Vector2) -> void: + var down := InputEventMouseButton.new() + down.position = pos + down.global_position = pos + down.button_index = MOUSE_BUTTON_LEFT + down.pressed = true + get_viewport().push_input(down) + var up: InputEventMouseButton = down.duplicate() + up.pressed = false + get_viewport().push_input(up) + await frames(2) + + +func click(node: Control) -> void: + check(node != null, "Klickziel vorhanden") + if node != null: + await click_at(node.get_global_rect().get_center()) + + +func right_click() -> void: + var down := InputEventMouseButton.new() + down.position = Vector2(5, 5) + down.global_position = down.position + down.button_index = MOUSE_BUTTON_RIGHT + down.pressed = true + get_viewport().push_input(down) + var up: InputEventMouseButton = down.duplicate() + up.pressed = false + get_viewport().push_input(up) + await frames(2) + + +func key(code: int) -> void: + var down := InputEventKey.new() + down.keycode = code + down.pressed = true + get_viewport().push_input(down) + var up := InputEventKey.new() + up.keycode = code + up.pressed = false + get_viewport().push_input(up) + await frames(2) + + +## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist. +func clear_mulligan(scr) -> void: + if scr.mulligan_box.visible: + await click(scr.b_keep) + + +func hover(node: Control) -> void: + var ev := InputEventMouseMotion.new() + ev.position = node.get_global_rect().get_center() + ev.global_position = ev.position + get_viewport().push_input(ev) + await frames(2) + + +func screen(): + return main.screen + + +## Jeder Abschnitt startet im Menü, auch wenn ein vorheriger abgebrochen ist. +func ensure_menu() -> void: + if main.net.connected: + main.net.leave() + if TranslationServer.get_locale().begins_with("en"): + main.set_language("de") + if not (screen() is MenuScreen): + main.show_screen(MenuScreen.new()) + await frames(2) + + +func find_button(text: String, root: Node = null) -> Button: + if root == null: + root = screen() + for c in root.get_children(): + if c is Button and (c.text == text or c.tr(c.text) == text) and c.is_visible_in_tree(): + return c + var found := find_button(text, c) + if found != null: + return found + return null + + +func card_views(root: Node) -> Array: + var out := [] + for c in root.get_children(): + if c is CardView: + out.append(c) + out.append_array(card_views(c)) + return out + + +## Wartet, bis keine Kachel mehr animiert wird (Angriffe verschieben Kacheln kurz). +func settle(scr) -> void: + for i in 40: + if not scr.tiles.values().any(func(t): return t.busy): + return + await frames(1) + + +func wait_for_human(scr) -> void: + var g: Game = scr.game + var wait := 0 + while g.current() != g.human and g.winner == "" and wait < 600: + await frames(1) + wait += 1 + await frames(1) + await clear_mulligan(scr) + + +func _ready() -> void: + Game.test_seed = 4242 # wiederholbare Partien und Booster + shots = OS.get_environment("DINGER_SHOTS") + var abs := ProjectSettings.globalize_path(SAVE) + for suffix in ["", ".bak", ".corrupt"]: + if FileAccess.file_exists(SAVE + suffix): + DirAccess.remove_absolute(abs + suffix) + main = load("res://scenes/main.tscn").instantiate() + main.save_path = SAVE + add_child(main) + await frames(3) + for t in [test_menu_und_regeln, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen, + test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_hotseat, + test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen]: + await ensure_menu() + await t.call() + for suffix in ["", ".bak", ".corrupt"]: + if FileAccess.file_exists(SAVE + suffix): + DirAccess.remove_absolute(abs + suffix) + print("%d Prüfungen bestanden, %d fehlgeschlagen" % [passed, failed]) + get_tree().quit(1 if failed > 0 else 0) + + +func test_menu_und_regeln() -> void: + check(screen() is MenuScreen, "Menü sichtbar") + await shot("01_menu") + await click(find_button("Regeln")) + check(screen() is RulesScreen, "Regeln geöffnet") + await shot("02_regeln") + await click(find_button("Zurück")) + check(screen() is MenuScreen, "zurück im Menü") + + +func test_booster() -> void: + await click(find_button("Booster öffnen")) + var scr = screen() + check(scr is BoosterScreen, "Boosterbildschirm") + var coins: int = main.profile.coins + var ach_before: int = main.profile.achievements.size() + await click(scr.b_buy) + var bonus := 0 + for a in Profile.ACHIEVEMENTS: + if a.id in main.profile.achievements.slice(ach_before): + bonus += a.reward + check(main.profile.coins == coins - Profile.BOOSTER_COST + bonus, "Booster bezahlt (plus Erfolgsmünzen %d)" % bonus) + check(scr.cards.size() == 5 and scr.revealed.is_empty(), "5 verdeckte Karten") + await shot("03_booster_zu") + var backs: Array = scr.slots.get_children().filter(func(c): return c is BoosterScreen.CardBack) + await click(backs[0]) + check(scr.revealed.size() == 1, "eine Karte aufgedeckt") + await click(scr.b_all) + check(scr.revealed.size() == 5, "alle aufgedeckt") + await shot("04_booster_offen") + check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert") + check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand") + await click(find_button("Zurück")) + check(screen() is MenuScreen, "zurück im Menü") + + +func test_deckbau() -> void: + await click(find_button("Deck bauen")) + var scr = screen() + check(scr is DeckScreen, "Deckbildschirm") + check(scr.deck.size() == 20, "Deck geladen") + await shot("05_deck") + await click(find_button("Leeren")) + check(scr.deck.is_empty(), "geleert") + var first_id: String = card_views(scr.grid)[0].card.id + var allowed: int = mini(main.profile.collection[first_id], Game.MAX_COPIES) + await click(card_views(scr.grid)[0]) + check(scr.deck == [first_id], "Karte hinzugefügt") + for i in allowed: + await click(card_views(scr.grid)[0]) + check(scr.deck.count(first_id) == allowed and "Kopien" in scr.msg_label.text, "nur besessene Kopien (%d)" % allowed) + await click(find_button("Speichern")) + check(main.profile.deck.size() == 20 and scr.msg_label.text != "Deck gespeichert", "zu kleines Deck nicht gespeichert") + await click(find_button("Wasser")) + await click(card_views(scr.grid)[0]) + await click(find_button("Erde")) + await click(card_views(scr.grid)[0]) + check("Elemente" in scr.msg_label.text, "drittes Element abgelehnt") + await click(find_button("Alle")) + # Suche, Sortierung, fehlende Karten + var all_count := card_views(scr.grid).size() + scr.search_box.text = "Wächter" + scr.search_box.text_changed.emit("Wächter") + await frames(1) + var found := card_views(scr.grid) + check(found.size() > 0 and found.size() < all_count, "Suche filtert") + check(found.all(func(v): return "Wächter" in v.card.text() or "Wächter" in v.card.name), "Suche trifft Kartentext") + scr.search_box.text = "" + scr.search_box.text_changed.emit("") + scr.sort_box.select(1) + scr.sort_box.item_selected.emit(1) + await frames(1) + var names: Array = card_views(scr.grid).map(func(v): return v.card.name) + var sorted_names := names.duplicate() + sorted_names.sort() + check(names == sorted_names, "Sortierung nach Name") + scr.missing_box.button_pressed = true + await frames(1) + check(card_views(scr.grid).size() == Cards.all().size(), "Fehlende Karten eingeblendet") + var missing = card_views(scr.grid).filter(func(v): return main.profile.collection.get(v.card.id, 0) == 0) + check(not missing.is_empty(), "es gibt fehlende Karten") + await click(missing[0]) + check("fehlt" in scr.msg_label.text, "fehlende Karte nicht ins Deck") + scr.missing_box.button_pressed = false + scr.sort_box.select(0) + scr.sort_box.item_selected.emit(0) + await frames(1) + var n: int = scr.deck.size() + await click(scr.deck_list.get_children()[0]) + check(scr.deck.size() == n - 1, "Zeile entfernt Karte") + await hover(card_views(scr.grid)[0]) + check(scr.preview.visible, "Vorschau bei Hover") + await shot("06_deck_bearbeitet") + # Slots: Deck 2 aus Deck 1 kopieren, speichern, aktiv setzen + await click(scr.slot_buttons[1]) + if scr.slot != 1 or not scr.deck.is_empty(): + print("DEBUG Slot: slot=", scr.slot, " deck=", scr.deck.size(), " rect=", scr.slot_buttons[1].get_global_rect(), + " hovered=", get_viewport().gui_get_hovered_control(), " decks1=", main.profile.decks[1].cards.size()) + check(scr.slot == 1 and scr.deck.is_empty(), "Slot 2 leer") + scr.deck = main.profile.decks[0].cards.duplicate() + scr.refresh() + await click(find_button("Speichern")) + check(main.profile.decks[1].cards.size() == 20 and "gespeichert" in scr.msg_label.text, "Slot 2 gespeichert") + await click(find_button("Aktiv setzen")) + check(main.profile.active_deck == 1 and main.profile.deck_name() == "Deck 2", "Slot 2 aktiv") + check(Profile.load_or_new(SAVE).active_deck == 1, "aktiver Slot gespeichert") + scr.name_box.text = "Testdeck" + scr.name_box.text_submitted.emit("Testdeck") + check(main.profile.deck_name(1) == "Testdeck" and Profile.load_or_new(SAVE).deck_name(1) == "Testdeck", "Deck umbenannt") + await click(scr.slot_buttons[2]) + check(scr.slot == 2 and scr.deck.is_empty(), "Slot 3 leer") + 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") + await shot("25_deck_slots") + await click(find_button("Zurück")) + check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen") + + +func target_view(scr, valid: Array) -> Control: + for v in scr.enemy_views: + if v.creature in valid: + return v + for v in scr.own_views: + if v.creature in valid: + return v + for p in scr.hero_panels: + if p in valid: + return scr.hero_panels[p] + return null + + +func human_turn(scr) -> void: + var g: Game = scr.game + await clear_mulligan(scr) + for i in 12: + var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0]) + if playable.is_empty(): + break + var card: Cards.Card = playable[0].card + var energy: int = g.human.energy + await click(playable[0]) + if scr.pending != null: + await click(target_view(scr, g.valid_targets(g.human, card.target_type()))) + check(g.human.energy == energy - card.cost, "Karte gespielt: " + card.name) + for i in 12: + var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack()) + if ready.is_empty() or g.winner != "": + break + var cr: Game.Creature = ready[0].creature + await settle(scr) + await hover(ready[0]) + var under = get_viewport().gui_get_hovered_control() + await click(ready[0]) + if scr.selected != cr: + print("DEBUG Auswahl fehlgeschlagen: rect=", ready[0].get_global_rect(), " scale=", ready[0].scale, + " busy=", ready[0].busy, " unter Maus=", under, " Meldung='", scr.msg_label.text, + "' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " pending=", scr.pending, " selected=", scr.selected) + check(scr.selected == cr, "Wesen gewählt") + var targets := [] + for o in g.opponents: + if o.alive() and g.can_attack(cr, o)[0]: + targets.append(o) + for c in o.board: + if g.can_attack(cr, c)[0]: + targets.append(c) + await settle(scr) + await click(target_view(scr, targets)) + check(scr.selected == null and cr.attacks_left < cr.attacks_per_turn(), "Angriff ausgeführt") + if g.winner == "": + await click(scr.b_end) + + +func start_battle(n_opp: int) -> void: + await click(find_button("Einzelspieler")) + check(screen() is OpponentSelectScreen, "Gegnerauswahl") + await click(find_button("%d Gegner" % n_opp)) + + +func test_gegnerauswahl() -> void: + await click(find_button("Einzelspieler")) + check(screen() is OpponentSelectScreen, "Gegnerauswahl geöffnet") + await shot("07_gegnerauswahl") + await click(find_button("Zurück")) + check(screen() is MenuScreen, "zurück im Menü") + for n in [1, 5]: + await start_battle(n) + var scr = screen() + check(scr is BattleScreen and scr.game.opponents.size() == n, "Kampf gegen %d gestartet" % n) + check(scr.game.players[0].max_energy == 1 and scr.game.opponents[0].life == Game.START_LIFE, "gleiche Regeln gegen %d" % n) + await shot("11_kampf_%d" % n) + await give_up() + + +func give_up() -> void: + var scr = screen() + await clear_mulligan(scr) + await click(find_button("Aufgeben")) + check(scr.confirm.visible, "Aufgeben fragt nach") + await click(find_button("Weiterspielen")) + check(not scr.confirm.visible and screen() == scr, "Weiterspielen bleibt im Kampf") + await click(find_button("Aufgeben")) + await click(find_button("Ja, aufgeben")) + check(screen() is MenuScreen, "Aufgeben führt ins Menü") + + +func test_einstellungen() -> void: + await click(find_button("Einstellungen")) + var scr = screen() + check(scr is SettingsScreen, "Einstellungen geöffnet") + scr.volume.value = 0.3 + scr.speed.value = 2.0 + scr.anim.button_pressed = false + await frames(1) + check(is_equal_approx(main.profile.settings.volume, 0.3) and is_equal_approx(main.profile.settings.ai_speed, 2.0), "Regler gespeichert") + check(main.profile.settings.animations == false, "Animationen abschaltbar") + check(is_equal_approx(Profile.load_or_new(SAVE).settings.volume, 0.3), "Einstellungen auf Platte") + scr.anim.button_pressed = true + await shot("13_einstellungen") + await click(find_button("Zurück")) + check(screen() is MenuScreen, "zurück im Menü") + + +func test_erfolge() -> void: + await click(find_button("Erfolge")) + check(screen() is AchievementsScreen, "Erfolge geöffnet") + await shot("14_erfolge") + await click(find_button("Zurück")) + check(screen() is MenuScreen, "zurück im Menü") + + +func test_werkstatt() -> void: + await click(find_button("Werkstatt")) + var scr = screen() + check(scr is WorkshopScreen, "Werkstatt geöffnet") + var p: Profile = main.profile + p.dust = 0 + var in_decks := {} + for d in p.decks: + for cid in d.cards: + in_decks[cid] = true + var free = scr.grid.views().filter(func(v): return p.collection.get(v.card.id, 0) > 0 and not in_decks.has(v.card.id)) + check(not free.is_empty(), "eine freie Karte zum Zerlegen") + var card: Cards.Card = free[0].card + await click(free[0]) + check(scr.selected == card, "Karte gewählt") + var before: int = p.collection[card.id] + await click(scr.b_dismantle) + check(p.collection[card.id] == before - 1 and p.dust == Profile.DUST_DISMANTLE[card.rarity], "zerlegt, Staub gutgeschrieben") + p.dust = 5000 + scr.search_box.text = "Phönix" + scr.search_box.text_changed.emit("Phönix") + await frames(1) + var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0] + await click(phoenix) + await click(scr.b_craft) + check(p.collection.get("ff_phoenix", 0) == 1 and p.dust == 5000 - Profile.DUST_CRAFT["legendär"], "Phönix hergestellt") + check(Profile.load_or_new(SAVE).collection.get("ff_phoenix", 0) == 1, "Werkstatt gespeichert") + await shot("20_werkstatt") + await click(find_button("Zurück")) + check(screen() is MenuScreen, "zurück im Menü") + + +func test_hotseat() -> void: + await click(find_button("Lokal zu mehreren")) + var scr = screen() + check(scr is HotseatScreen, "Hotseat-Auswahl") + await click(find_button("3 Spieler")) + await click(find_button("1 KI")) + check(scr.n_humans == 3 and scr.n_ai == 1, "3 Menschen, 1 KI") + await shot("17_hotseat") + await click(find_button("Start")) + var b = screen() + check(b is BattleScreen and b.is_hotseat() and b.game.players.size() == 4, "Hotseat-Kampf gestartet") + b.ai_delay = 0.0 + var guard := 0 + while not b.handover.visible and b.game.winner == "" and guard < 400: + if b.game.current() == b.perspective: + await clear_mulligan(b) + if b.game.current() == b.perspective: + await click(b.b_end) + await frames(1) + guard += 1 + check(b.handover.visible, "Übergabe erscheint") + var cur: Game.Player = b.game.current() + check(cur.is_human and cur != b.perspective, "anderer Mensch ist dran") + await shot("18_hotseat_uebergabe") + await click(find_button("Weiter")) + check(b.perspective == cur and not b.handover.visible, "Perspektive gewechselt") + check(b.hand_views.size() == cur.hand.size() and b.name_label.text == cur.name, "Hand und Name des neuen Spielers") + check(b.mulligan_box.visible, "neuer Spieler bekommt seine Mulligan-Maske") + await clear_mulligan(b) + check(b.hero_panels.size() == 3 and not b.hero_panels.has(cur), "drei andere am Tisch") + await shot("19_hotseat_kampf") + await give_up() + + +func test_sprache() -> void: + await click(find_button("Einstellungen")) + var scr = screen() + scr.language.select(1) + scr.language.item_selected.emit(1) + await frames(2) + 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(find_button("Back") != null, "Knöpfe übersetzt") + await shot("26_englisch") + await click(find_button("Back")) + check(find_button("Single player") != null and find_button("Settings") != null, "Menü auf Englisch") + await click(find_button("Settings")) + var scr2 = screen() + scr2.language.select(0) + scr2.language.item_selected.emit(0) + await frames(2) + check(find_button("Zurück") != null, "zurück auf Deutsch") + await click(find_button("Zurück")) + check(screen() is MenuScreen, "zurück im Menü") + + +func test_anleitung() -> void: + check(not main.profile.tutorial_done, "Anleitung noch offen") + await click(find_button("Anleitung")) + var scr = screen() + check(scr is BattleScreen and scr.tutorial, "Anleitung gestartet") + scr.ai_delay = 0.0 + var g: Game = scr.game + check(g.players[0] == g.human and g.opponents[0].name == "Fenn" and g.opponents[0].perks.is_empty(), "Fenn ohne Merkmal, Mensch beginnt") + await frames(2) + check(scr.mulligan_box.visible, "Mulligan-Maske im ersten Zug") + var views = scr.mulligan_layer.get_children() + await click(views[0]) + check(scr.mulligan_selected == [0], "Karte zum Tausch gewählt") + await shot("22_anleitung_mulligan") + await click(scr.b_swap) + 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(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar") + var idx := -1 + for i in g.human.hand.size(): + if g.human.hand[i].is_creature() and g.human.hand[i].cost == 1: + idx = i + break + if idx >= 0: + await key(KEY_1 + idx) + check(g.human.board.size() == 1, "Wesen per Taste gespielt") + check(scr.tutorial_step == 1, "Schritt 2 nach dem Ausspielen") + await shot("22_anleitung") + await key(KEY_SPACE) + check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") + await wait_for_human(scr) + check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug") + var step_before: int = scr.tutorial_step + await click(scr.b_tut_next) + check(scr.tutorial_step == step_before + 1, "Weiter-Knopf") + await key(KEY_ESCAPE) + check(scr.confirm.visible, "Esc öffnet Aufgeben") + await key(KEY_ESCAPE) + check(not scr.confirm.visible, "Esc schließt den Dialog") + await give_up() + check(main.profile.tutorial_done, "Anleitung als erledigt gespeichert") + + +func test_netzwerk() -> void: + await click(find_button("Netzwerkspiel")) + var scr = screen() + check(scr is LobbyScreen, "Lobby geöffnet") + await click(find_button("Spiel eröffnen")) + check(main.net.is_host and main.net.connected, "Spiel eröffnet") + var args := ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--", "--ip=127.0.0.1", "--name=Bot"] + var pid := OS.create_process(OS.get_executable_path(), args) + check(pid > 0, "Bot-Prozess gestartet") + var wait := 0 + while main.net.names.size() < 2 and wait < 900: + await frames(1) + wait += 1 + check(main.net.names.size() == 2, "Bot ist beigetreten (Pfad %s)" % main.net.get_path()) + await shot("23_lobby") + await click(find_button("Start")) + var b = screen() + check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host") + b.ai_delay = 0.0 + var rounds := 0 + while b.game != null and b.game.winner == "" and rounds < 4: + if b.game.current() == b.perspective: + await clear_mulligan(b) + if b.game.current() == b.perspective: + await click(b.b_end) + var w2 := 0 + while b.game.current() != b.perspective and b.game.winner == "" and w2 < 900: + await frames(1) + w2 += 1 + rounds += 1 + check(b.game.turn >= 3 and b.game.winner == "", "mehrere 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") + await shot("24_netzwerk_kampf") + OS.kill(pid) + var w3 := 0 + while b.game.winner == "" and w3 < 1500: + await frames(1) + w3 += 1 + if b.game.winner != "": + check(b.game.winner == "human" and b.game.log.any(func(l): return "verlassen" in l), "Trennung des Bots zählt als Aufgabe") + await frames(2) + check(b.overlay.visible, "Spielende angezeigt") + await click(find_button("Zurück zum Menü")) + else: + print("HINWEIS: Bot-Trennung nach 25 s noch nicht erkannt, Aufgeben stattdessen") + await give_up() + check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü") + + +func test_kampagne() -> void: + await click(find_button("Kampagne")) + var scr = screen() + check(scr is CampaignScreen, "Kampagne geöffnet") + await shot("15_kampagne") + check(find_button("Gesperrt") != null and find_button("Kämpfen") != null, "Stufen gesperrt und offen") + await click(find_button("Kämpfen")) + var battle = screen() + check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet") + check(battle.game.opponents.size() == 1 and battle.game.opponents[0].name == "Fenn", "Fenn ist der Gegner") + await shot("16_kampagne_kampf") + await give_up() + + +func test_kampf(n_opp: int, shot_turn: int) -> void: + await start_battle(n_opp) + var scr = screen() + check(scr is BattleScreen, "Kampfbildschirm") + scr.ai_delay = 0.0 + var g: Game = scr.game + check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost") + await wait_for_human(scr) + check(g.current() == g.human and g.human.mulligan_done, "Spieler ist dran, Mulligan erledigt") + check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar") + await shot("07_kampf_start") + var coins: int = main.profile.coins + var guard := 0 + while g.winner == "" and guard < 60: + guard += 1 + var turn_before := g.turn + await human_turn(scr) + if g.winner != "": + break + check(g.current() != g.human or g.turn > turn_before, "Zug beendet") + var wait := 0 + while g.current() != g.human and g.winner == "" and wait < 600: + await frames(1) + wait += 1 + if guard == shot_turn: + await shot("08_kampf_zug%d" % (shot_turn + 1)) + check(g.winner != "", "Spiel beendet") + await frames(3) + await shot("09_kampf_ende") + check(scr.reward > 0 and main.profile.coins >= coins + scr.reward, "Belohnung gutgeschrieben") + check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Belohnung gespeichert") + await click(find_button("Zurück zum Menü")) + check(screen() is MenuScreen, "zurück im Menü") + + +func test_kampf_abbrechen() -> void: + await start_battle(5) + var scr = screen() + scr.ai_delay = 0.0 + var g: Game = scr.game + await wait_for_human(scr) + check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner") + # Falle legen und Kombo-Hinweis prüfen + g.human.hand.append(Cards.by_id("f_falle")) + g.human.energy = 10 + var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000) + g.human.board.append(wolf) + g.human.hand.append(Cards.by_id("w_reiter")) + scr.refresh() + var trap_view = scr.hand_views.filter(func(v): return v.card.id == "f_falle")[0] + await click(trap_view) + 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] + await hover(reiter_view) + var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel) + check(hint_boxes.size() == 1 and "Feldkombo Dampf" in hint_boxes[0].text, "Kombo-Hinweis in der Vorschau") + 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]) + if not spells.is_empty(): + var card: Cards.Card = spells[0].card + await click(spells[0]) + check(scr.pending == card, "Ziel wird erwartet") + await right_click() + check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab") + await hover(scr.hand_views[0]) + check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover") + await click(find_button("Lupe")) + check(scr.zoom_player == scr.others()[0], "Lupe öffnet die erste Spalte") + await shot("12_lupe") + await click(find_button("Schließen")) + check(scr.zoom_player == null, "Lupe geschlossen") + # Runden laufen lassen, damit die Gegner ihre Züge machen + for round in 4: + await click(scr.b_end) + var wait := 0 + while g.current() != g.human and g.winner == "" and wait < 600: + await frames(1) + wait += 1 + var ai_attacked: bool = g.log.any(func(l): return l.begins_with("Gegner") and " spielt " in l) \ + and g.log.any(func(l): return " greift " in l) + check(ai_attacked, "Gegner spielen Karten und greifen an") + check(scr.tiles.size() == g.players.reduce(func(acc, p): return acc + p.board.size(), 0), "eine Kachel je Wesen") + await shot("10_kampf_fuenf") diff --git a/tests/ui_test.gd.uid b/tests/ui_test.gd.uid new file mode 100644 index 0000000..2c930e7 --- /dev/null +++ b/tests/ui_test.gd.uid @@ -0,0 +1 @@ +uid://ci5hglrkyevqi diff --git a/tests/ui_test.tscn b/tests/ui_test.tscn new file mode 100644 index 0000000..f32e8c4 --- /dev/null +++ b/tests/ui_test.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/ui_test.gd" id="1"] + +[node name="UITest" type="Node"] +script = ExtResource("1") diff --git a/translations/ui.csv b/translations/ui.csv new file mode 100644 index 0000000..87d4165 --- /dev/null +++ b/translations/ui.csv @@ -0,0 +1,59 @@ +keys,en +Dinger,Dinger +Ein Kartenspiel: jeder gegen jeden,A card game: free-for-all +Einzelspieler,Single player +Kampagne,Campaign +Lokal zu mehreren,Local multiplayer +Netzwerkspiel,Network game +Booster öffnen,Open boosters +Deck bauen,Build deck +Werkstatt,Workshop +Erfolge,Achievements +Regeln,Rules +Einstellungen,Settings +Anleitung,Tutorial +Beenden,Quit +Zurück,Back +Weiter,Continue +Start,Start +Speichern,Save +Leeren,Clear +Suche,Search +Fehlende,Missing +Alle,All +Aktiv setzen,Set active +Auto-Ausfüllen,Auto-fill +Umbenennen,Rename +Zug beenden,End turn +Aufgeben,Surrender +Weiterspielen,Keep playing +"Ja, aufgeben","Yes, surrender" +Wirklich aufgeben?,Really surrender? +Der Kampf endet ohne Münzen.,The battle ends without coins. +Zurück zum Menü,Back to menu +Lupe,Zoom +Schließen,Close +Held als Ziel,Target hero +Verlauf,Log +Karte überfahren für Details,Hover a card for details +Lautstärke,Volume +Musik,Music +KI-Tempo,AI speed +Animationen,Animations +Sprache,Language +Booster,Boosters +Booster kaufen (100 Münzen),Buy booster (100 coins) +Alle aufdecken,Reveal all +Zerlegen,Dismantle +Herstellen,Craft +Kämpfen,Fight +Wiederholen,Replay +Gesperrt,Locked +Spiel eröffnen,Host game +Beitreten,Join +Behalten,Keep +Tauschen,Swap +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. +Fallen,Traps +Du bist dran,Your turn diff --git a/translations/ui.csv.import b/translations/ui.csv.import new file mode 100644 index 0000000..a784d29 --- /dev/null +++ b/translations/ui.csv.import @@ -0,0 +1,19 @@ +[remap] + +importer="csv_translation" +type="Translation" +uid="uid://bcfsui5t8peik" + +[deps] + +files=["res://translations/ui.en.translation"] + +source_file="res://translations/ui.csv" +dest_files=["res://translations/ui.en.translation"] + +[params] + +compress=1 +delimiter=0 +unescape_keys=false +unescape_translations=true diff --git a/translations/ui.en.translation b/translations/ui.en.translation new file mode 100644 index 0000000..39c379a Binary files /dev/null and b/translations/ui.en.translation differ