Compare commits

...
27 Commits
Author SHA1 Message Date
team3 1d563b9eec update
Tests / headless (push) Waiting to run
Tests / fenster-schnell (push) Waiting to run
Tests / fenster-voll (push) Skipped
Tests / balance (push) Skipped
2026-10-14 17:35:09 +02:00
team3 f2c609a08b update
Tests / headless (push) Canceled after 0s
Tests / fenster-schnell (push) Canceled after 0s
Tests / fenster-voll (push) Canceled after 0s
Tests / balance (push) Canceled after 0s
2026-10-13 15:39:55 +02:00
team3 b91f475a6e update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-10-10 16:34:52 +02:00
team3 a11cee2e0a update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-10-10 14:30:29 +02:00
team3 c245c7af87 update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-10-08 14:28:52 +02:00
team3 5bc8569717 update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-10-06 18:16:17 +02:00
Team3 0a612f91b3 update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-10-04 22:40:18 +02:00
team3 5f9442497a update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-10-03 01:17:27 +02:00
Team3 24a03ed1ee update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-10-02 19:57:02 +02:00
Team3 f0a8f18de8 update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-09-28 19:44:30 +02:00
Team3 dfcd3cb713 update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-09-22 13:23:02 +02:00
Team3 8944fdd017 update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-09-19 13:37:12 +02:00
Team3andClaude Opus 5 89db101137 Erfolge entfernt, der Bildschirm zeigt nur noch Statistik
- Die dreizehn Erfolge samt Belohnungen, Meldungen und Spielstandfeld sind weg
- Der bisherige Erfolgs-Bildschirm heißt Statistik und zeigt Kämpfe, Tagesaufgabe,
  Bestenliste, beste Karten und den CSV-Export

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-17 21:37:58 +02:00
Team3andClaude Opus 5 43073351b0 Wöchentliche Herausforderung und die Netzwerk-Hinweise entfernt
- Challenge samt Bildschirm, Menüpunkt, Erfolg "Wochenheld" und Spielstandfeld entfernt;
  record_battle verliert den challenge-Parameter, die Bestenliste bleibt
- Lobby: Erklärzeile unter dem Titel weg, "KI-Gegner (nur Host)" heißt jetzt "KI-Gegner"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-17 21:13:08 +02:00
Team3andClaude Opus 5 0f2dc22ef6 Gegnerauswahl und Kampagne aufgeräumt, Hotseat entfernt
- Einzelspieler: Schwierigkeit, darunter nur die zwei Deckstärken, Gegnerzahl als
  Schieberegler, Starten und Zurück; kein Würfeln, die Gegnerdecks entstehen beim Start
- Kampagne: nur noch Name, Elementpunkte und Knopf, mit Innenabstand in den Zeilen
- Hotseat (Lokal zu mehreren) samt Übergabe-Vorhang, Auswahlbildschirm und Tests entfernt
- Netzwerktests laufen nur mit DINGER_NETZ=1, weil die Bot-Prozesse Minuten brauchen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-17 21:05:09 +02:00
Team3 5c1084fd79 update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-09-17 20:13:42 +02:00
team3 f7d06b1e7c update
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
2026-09-17 12:14:27 +02:00
Team3andClaude Fable 5.1 7ed58ed69a Balance in drei Messrunden: Wasser und Feuer zäher, Lava-Karten schwächer, Archetypen neu gemessen
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled
Je 200 Partien pro Archetyp; Spanne 40–64 % statt 36–78 %.
Feuer+Erde bleibt mit 64 % die Spitze, Feuer mit 40 % das Schlusslicht.

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

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

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

No files matched your search

+45 -4
View File
@@ -1,6 +1,11 @@
name: Tests
on: [push, pull_request]
on:
push:
pull_request:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
headless:
@@ -13,10 +18,11 @@ jobs:
unzip -q godot.zip
mv Godot_v4.7.2-stable_linux.x86_64 godot
chmod +x godot
- name: Tests
- name: Statische Prüfung und Tests
run: ./tests/run_headless.sh ./godot
fenster:
fenster-schnell:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -29,7 +35,42 @@ jobs:
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)
- name: Fenster-Test (schnell)
run: DINGER_FAST=1 xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
- name: Ergebnis
run: grep -q " 0 fehlgeschlagen" fenster.log
fenster-voll:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Godot und Xvfb laden
run: |
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
unzip -q godot.zip
mv Godot_v4.7.2-stable_linux.x86_64 godot
chmod +x godot
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
- name: Import
run: ./godot --headless --path . --import || true
- name: Fenster-Test (voll)
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
balance:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Godot laden
run: |
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
unzip -q godot.zip
mv Godot_v4.7.2-stable_linux.x86_64 godot
chmod +x godot
- name: Import
run: ./godot --headless --path . --import || true
- name: Balance messen
run: ./godot --headless --path . -s tests/simulate.gd 2>&1 | grep -E "Siege|Dauer"
+42 -22
View File
@@ -1,43 +1,62 @@
# Dinger
Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle mit denselben Regeln.
Kartenspiel in 2D (Godot 4.7): ein Duell gegen einen KI-Magier, beide mit denselben Regeln.
## Start
## Starten
Projektordner in Godot öffnen und auf „Play“ drücken, oder im Terminal:
Projektordner in Godot öffnen und auf „Play" drücken, oder im Terminal:
```
godot --path .
./start.sh # Linux/macOS (start.bat unter Windows); Godot 4.7 muss als `godot` im Pfad liegen
godot --path . # wenn das Projekt schon einmal importiert wurde
```
Frisch geklont fehlt das Verzeichnis `.godot` mit Godots Import-Cache. Die Startskripte holen den Import
beim ersten Start nach; alternativ das Projekt einmal im Godot-Editor öffnen oder
`godot --headless --path . --import` ausführen.
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)
- 550 Karten in `data/karten.json`: je Element und neutral alle Werte von 0/1 bis 10/10, die ID verrät sie (`f_3_2` = Feuer 3/2, `n_…` neutral), Namen aus Eigenschaft und Wesen; Kombos sind die Namen verschmolzener Wesen. Neutrale Karten sind nie im Vorteil oder Nachteil, bilden kein Paar, haben aber ihren eigenen Zähler (+1/+1 je drei); verschmolzen mit einem Element (Feuerritter …) bleibt dessen Vorteil, die Schwäche fällt weg
- Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts)
- Mana: 1 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen und Legen
- Jeder spielt mit seinem eigenen Stapel aus 30 bis 60 Karten (Menü „Deck“: eigene Karten rein oder raus, mit Element- und Manafilter, Manakurve und Elementanteilen; „Auffüllen“ nimmt die wertvollsten des gewählten Filters, bei gleichem Wert die teuersten; „Stärke“ ist der mittlere Kartenwert: ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100, also 100 für Grundkarten bis 500 für Ultimate Rare). Zu Beginn die obersten fünf Karten des gemischten Stapels. Ziehen bringt eine Karte, die erste im Zug ist frei, dann 1, 2, 3 … Mana; ist der Stapel leer, kostet jeder Zug 1, 2, 3 … Leben (Erschöpfung); eine Handkarte abwerfen bringt 1 Mana (Rechtsklick)
- Eigenschaften gibt es nur über Elementpaare (siehe Elementboni): Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug). Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink; verletzte Wesen heilen nicht von selbst
- Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf
- Man startet mit allen 110 neutralen Karten in der Sammlung, der Stapel sind 30 davon nach Manakurve (2 zu 0 Mana, 5 zu 1, 6 zu 2, 6 zu 3, 5 zu 4, 3 zu 5, 2 zu 6, 1 zu 7); die Elemente schaltet die Kampagne frei: mit jedem offenen Kapitel gibt es den Booster seines Elements, die Werkstatt bringt den Rest; Sammlung, Deckfilter und Booster zeigen nur Elemente offener Kapitel. Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare) und ist je Stufe +1/+1 stärker, bei gleichen Kosten; die Stufe zeigt sich auch in der Optik (Glanz, Holo, Relief, Funkeln). Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern
- Booster: neutral 100 Münzen, je Element 200 (ab seinem Kapitel), einzeln oder fünf und zehn auf einmal; fünf Karten des Elements mit gewürfelter Stufe (45/25/15/10/5 %, mindestens eine Rare); neue kommen in Sammlung und Stapel, eine höhere Stufe ersetzt die alte Version (die wird zu Staub), der Rest wird zu Staub (50/100/200/500/1000). Werkstatt in der Sammlung: Aufwerten kostet 100/200/500/1000 Staub, Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Elementpaare, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); verdeckte zählen nicht. Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte. Die Liste links neben dem jeweiligen Brett zeigt nur Elemente mit offenen Wesen: zwei Symbole, gefüllt je offenem eigenen Wesen, und die Zahl der Wesen
- Elementvorteil beim Kampf (Wasser > Feuer > Luft > Erde > Wasser): wer im Vorteil ist, macht doppelten Schaden; wer im Nachteil ist, halben (aufgerundet); beim Angriff wie beim Konter, gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht, mit Neutral verschmolzen ist es nie im Nachteil; das Boss-Merkmal „kein Elementnachteil“ hebt den Nachteil auf. Ist ein Angreifer gewählt, zeigen die Gegnerkarten ihren Konter gegen ihn; über einem Ziel zeigt der Angreifer seinen Angriff mit Vorteil, und wer stirbt, trägt die Marke „stirbt“
- Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar, per Doppelklick auf das eigene Wesen direkt. Nichts schlägt durch. Den Zug beendet die Leertaste oder der Rechtsklick ins Leere; Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden)
- Verschmelzen: eine Handkarte oder ein eigenes Wesen auf eine andere ziehen und loslassen, auch gemischt; das Ergebnis erscheint über dem Ziel, darunter „Verschmelzen“ oder „Abbrechen“ (Gamepad: Y, „Verschmelzen mit …“). Es kostet nichts; Wesen verlassen das Brett, die neue Karte kommt unverletzt auf die Hand und wird wie jede andere ausgespielt. Je Wert die aufgerundete Hälfte der Grundwertsumme plus eins, Kosten daraus wie bei jeder Karte, dann die höhere Stufe der Eltern mit ihrem +1/+1; Elemente und Eigenschaften beider; drei oder mehr Elemente ergeben eine Chimäre
- Duell mit ausgeloster Zugreihenfolge, beide beginnen mit 1 Mana; keine Grenze für Handkarten, das Feld fasst 5 Wesen
- Münzen nach jedem Kampf: 5 je eigenem Zug, 5 je ausgespielter Karte, 5 je Zug unter 15 Sekunden; ein Sieg verdoppelt alles; das Ergebnisbild schlüsselt es auf. In der Kampagne ohne Schwierigkeitsfaktor, der erste Sieg einer Stufe bringt ihre Belohnung obendrauf
- Erfahrung und Skilltree (Menü „Skilltree“): jede verdiente Münze zählt als Erfahrung; Level n braucht 50·n·(n+1) EP (100, 300, 600 …), jedes Level bringt einen Skillpunkt, Münzen kaufen keine Punkte. Drei Äste: Sammeln (Packglück: Common −8, die höheren Stufen +3/+2/+2/+1 Prozentpunkte je Stufe, bis 3; Staubsammler: +25 % Staub je Stufe, bis 3), Wirtschaft (Münzsegen: +10 % Münzen je Stufe, bis 3; Händler: Booster 10 % billiger je Stufe, bis 2), Kampf (Freies Ziehen: auch das zweite Ziehen je Zug gratis; Starker Start: 3 statt 1 Mana im ersten Zug; beide wirken wie die gleichnamigen Merkmale). Jede Skillkarte zeigt die Wirkung jetzt und auf der nächsten Stufe in Zahlen
- Cheats (kleiner Knopf unten rechts im Hauptmenü): Münzen, Staub, Level, Skills, Kapitel und Sammlung direkt setzen, zum Ausprobieren
- Schwierigkeit Leicht/Normal/Schwer (Einzelspieler): der Gegner spielt 30 Karten eines Elements aus den offenen Kapiteln nach Manakurve, seine Karten eine Stufe unter deiner Deckstärke, auf ihr oder eine darüber (Stärke 100 → Common … 500 → Ultimate Rare); Leicht mit einfacher KI, Schwer mit einem zufälligen Merkmal; 30 Leben wie du; Münzen 1/1,5/2-fach
- KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread
- Kampagne: fünf Kapitel mit je zehn Magiern, der zehnte ist der Boss; Kapitel 1 spielt neutrale Karten, Kapitel 2 bis 5 Feuer, Wasser, Luft, Erde; jeder Gegner spielt 30 Karten seines Elements nach Manakurve, der Kostendeckel steigt je Stufe von 3 auf 10 (Boss), in Kapitel 1 und 2 Common, dann je Kapitel eine Stufe höher (Kapitel 5 Ultra Rare); ein Merkmal je Gegner, ab Kapitel 3 zwei, in Kapitel 5 drei; eigenes Merkmal nach Kapitel 1
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
- 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
- Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`)
- Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen; Ton und Musik sind standardmäßig aus
## Tests
`tests/run_headless.sh` führt Import und Headless-Prüfungen aus (auch in `.github/workflows/tests.yml`).
`tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`:
Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft).
Beide Suiten brechen beim ersten Fehler ab. Der Fenster-Test spielt eine kurze Partie (einfache KI, Gegner mit 1 Leben) und läuft in wenigen Sekunden.
```
godot --headless --path . --import # einmalig: Ressourcen importieren
godot --headless --path . --import # einmalig nach dem Klonen: 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
DINGER_ONLY=test_sammlung godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Stapel, Musik
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf
```
## Export
@@ -52,11 +71,12 @@ 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/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementboni, Kampf, Verschmelzen
- `scripts/deckgen.gd` – Stapel: zufällige für Gegner, Standardstapel aus der Sammlung
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen
- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Stapel, Booster, Werkstatt, 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)
- `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `element_view.gd` die Elementpaare, `battle_layout.gd` die Aufteilung des Kampfes
- `translations/ui.csv` – englische Oberflächentexte
- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs
+26 -16
View File
@@ -1,24 +1,34 @@
# Dinger
# Cards
##
## 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
- Duell: Spieler gegen einen KI-Gegner, beide mit gleichen Regeln
- Einzelspieler-Knopf führt zur Wahl der Schwierigkeit: der Gegner spielt ein Element aus den offenen Kapiteln, Karten eine Stufe unter deiner Deckstärke / auf ihr / eine darüber; Leicht einfache KI, Schwer ein zufälliges Merkmal; Münzen ×1/×1,5/×2
- Münzen nach jedem Kampf: 5 je eigenem Zug, 5 je ausgespielter Karte, 5 je Zug unter 15 s; ein Sieg verdoppelt; Kampagne ohne Faktor, der erste Sieg einer Stufe bringt ihre Belohnung obendrauf
- Erfahrung = verdiente Münzen; Level n braucht 50·n·(n+1) EP, je Level ein Skillpunkt (nicht kaufbar); Skilltree mit drei Ästen: Sammeln (Packglück, Staubsammler), Wirtschaft (Münzsegen, Händler), Kampf (Freies Ziehen, Starker Start als Merkmale)
- Kampagne: fünf Kapitel mit je zehn Magiern, der zehnte ist der Boss; Kapitel 1 neutral, dann Feuer, Wasser, Luft, Erde; Gegner spielen 30 Karten ihres Elements nach Manakurve, Kostendeckel je Stufe 3 bis 10, Kapitel 1 und 2 Common, dann je Kapitel eine Stufe höher (Kapitel 5 Ultra Rare); ein offenes Kapitel schaltet den Booster seines Elements frei (neutral 100 Münzen, Element 200)
## Sammeln
- Booster öffnen
- Karten haben Seltenheiten
- Aus der Sammlung werden Decks gebaut (mehrere Slots)
- Überschüssige Karten lassen sich zerlegen und neue herstellen
- Man startet mit allen 110 neutralen Karten in der Sammlung, 30 davon nach Manakurve im Stapel; die Elemente kommen über die Kampagne und ihre Booster; alle Karten sammeln ist das lange Ziel
- Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare); je Stufe +1/+1 bei gleichen Kosten, dazu Optik (Glanz, Holo, Relief, Funkeln); Fusionen tragen die höhere Stufe der Eltern
- Booster öffnen (Chancen 45/25/15/10/5 %, mindestens eine Rare): neue Karten kommen in Sammlung und Stapel, von jeder Karte zählt die seltenste Version, die alte wird zu Staub, der Rest wird zu Staub (50/100/200/500/1000)
- Werkstatt: Staub wertet gezielt auf (100/200/500/1000), Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Jeder hat sein Deck: 30 bis 60 eigene Karten, im Menü „Deck“ zusammengestellt (Auffüllen folgt dem Filter, wertvollste zuerst; Stärke = mittlerer Kartenwert ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100: 100 bis 500); zu Beginn die obersten fünf Karten daraus; Ziehen bringt eine Karte, die erste im Zug frei, dann 1, 2, 3 … Mana; leerer Stapel: jeder Zug kostet 1, 2, 3 … Leben
- Mana für Ziehen und Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet, höchstens 10; 0/2 kostet 0), Abwerfen bringt 1; 1 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; keine Grenze für Handkarten
- Karten haben nur Angriff und Leben; Eigenschaften kommen allein über Elementpaare: Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug)
- Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink (auch im ersten Zug); verletzte Wesen heilen nicht von selbst
- Wesen lassen sich verdeckt legen (mittlere Maustaste oder Kartenmenü); aufdecken ist frei, frühestens im nächsten Zug, dann greift es sofort an; ein Angriff deckt auf
- Leben erst ohne Wesen angreifbar; Wesen mit Spott zuerst; nichts schlägt durch
- Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen
## 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
- Jede Karte gehört zu genau einem Element oder ist neutral; je Element und neutral gibt es alle Werte von 0/1 bis 10/10 (550 Karten). Neutral: nie im Vorteil oder Nachteil, kein Paar; verschmolzen nimmt das Wesen im schlechten Matchup die neutrale Seite
- Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan)
- Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag)
- Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte; verdeckte zählen nicht fürs Paar
- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (wer im Vorteil ist, macht doppelten Schaden, wer im Nachteil ist, halben, aufgerundet; beim Angriff wie beim Konter; gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht; Boss-Merkmal „kein Elementnachteil“)
- Verschmelzen: eine Handkarte oder ein eigenes Wesen auf eine andere ziehen und loslassen, auch gemischt; das Ergebnis erscheint über dem Ziel und wird bestätigt oder abgebrochen (Gamepad: über das Kartenmenü); kostet kein Mana; Wesen verlassen das Brett, die neue Karte kommt unverletzt auf die Hand; je Wert die aufgerundete Hälfte der Grundwertsumme plus eins, Kosten daraus, dann die höhere Stufe der Eltern (+1/+1 je Stufe); Elemente und Eigenschaften beider
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus
+5362 -1860
View File
File diff suppressed because it is too large. Load diff
+155
View File
@@ -0,0 +1,155 @@
# Design von Dinger
Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entwurf.
## Woran wir uns orientieren
- **Hearthstone** – Kosten und Werte springen aus dem Kartenrahmen (Kristall oben links, Angriff
und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt
kurz. Auf dem Brett: eigene Seite unten, Gegner oben, links Elementzähler und Meldungen, rechts Stapel und Kästen beider Seiten, Wesen
als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt.
- **Teamfight Tactics** – Synergien durch gleiche Elemente: zwei eigene geben den Effekt des
Elements, drei auf dem Tisch den Wertebonus; die Elementleiste zeigt den Stand.
- **Yu-Gi-Oh** – verdeckt gelegte Monster, Angriffe erst auf Monster, Fusion durch Übereinander-
legen (hier: ziehen und halten).
- **Pokémon TCG** – Seltenheit ist Optik: Folienglanz, Funkeln, Schillern. Werte und Symbole
statt Sätze.
- **Allgemeine Regeln für Kartenspiel-Oberflächen** (GDKeys, Medium): klar getrennte Zonen,
Lesefluss von links (Vergangenheit) über die Mitte (Spiel) nach rechts (Zukunft), die eigene
Seite größer als die der Gegner, Zahlen groß, Text klein, Symbole statt Wörter, und Rückmeldung
für jede Aktion (Bewegung, Ton, kurzes Zittern).
## Karte
- Oben links der Kostenkristall, daneben der Name (immer eine Zeile, nie abgeschnitten: die
Schrift schrumpft etwas, dann wird der Text schmaler gezeichnet; das Feld ist so hoch wie der
Kristall), in der Mitte das Bild als Elementsymbol im dunklen Kreis, so groß wie
der Platz erlaubt, unten Angriff (gelb, links) und Leben (blau, rechts) als Kreise. Karten
haben keinen Text: nur Element, Angriff, Leben. Auf dem Feld stehen unter dem Namen links die
Eigenschaften als Symbole (Ausrufezeichen = Spott, Doppelpfeil = Flink, Kreuz = Regeneration,
gekreuzte Klingen = Doppelschlag, in der Farbe des Elements, das sie gibt).
- Die Hand sind alle Karten in Feldgröße, ab sechs überlappend; Details kommen aus der schwebenden Vorschau.
- Seltenheit nach Yu-Gi-Oh: Common matt; Rare silbern glänzender Name; Super Rare Holo-Bild
(wandernder Regenbogenschimmer im Bildkreis); Ultra Rare golden glänzender Name plus Holo-Bild;
Ultimate Rare goldener Name, goldenes Bild, glänzende Kosten und Werte, Schimmer über die ganze
Karte, Funkeln, schillernder Rahmen. Der Rahmen trägt ab Rare die Stufenfarbe.
Fusionen tragen die höhere Stufe ihrer Eltern.
- Mehrere Elemente: der Hintergrund trägt je Element einen senkrechten Streifen; das Bild kommt
vom stärkeren Elternteil (Angriff plus Leben).
- Verdeckte fremde Wesen sind ein Kartenrücken mit „?“; eigene verdeckte zeigen die Karte mit
dem Vermerk „verdeckt“.
## Kampfbildschirm (1280×800)
Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
- **Hand** als alle Karten in Feldgröße unten, in einem leichten Bogen, die des Gegners
gleich groß als Rückseiten oben; der
eigener Stapel rechts unten als Kartenstoß mit den Kosten des nächsten Ziehens (das erste im Zug
frei), darüber der eigene Kasten; der Stapel des Gegners rechts oben, sein Kasten darunter.
- **Verschmelzen** per Ziehen: eine Handkarte oder ein eigenes Wesen auf eine andere ziehen
(golden gerahmt) und loslassen, auch gemischt. Die neue Handkarte schwebt über dem Ziel, beide
Ausgangskarten bleiben sichtbar; darunter „Verschmelzen“ und „Abbrechen“. Es kostet nichts;
Wesen verlassen das Brett, die neue Karte wird wie jede andere ausgespielt. Mit Gamepad über
das Y-Menü („Verschmelzen mit …“, dann A auf dem Partner).
- **Eigenes Brett** mit fünf sichtbaren Plätzen; links daneben die eigene Leiste (Leben, Mana,
Einkommen, Zug), rechts „Rückgängig“, nur wenn es gerade möglich ist.
- **Elementpaare** je Spieler als Liste links neben seinem Brett, nur Elemente mit offenen Wesen.
Je Element zwei Symbole in seiner Form, gefüllt je offenem eigenen Wesen (beide voll: Sondereffekt),
daneben die Zahl der Wesen.
- **Mittlere Maustaste** auf eine Handkarte legt sie verdeckt. **Kartenmenü** per Rechtsklick
oder Y auf Hand- und Brettkarten: verdeckt legen, aufdecken, Spott, Flink, Regeneration und
Doppelschlag kaufen; per Y zusätzlich verschmelzen.
- **Gegner** oben in der Mitte mit seinem Brett in voller Größe. Das Spiel ist ein Duell wie
Hearthstone: mehr als zwei Bretter passen nicht lesbar auf einen Bildschirm, und ein Gegner
macht Ziele, Texte und Balance eindeutig.
- **Vorschau** steht fest rechts auf halber Höhe, darunter die Zusatzzeile, mit Resonanz, Feld
und Feldkombo darunter.
- **Optionen** per Esc als Überlagerung: Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden; der Verlauf
öffnet darüber. Zug beenden per Leertaste oder Rechtsklick ins Leere, oben rechts nur Rückgängig.
- Beim Angreifen zeigt das Ziel seinen Konter mit Elementvorteil (doppelt oder halb), bei
Verdeckten nicht; über dem Ziel zeigt der Angreifer seinen Angriff mit Vorteil.
## Bewegung und Ton
- Zugbeginn: Band „Dein Zug“ quer über die Mitte, Ton.
- Ausspielen: die Kachel fliegt von der Hand auf ihren Platz und federt ein.
- Angriff: Ausfallschritt, roter Rahmen am Ziel, kurzes Zittern des Bretts, Schadenszahl.
- Aufdecken: „Aufgedeckt!“ am Wesen; Regeneration am Zugende steigt grün auf.
- Karten heben sich beim Zeigen an, Knöpfe federn beim Drücken, Bildschirme blenden ein.
- Booster: Karten drehen sich auf, Stufen ab Rare blitzen in ihrer Farbe auf.
- Alles hängt an der Einstellung „Animationen“.
## Menüs
- Hauptmenü: Titel, Einzelspieler, Kampagne und Deck links, darunter Booster, Sammlung, Skilltree,
in der Zeile darunter Einstellungen und Beenden, unten rechts der kleine Cheats-Knopf; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und
Staub oben rechts.
- Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter.
- Kampagne als Pfad mit zehn Stationen je Kapitel (fünf Kapitel: Neutral, Feuer, Wasser, Luft,
Erde, das Element steht über dem Pfad), die nächste pulsiert; darunter die gewählte Stufe mit
Gegner, Leben, Belohnung und Merkmalen. Die Gegner werden über Leben, Merkmale und die
Seltenheit ihrer Karten stärker, nicht über Begleiter.
- Booster: je offenem Kapitel eine Pack-Kachel (Symbol, Name, Preis, ×1 ×5 ×10), mittig; gesperrte
gibt es nicht zu sehen. Die Karten fliegen aus dem Pack ins Raster (ein Pack fünf große, mehrere
zehn kleine je Reihe, beste Stufe zuerst) und liegen verdeckt; Aufdecken gestaffelt, seltene
blitzen auf, ab Ultra Rare mit Pause. Danach das Fazit „n neu · n aufgewertet · +n Staub“, das
gekaufte Pack pulsiert.
- Skilltree: oben Level, Erfahrung bis zum nächsten Level und freie Punkte, darunter drei Spalten
(Sammeln, Wirtschaft, Kampf) mit Skillkarten: Name, Wirkung, „Jetzt“ und „Nächste Stufe“ in
Zahlen, Stufe x / y und „Lernen“.
- Cheats (kleiner Knopf unten rechts im Hauptmenü): Stand oben, darunter Knopfreihen für Währung,
Erfahrung, Kampagne und Sammlung; jeder Knopf speichert sofort.
- Sammlung: sechs Spalten, nur Elemente offener Kapitel, eigene Karten zuerst (höchste Stufen vorn), fehlende abgedunkelt, rechts
die große Vorschau mit Aufwerten, Zerlegen oder Herstellen. Deck: gleiches Raster, Filter nach Element (Symbolknöpfe)
und Mana 0 bis 10, Stapelkarten zuerst und höchste Stufen vorn, Stapelkarten mit grünem Haken, die
anderen abgedunkelt; Klick wechselt. Rechts Kartenzahl, Stärke (mittlerer Kartenwert, 100 für Grundkarten bis 500 für
Ultimate Rare), Manakurve, Elementanteile und die Vorschau der gezeigten Karte.
## Grafik
Ziel
- Wie ein normales Godot/Unity-Spiel, nicht primitiv
- Keine Kunstwerke
Stil
- Dunkler Tisch, Elemente leuchten
- Rahmen wie Hearthstone, Glanz wie Pokémon TCG
- Eine Palette, ein Font, eine Formensprache
- Palette aus style.gd erweitern, nicht ersetzen
- Font DejaVu Sans
Regeln
- Zahlen groß, Kontrast hoch
- Zustände sichtbar: Hover, Pressed, Disabled, Fokus
- Icons als SVG, ein Strichstil
Verbote
- Flache graue Panels
- Lila Verläufe
- Effekte ohne Zweck
Entschieden
- Richtung Stein: warm, Holz und Bronze, Bevel-Rahmen, Vignette
- Werte und Kristall bleiben im Kartenrahmen
- Rundungen und Kanten glatt, keine Treppchen
Offen
- Szene wählen: Taverne, Arena, Schrein (preview/out/3-*.png)
- Radius, Ränder, Schatten im Spiel nachziehen
## Offen
- Bilder statt Elementsymbolen, wenn es einmal Kartenkunst gibt.
- Hero-Porträts für Kampagnenbosse.
- Ein Tisch mit mehreren Spielern ließe sich als TFT-Rotation obendrauf setzen: jede Runde ein
anderer Gegner, das Brett bleibt.
## Quellen
- https://hearthstone.wiki.gg/wiki/Battlefield
- https://hearthstone.fandom.com/wiki/Card
- https://moirao.me/home/tft-ui
- https://gdkeys.com/the-card-games-ui-design-of-fairtravel-battle/
- https://medium.com/@acbassettone/5-ux-ui-lessons-from-designing-a-card-game
- https://hackread.com/the-juice-factor-designing-game-feel/
+4 -1
View File
@@ -1,6 +1,10 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
@@ -15,7 +19,6 @@ config/features=PackedStringArray("4.7", "GL Compatibility")
window/size/viewport_width=1280
window/size/viewport_height=800
window/stretch/mode="canvas_items"
window/stretch/aspect="keep"
[internationalization]
+215 -143
View File
@@ -1,21 +1,19 @@
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.
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels ausgeführt und die
## Stellung danach bewertet; die besten Erstschritte werden um einen Folgeschritt vertieft.
## Verdeckte gegnerische Wesen kennt die KI nicht (Game.masked_clone). Die gierige Variante
## (choose_action_greedy) dient als Vergleich und für die Schwierigkeit Leicht. Alles hier ist
## zustandslos und darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
# --- Bewertung -------------------------------------------------------------------
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
v += c.power() * 0.5
if c.has("Regeneration"):
v += 1.0
return v
@@ -26,13 +24,22 @@ static func board_value(p: Game.Player) -> float:
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.
## Karten auf der Hand zählen nach ihren Werten: ein Drache ist mehr wert als ein Kobold.
static func hand_value(p: Game.Player) -> float:
var v := 0.0
for c in p.hand:
v += 0.15 * (c.attack + c.health)
return v
## Stellung aus Sicht von p: eigenes Leben, Brett und Hand zählen positiv, die des Gegners
## negativ. Ohne eigene Wesen zielt gegnerische Angriffskraft aufs Leben, tödliche besonders.
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 score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.01
var alive_enemies := 0
var threat := 0.0
for e in g.players:
if e == p:
continue
@@ -43,47 +50,120 @@ static func evaluate(g: Game, p: Game.Player) -> float:
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
for c in e.board:
if not c.hidden:
threat += c.power() * c.attacks_per_turn()
if alive_enemies == 0:
return 10000.0
if p.board.is_empty():
if threat >= p.life:
score -= 25.0
else:
score -= threat * 0.3
else:
score -= threat * 0.12 # auch hinter Wesen tut gegnerische Angriffskraft weh
return score
# --- Vorausschau ---------------------------------------------------------------
static func candidate_actions(game: Game, p: Game.Player) -> Array:
const MAX_TARGETS := 4 # je Angreifer nur die aussichtsreichsten Ziele prüfen
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann der Rest.
static func _attack_rank(cr: Game.Creature, t) -> float:
if t is Game.Player:
return 50.0 - t.life
if _kills(cr, t):
return (100.0 if not _kills(t, cr) else 60.0) + _value(t)
return -float(t.health)
static func _top(list: Array, rank: Callable, n: int) -> Array:
if list.size() <= n:
return list
var ranked := []
for t in list:
ranked.append([rank.call(t), t])
ranked.sort_custom(func(a, b): return a[0] > b[0])
return ranked.slice(0, n).map(func(pair): return pair[1])
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Angreifer.
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> 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})
if game.can_play(p, card)[0]:
out.append({"type": "play", "card": card, "face_down": false})
for cr in p.board:
if cr.hidden and game.can_reveal(p, cr)[0]:
out.append({"type": "reveal", "creature": cr})
for pr in fusion_pairs(game, p, prune):
out.append({"type": "fuse", "creature": pr[0], "other": pr[1]})
for cr in p.board:
if not cr.can_attack():
continue
var targets := []
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})
targets.append_array(e.board)
if e.board.is_empty():
targets.append(e)
targets = targets.filter(func(t): return game.can_attack(cr, t)[0])
if prune:
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
for t in targets:
out.append({"type": "attack", "creature": cr, "target": t})
return out
## Verschmelzungen, die sich lohnen könnten: Paare eigener Wesen (mit prune die schwächsten, die
## Plätze frei machen), jedes angeschlagene Wesen mit der billigsten Handkarte, die zwei
## billigsten Handkarten. Das Ergebnis kommt auf die Hand, die Vorausschau legt es im Folgeschritt.
static func fusion_pairs(game: Game, p: Game.Player, prune: bool) -> Array:
var pairs := []
for i in p.board.size():
for j in range(i + 1, p.board.size()):
pairs.append([p.board[i], p.board[j]])
if prune and pairs.size() > MAX_TARGETS:
pairs = _top(pairs, func(pr): return -(_value(pr[0]) + _value(pr[1])), MAX_TARGETS)
var cheap := _cheapest(p)
if not cheap.is_empty():
for cr in p.board:
if cr.health < cr.max_health:
pairs.append([cr, cheap[0]])
if cheap.size() >= 2:
pairs.append([cheap[0], cheap[1]])
return pairs.filter(func(pr): return game.can_fuse(p, pr[0], pr[1])[0])
## Handkarten nach Kosten aufsteigend.
static func _cheapest(p: Game.Player) -> Array:
var cheap: Array = p.hand.duplicate()
cheap.sort_custom(func(a, b): return a.cost < b.cost)
return cheap
## Teil einer Verschmelzung in einem anderen Spiel: Handkarten sind in Kopien dieselben Objekte,
## Wesen werden über ihre uid gefunden.
static func _part(g: Game, x):
return x if x is Cards.Card else g.find_creature(x.uid)
## 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)
match action.type:
"play":
g2.play_card(p2, action.card, bool(action.get("face_down", false)))
"reveal":
g2.reveal(p2, g2.find_creature(action.creature.uid))
"fuse":
g2.fuse(p2, _part(g2, action.creature), _part(g2, action.other))
_:
g2.attack(g2.find_creature(action.creature.uid), map_target(g2, game, action.target))
static func map_target(g2: Game, game: Game, t):
@@ -92,84 +172,102 @@ static func map_target(g2: Game, game: Game, t):
return g2.players[game.players.find(t)]
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe
## Überträgt eine auf der Kopie gewählte Aktion zurück auf das Original.
static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary:
var out := action.duplicate()
if action.has("creature"):
out["creature"] = _part(game, action.creature)
if action.has("other"):
out["other"] = _part(game, action.other)
if action.get("target") != null:
out["target"] = map_target(game, copy, action.target)
return out
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
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).
## Beste Aktion nach einer Vorausschau von zwei Schritten auf einer Kopie, die verdeckte
## gegnerische Wesen nicht kennt. Bei Zeitmangel gilt der beste bisherige Zug.
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 view := game.masked_clone(p)
var me: Game.Player = view.players[game.players.find(p)]
var t0 := Time.get_ticks_msec()
var base := evaluate(view, me) + 0.01
var firsts := [] # {action, game, score}
for action in candidate_actions(view, me, true):
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
break
var g2 := view.clone()
apply_on_clone(g2, view, action)
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[view.current_idx])})
firsts.sort_custom(func(a, b): return a.score > b.score)
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)
var best_score := base
for i in mini(firsts.size(), DEEPEN):
var f: Dictionary = firsts[i]
var g2: Game = f.game
var score: float = f.score
if g2.winner == "":
var p2: Game.Player = g2.players[view.current_idx]
var count := 0
for f in candidate_actions(g2, p2):
if count >= FOLLOW_UPS:
for nxt in candidate_actions(g2, p2, true):
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
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)
var g3 := g2.clone()
apply_on_clone(g3, g2, nxt)
score = maxf(score, evaluate(g3, g3.players[view.current_idx]) - 0.001)
if score > best_score:
best_score = score
best = action
return best
best = f.action
# Erstschritte ohne Vertiefung zählen mit ihrer Einzelbewertung
for i in range(DEEPEN, firsts.size()):
if firsts[i].score > best_score:
best_score = firsts[i].score
best = firsts[i].action
if best.type == "end":
if game.can_draw(p)[0]:
return {"type": "draw"} # Mana übrig und Platz auf der Hand: lieber ziehen als passen
return best
return map_action(game, view, best)
# --- Gierige Vergleichs-KI -------------------------------------------------------
# --- Einfache Heuristiken ----------------------------------------------------------
static func _kills(attacker, defender) -> bool:
return not defender.shield and defender.health <= attacker.power()
if defender is Game.Player:
return attacker.power() >= defender.life
return attacker.power() + Game.matchup_bonus(attacker, defender) >= defender.health
static func _value(c) -> int:
return c.power() + c.health + (2 if c.taunt() else 0)
if c is Game.Player:
return 1000
return c.power() + c.health
static func _best(arr: Array, key: Callable):
static func _best(list: 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
var best_v := -INF
for t in list:
var v := float(key.call(t))
if v > best_v:
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
best = t
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)}
# Gierig: ein Wesen mit höchstens halbem Leben heilen (mit der billigsten Handkarte
# verschmelzen, wenn das Ergebnis bezahlbar bleibt), dann das teuerste Spielbare legen (bei
# vollem Feld die zwei schwächsten Wesen verschmelzen), dann aufdecken und angreifen, dann ziehen.
var cheap := _cheapest(p)
if not cheap.is_empty():
for cr in p.board:
if cr.health * 2 <= cr.max_health and game.can_fuse(p, cr, cheap[0])[0] and Game.fusion_result(p, cr, cheap[0]).cost <= p.energy:
return {"type": "fuse", "creature": cr, "other": cheap[0]}
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
if not playable.is_empty():
Game.shuffle(playable, game.rng)
@@ -177,71 +275,40 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
for c in playable:
if c.cost > card.cost:
card = c
return {"type": "play", "card": card, "target": pick_target(game, p, card)}
return {"type": "play", "card": card, "face_down": false}
if p.board.size() >= Game.MAX_BOARD and p.hand.any(func(c): return c.cost <= p.energy):
var weakest: Array = p.board.duplicate()
weakest.sort_custom(func(a, b): return _value(a) < _value(b))
if game.can_fuse(p, weakest[0], weakest[1])[0]:
return {"type": "fuse", "creature": weakest[0], "other": weakest[1]}
for cr in p.board:
if cr.hidden and game.can_reveal(p, cr)[0]:
return {"type": "reveal", "creature": cr}
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}
if game.can_draw(p)[0]:
return {"type": "draw"}
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]
## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
## der Held nur, wenn kein Wesen mehr steht.
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():
for e in game.enemies_of(p):
if e.board.is_empty():
return e if game.can_attack(cr, e)[0] else null
var creatures: Array = e.board.filter(func(c): return game.can_attack(cr, c)[0])
if creatures.is_empty():
continue
var good: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and not _kills(c, cr))
if not good.is_empty():
return _best(good, Callable(AI, "_value"))
var trade: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and _value(c) > _value(cr))
if not trade.is_empty():
return _best(trade, Callable(AI, "_value"))
return _best(creatures, func(c): return 1000 - c.health)
return null
@@ -251,11 +318,15 @@ static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
match action.type:
"play":
game.play_card(p, action.card, action.target)
game.play_card(p, action.card, bool(action.get("face_down", false)))
"draw":
game.draw(p)
"reveal":
game.reveal(p, action.creature)
"attack":
game.attack(action.creature, action.target)
"mulligan":
game.mulligan(p, action.cards)
"fuse":
game.fuse(p, action.creature, action.other)
_:
game.end_turn()
@@ -267,3 +338,4 @@ static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: i
apply_action(game, p, action)
if action.type == "end" or game.winner != "":
return
game.end_turn()
+57 -44
View File
@@ -1,59 +1,72 @@
class_name Campaign
## Kampagne: zehn benannte Magier, jede Stufe mit mehr Gegnern am Tisch.
## Kampagne: fünf Kapitel mit je zehn benannten Magiern, der zehnte ist der Boss. Kapitel 1 spielt
## neutrale Karten, Kapitel 2 bis 5 je ein Element; jeder Gegner spielt 30 Karten seines Elements
## nach Manakurve (DeckGen.curve_deck) mit steigendem Kostendeckel (COST_CAP), in der Stufe seines Kapitels (Kapitel 1 und 2 Common, dann je Kapitel eine höher, Kapitel 5
## Ultra Rare). Mit jedem offenen
## Kapitel gibt es den Booster seines Elements zu kaufen (Profile.booster_unlocked).
const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "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"]},
]
const STAGES_PER_CHAPTER := 10
const CHAPTER_ELEMENTS: Array[String] = [Cards.NEUTRAL, "Feuer", "Wasser", "Luft", "Erde"]
const NAMES: Array[String] = ["Fenn", "Mara", "Borin", "Liska", "Vulkar", "Nyx", "Ignis", "Thalassa", "Zephyra", "Archimagier"]
const TITLES := {
"Neutral": ["Kobold-Zähmer", "Wanderhexe", "Steinfaust", "Pfadfinderin", "Söldnerfürst", "Schattenruferin", "Grauer Priester", "Händlerkönigin", "Stahlkaiserin", "Meister der Lehre"],
"Feuer": ["Flammenzähmer", "Glutweberin", "Schmiedefaust", "Funkenläuferin", "Herr der Lava", "Aschenruferin", "Inferno-Priester", "Glutkönigin", "Flammenkaiserin", "Meister des Feuers"],
"Wasser": ["Gezeitenzähmer", "Gezeitenhexe", "Eisfaust", "Nebelläuferin", "Herr der Brandung", "Regenruferin", "Tiefsee-Priester", "Tiefseekönigin", "Flutkaiserin", "Meister des Wassers"],
"Luft": ["Windzähmer", "Wolkenhexe", "Sturmfaust", "Windläuferin", "Herr der Böen", "Sturmruferin", "Himmelspriester", "Wolkenkönigin", "Blitzkaiserin", "Meister der Lüfte"],
"Erde": ["Wurzelzähmer", "Moorhexe", "Felsenfaust", "Hügelläuferin", "Herr der Schluchten", "Bebenruferin", "Erzpriester", "Kristallkönigin", "Bergkaiserin", "Meister der Erde"],
}
const LIFE := [22, 22, 24, 24, 24, 26, 26, 26, 28, 28]
## Höchste Kartenkosten im Stapel je Stufe eines Kapitels.
const COST_CAP: Array[int] = [3, 4, 4, 5, 5, 6, 7, 8, 9, 10]
## Merkmale je Stufe: das erste nach dieser Liste, weitere je drei Plätze dahinter.
const PERK_ORDER: Array[String] = ["feuer_angriff", "leben_35", "wesen_angriff", "ziehen_gratis", "wesen_leben", "extra_mana", "kein_nachteil", "alle_flink", "alle_flink", "start_energie"]
static func total() -> int:
return STAGES.size() + STAGES2.size()
return STAGES_PER_CHAPTER * CHAPTER_ELEMENTS.size()
static func chapter(stage: int) -> int:
return 1 if stage < STAGES.size() else 2
return stage / STAGES_PER_CHAPTER + 1
## Stufe mit einheitlichem Feld "perks".
static func element_of(chapter_no: int) -> String:
return CHAPTER_ELEMENTS[chapter_no - 1]
## Merkmale je Gegner: eins, ab Kapitel 3 zwei, in Kapitel 5 drei.
static func perk_count(chapter_no: int) -> int:
return (chapter_no + 1) / 2
## Stufe i: Name, Titel, Leben, Belohnung (Kapitel 1: 100 bis 300, Boss 500; je Kapitel 250 mehr),
## Merkmale und Element.
static func stage(i: int) -> Dictionary:
if i < STAGES.size():
var st: Dictionary = STAGES[i].duplicate()
st["perks"] = [st.perk]
return st
return STAGES2[i - STAGES.size()]
var c := chapter(i)
var k := i % STAGES_PER_CHAPTER
var perks: Array = []
for n in perk_count(c):
perks.append(PERK_ORDER[(k + 3 * n) % STAGES_PER_CHAPTER])
var base := 100 + 250 * (c - 1)
return {"name": NAMES[k], "title": TITLES[element_of(c)][k], "life": LIFE[k],
"reward": base + (400 if k == STAGES_PER_CHAPTER - 1 else 50 * (k / 2)),
"perks": perks, "element": element_of(c), "max_cost": COST_CAP[k]}
static func opponents(stage_index: int, r: RandomNumberGenerator) -> Array:
## Gegner einer Stufe: Leben und Merkmale der Stufe, 30 Karten seines Elements nach Manakurve bis
## max_cost, in der Stufe des Kapitels (rarity_of).
static func opponent(stage_index: int, r: RandomNumberGenerator = null) -> Game.Player:
var st := stage(stage_index)
var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE
var boss := Game.Player.new(st.name, Game.build_opponent_deck(r, st.elements, st.bias), false, life, st.title)
var boss := Game.Player.new(st.name, false, int(st.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
if r == null:
r = Game.make_rng()
boss.deck = DeckGen.curve_deck(st.element, r, int(st.max_cost))
for id in boss.deck:
boss.looks[id] = rarity_of(chapter(stage_index))
return boss
## Seltenheitsstufe der Gegnerkarten: Kapitel 1 und 2 Common, dann je Kapitel eine höher.
static func rarity_of(chapter_no: int) -> int:
return maxi(0, chapter_no - 2)
+188 -180
View File
@@ -1,15 +1,25 @@
class_name Cards
## Elemente, Kombos und der Kartenpool.
## Elemente, Kombonamen, Schlüsselwörter und der Kartenpool.
const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"]
# Neutral ist kein Element: nie im Vorteil oder Nachteil, zählt nicht für Paare und Zähler.
# Verschmolzen mit einem Element nimmt das Wesen im schlechten Matchup die neutrale Seite.
const NEUTRAL := "Neutral"
const NEUTRAL_TEXT := "Nie im Vorteil oder Nachteil"
const ELEMENT_COLORS := {
"Feuer": Color("be3c23"),
"Wasser": Color("2d64be"),
"Erde": Color("73552d"),
"Luft": Color("78aac8"),
"Neutral": Color("6e7382"),
}
# Jede Paarung ist eine Kombo, auch gleiche Elemente.
# Jede Karte hat genau ein Element (oder ist neutral). Kombos sind die Namen verschmolzener Wesen.
const COMBOS := {
"Neutral+Neutral": "Veteran",
"Feuer+Neutral": "Feuerritter",
"Wasser+Neutral": "Wasserritter",
"Erde+Neutral": "Erdritter",
"Luft+Neutral": "Luftritter",
"Feuer+Feuer": "Inferno",
"Wasser+Wasser": "Tiefsee",
"Erde+Erde": "Berg",
@@ -20,236 +30,238 @@ const COMBOS := {
"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"]
# Elementkreis: Wasser löscht Feuer, Feuer verbrennt Luft, Luft trägt Erde ab, Erde saugt Wasser auf.
const BEATS := {"Wasser": "Feuer", "Feuer": "Luft", "Luft": "Erde", "Erde": "Wasser"}
# Seltenheit ist reine Optik: jede Karte gibt es in jeder Stufe, die Werte bleiben gleich.
# Stufen wie bei Yu-Gi-Oh: Common grau, Rare silber, Super Rare blau, Ultra Rare gold,
# Ultimate Rare schillert durch alle Farben.
const RARITIES: Array[String] = ["Common", "Rare", "Super Rare", "Ultra Rare", "Ultimate Rare"]
const RARITY_COLORS := {
"gewöhnlich": Color("aaaaaa"),
"selten": Color("4682e6"),
"episch": Color("aa46dc"),
"legendär": Color("f0aa28"),
"mythisch": Color("ff5a8c"),
"Common": Color("aaaaaa"),
"Rare": Color("c8d0dc"),
"Super Rare": Color("4682e6"),
"Ultra Rare": Color("f0aa28"),
"Ultimate Rare": 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.",
# Eigenschaften haben Karten nicht von Haus aus; sie kommen allein vom Elementpaar der eigenen
# Seite (ELEMENT_KEYWORD, siehe Game.Creature.has).
const KEYWORDS: Array[String] = ["Spott", "Flink", "Regeneration", "Doppelschlag"]
const KEYWORD_TEXT := {
"Spott": "Muss zuerst angegriffen werden",
"Flink": "Greift sofort an",
"Regeneration": "Heilt sich am Zugende ganz",
"Doppelschlag": "Greift zweimal je Zug",
}
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"]
const ELEMENT_KEYWORD := {"Erde": "Spott", "Luft": "Flink", "Wasser": "Regeneration", "Feuer": "Doppelschlag"}
const DATA_PATH := "res://data/karten.json"
## Reihenfolge der Elemente in Namen und Darstellung: wie ELEMENTS, Neutral zuletzt.
static func element_order(e: String) -> int:
var i := ELEMENTS.find(e)
return i if i >= 0 else ELEMENTS.size()
static func combo_key(elements: Array) -> String:
var sorted := elements.duplicate()
sorted.sort_custom(func(a, b): return ELEMENTS.find(a) < ELEMENTS.find(b))
sorted.sort_custom(func(a, b): return element_order(a) < element_order(b))
return "+".join(sorted)
## Name der Elementkombination eines verschmolzenen Wesens; drei oder vier Elemente sind eine Chimäre.
static func combo_name(elements: Array) -> String:
if elements.size() == 1:
return elements[0]
return COMBOS[combo_key(elements)]
return COMBOS.get(combo_key(elements), "Chimäre")
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
## Elementvorteil aus Sicht von own: +1 im Vorteil, -1 im Nachteil, sonst 0.
## Hat ein Wesen mehrere Elemente, kämpft es mit dem, das gegen die Gegenseite am besten steht.
static func matchup(own: Array, other: Array) -> int:
var mine := best_element(own, other)
var theirs := best_element(other, own)
if mine == "" or theirs == "":
return 0
return duel(mine, theirs)
const TRIGGER_TEXT := {
"attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift",
"creature_played": "Wenn ein Gegner ein Wesen ausspielt",
}
## +1, wenn a b schlägt, -1, wenn b a schlägt, sonst 0.
static func duel(a: String, b: String) -> int:
if BEATS.get(a, "") == b:
return 1
if BEATS.get(b, "") == a:
return -1
return 0
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
## Das Element aus own, das gegen die Elemente in other am besten steht.
static func best_element(own: Array, other: Array) -> String:
var best := ""
var best_score := -9
for e in own:
var score := 0
for o in other:
score += duel(e, o)
if score > best_score:
best = e
best_score = score
return best
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
## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts).
static func cost_of(attack: int, health: int) -> int:
return mini(10, (2 * attack + health) / 3)
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 ""
## Wert eines verschmolzenen Wesens aus den Grundwerten der Eltern: die aufgerundete Hälfte der
## Summe plus eins. Ein starker und ein schwacher Partner ergeben also weniger als der starke.
static func fusion_value(a: int, b: int) -> int:
return ceili((a + b) / 2.0) + 1
## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt).
static func fusion_name(elements: Array) -> String:
return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]])
## Verschmelzung zweier Karten: Elemente (sortiert) und Eigenschaften beider, Angriff und Leben
## nach fusion_value aus den Grundwerten, Kosten daraus wie bei jeder Karte; dann die Stufe level
## mit ihrem +1/+1 (ohne Angabe die höhere der Eltern). Das Bild trägt der stärkere Elternteil
## (Angriff plus Leben).
static func fused(a: Card, b: Card, level: int = -1) -> Card:
var attack := fusion_value(a.base_attack(), b.base_attack())
var health := fusion_value(a.base_health(), b.base_health())
var els := []
for e in a.elements + b.elements:
if not (e in els):
els.append(e)
els.sort_custom(func(x, y): return element_order(x) < element_order(y))
var c := Card.new()
c.id = "fus_%s_%s" % [a.id, b.id]
c.name = fusion_name(els)
c.elements = els
c.attack = attack
c.health = health
c.cost = cost_of(attack, health)
for k in KEYWORDS:
if k in a.keywords or k in b.keywords:
c.keywords.append(k)
var strong := a if a.attack + a.health >= b.attack + b.health else b
c.art = strong.art_card().id
return c.leveled(maxi(level, maxi(maxi(a.look, b.look), 0)))
## Platzhalter für ein verdecktes gegnerisches Wesen: unbekanntes Element, geschätzte Werte.
static func unknown() -> Card:
var c := Card.new()
c.id = "?"
c.name = "Verdeckt"
c.name_en = "Face-down"
c.elements = []
c.attack = 2
c.health = 3
c.cost = cost_of(2, 3)
return c
class Card extends RefCounted:
var id: String
var name: String
var name: String # deutscher Name (auch im Verlauf und in Tests)
var name_en := "" # englischer Name
var elements: Array
var 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"
var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet, höchstens 10 (aus den Grundwerten)
var keywords: Array = [] # feste Eigenschaften der Karte (Elementpaare geben ihre zur Laufzeit)
var look := -1 # Seltenheitsstufe des Exemplars (gibt +1/+1 je Stufe); -1: Grundkarte aus dem Pool
var art := "" # verschmolzen: Karte, deren Bild das Wesen trägt (der stärkere Elternteil)
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
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "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
if look >= 0:
d["look"] = look
if art != "":
d["art"] = art
if cost != Cards.cost_of(attack, health):
d["cost"] = cost
return d
## Wert der Karte: was sie über ihre Manakosten hinaus bringt, mal 100. Grundkarten liegen
## bei 100, jede Seltenheitsstufe gibt 100 dazu (Ultimate Rare 500); Fusionen aus ihren Werten.
func value() -> int:
return (Cards.cost_of(attack, health) - cost + 1) * 100
## Grundwerte ohne den Stufenbonus.
func base_attack() -> int:
return attack - maxi(look, 0)
func base_health() -> int:
return health - maxi(look, 0)
## Stufe dieser Karte für einen Besitzer mit dieser Sammlung (Game.Player.looks).
func rarity_in(looks: Dictionary) -> String:
return Cards.RARITIES[look if look >= 0 else int(looks.get(id, 0))]
static func from_dict(d: Dictionary) -> Card:
var c := Card.new()
c.id = d.id
c.name = d.name
c.name_en = str(d.get("name_en", ""))
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.cost = int(d.get("cost", Cards.cost_of(c.attack, c.health)))
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", ""))
c.look = int(d.get("look", -1))
c.art = str(d.get("art", ""))
return c
## Karte, deren Bild gezeichnet wird: bei Verschmelzungen der stärkere Elternteil, sonst sie selbst.
func art_card() -> Card:
return Cards.by_id(art) if art != "" and Cards._by_id.has(art) else self
## Eigenes Exemplar (Hand, Brett): Schlüsselwörter gehören dem Exemplar, nicht dem Pool.
func copy() -> Card:
return Card.from_dict(to_dict())
## Exemplar in einer Seltenheitsstufe: +1/+1 je Stufe, die Kosten bleiben die der Grundkarte.
func leveled(level: int) -> Card:
var c := copy()
c.look = level
c.attack += level
c.health += level
c.cost = cost
return c
## Anzeigename in der aktuellen Sprache.
func title() -> String:
if name_en != "" and TranslationServer.get_locale().begins_with("en"):
return name_en
return name
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 is_neutral() -> bool:
return Cards.NEUTRAL in elements
## Kartentext: Neutral und die festen Eigenschaften.
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)
var kws: Array[String] = []
if is_neutral():
kws.append(tr(Cards.NEUTRAL))
for k in Cards.KEYWORDS:
if k in keywords:
kws.append(tr(k))
if kws.is_empty():
return ""
return ", ".join(kws) + "."
static var _all: Array = []
@@ -274,10 +286,6 @@ static func has_id(id) -> bool:
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()
-33
View File
@@ -1,33 +0,0 @@
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 ""
-1
View File
@@ -1 +0,0 @@
uid://n5h6q6kfoo34
+108
View File
@@ -0,0 +1,108 @@
class_name DeckGen
## Stapel: Elementstapel nach Manakurve (Startstapel und Gegner), Auffüllen aus der Sammlung,
## zufällige Stapel für Tests und Simulation.
const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens
## Manakurve eines Stapels mit DECK_MIN Karten: so viele Karten je Kosten 0 bis 10.
const CURVE: Array[int] = [2, 5, 6, 6, 5, 3, 2, 1, 0, 0, 0]
## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang.
static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array:
var cheap := Cards.all().filter(func(c): return c.cost <= Game.START_MANA)
var rest := Cards.all().filter(func(c): return c.cost > Game.START_MANA and c.cost <= max_cost)
Game.shuffle(cheap, r)
Game.shuffle(rest, r)
var out := []
for c in cheap.slice(0, CHEAP_IN_RANDOM) + rest + cheap.slice(CHEAP_IN_RANDOM):
if out.size() >= Game.DECK_MIN:
break
out.append(c.id)
return out
## Alle Karten eines Elements (oder die neutralen) in Datenreihenfolge: die Startsammlung.
static func element_deck(element: String) -> Array:
return Cards.all().filter(func(c): return c.elements[0] == element).map(func(c): return c.id)
## Stapel eines Elements nach der Manakurve: DECK_MIN verschiedene Karten des Elements bis max_cost,
## je Kosten so viele wie CURVE vorgibt, zufällig gewählt. Was über dem Deckel fehlt, füllen die
## erlaubten Kosten reihum von billig nach teuer auf.
static func curve_deck(element: String, r: RandomNumberGenerator, max_cost: int = 10) -> Array:
var by_cost := {}
for c in Cards.all():
if c.elements[0] == element and c.cost <= max_cost:
if not by_cost.has(c.cost):
by_cost[c.cost] = []
by_cost[c.cost].append(c.id)
for cost in by_cost:
Game.shuffle(by_cost[cost], r)
var out := []
for cost in CURVE.size():
for k in CURVE[cost]:
if by_cost.has(cost) and not by_cost[cost].is_empty():
out.append(by_cost[cost].pop_back())
while out.size() < Game.DECK_MIN:
var added := false
for cost in range(0, max_cost + 1):
if out.size() >= Game.DECK_MIN:
break
if by_cost.has(cost) and not by_cost[cost].is_empty():
out.append(by_cost[cost].pop_back())
added = true
if not added:
break
return out
## Füllt deck aus pool (Karten-IDs) bis limit auf, die wertvollsten zuerst (Card.value in der Stufe
## aus levels: ID -> Stufe); bei gleichem Wert zuerst, was der Manakurve (CURVE, auf limit
## skaliert) noch fehlt, dann die billigeren, dann nach ID; nichts doppelt.
static func fill(deck: Array, pool: Array, limit: int, levels: Dictionary = {}) -> Array:
var out := deck.duplicate()
var cards := pool.filter(func(id): return not (id in out)).map(func(id): return Cards.by_id(id).leveled(int(levels.get(id, 0))))
cards.sort_custom(func(a, b):
if a.value() != b.value():
return a.value() > b.value()
return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
var have := {} # Kosten -> Karten im Stapel
for id in out:
var slot := clampi(Cards.by_id(id).cost, 0, CURVE.size() - 1)
have[slot] = int(have.get(slot, 0)) + 1
var i := 0
while i < cards.size() and out.size() < limit:
var j := i
while j < cards.size() and cards[j].value() == cards[i].value():
j += 1
var rest := []
for c in cards.slice(i, j):
if out.size() >= limit:
break
var slot := clampi(c.cost, 0, CURVE.size() - 1)
if int(have.get(slot, 0)) < CURVE[slot] * limit / Game.DECK_MIN:
out.append(c.id)
have[slot] = int(have.get(slot, 0)) + 1
else:
rest.append(c)
for c in rest:
if out.size() >= limit:
break
out.append(c.id)
i = j
return out
## Stärke eines Stapels: der mittlere Kartenwert (Card.value) in den Stufen aus levels,
## 100 für lauter Grundkarten bis 500 für lauter Ultimate Rare; leer: 0.
static func strength(deck: Array, levels: Dictionary) -> int:
if deck.is_empty():
return 0
var sum := 0
for id in deck:
sum += Cards.by_id(id).leveled(int(levels.get(id, 0))).value()
return roundi(float(sum) / deck.size())
static func random_opponent(life: int = Game.START_LIFE) -> Game.Player:
return Game.Player.new("Gegner", false, life)
+1
View File
@@ -0,0 +1 @@
uid://ev8t5ar278vl
+336 -548
View File
File diff suppressed because it is too large. Load diff
-279
View File
@@ -1,279 +0,0 @@
class_name Net
extends Node
## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten
## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen).
signal lobby_changed
signal started(seat: int)
signal snapshot_received(snap: Dictionary)
signal action_applied
signal ended(reason: String)
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}
-1
View File
@@ -1 +0,0 @@
uid://drhh23lblvq8o
+344 -453
View File
@@ -1,519 +1,410 @@
class_name Profile
extends RefCounted
## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Statistik, Erfolge, Tagesaufgabe, Einstellungen.
## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen.
## Man startet mit allen neutralen Karten (Stapel: 30 nach Manakurve); die Elemente schaltet die Kampagne frei (je Kapitel einen
## Booster), die Werkstatt bringt den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe
## gibt (Cards.Card.leveled); kommt eine seltenere Version, ersetzt sie die alte, die zu Staub wird.
const SAVE_VERSION := 5
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
const SAVE_VERSION := 8
const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100
const BOOSTER_COST := 100 # neutraler Booster
const ELEMENT_BOOSTER_COST := 200 # Booster eines Elements (ab seinem Kapitel)
const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0}
const START_COINS := 150
const REWARD_WIN := {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
# Münzen nach dem Kampf: je eigener Zug, je ausgespielte Karte und je Zug unter FAST_TURN_SEC
# Sekunden; ein Sieg verdoppelt alles, die Schwierigkeit multipliziert (siehe battle_coins).
const COINS_TURN := 5
const COINS_CARD := 5
const COINS_FAST := 5
const FAST_TURN_SEC := 15.0
# Schwierigkeit im Einzelspieler: Stufe der Gegnerkarten relativ zu deiner Deckstärke (level),
# KI (smart: Vorausschau, sonst gierig), ein zufälliges Merkmal (perk) und der Münzfaktor.
const DIFFICULTIES := {
"leicht": {"name": "Leicht", "level": -1, "smart": false, "perk": false, "reward": 1.0},
"normal": {"name": "Normal", "level": 0, "smart": true, "perk": false, "reward": 1.5},
"schwer": {"name": "Schwer", "level": 1, "smart": true, "perk": true, "reward": 2.0},
}
# Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub.
const DUST_DISMANTLE := {"Common": 50, "Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
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": []})
var xp := 0 # Erfahrung: alle je verdienten Münzen (Skills.level_for)
var skills := {} # Skill-ID -> gelernte Stufe (Skills.SKILLS)
var collection := {} # eigene Karten: id -> Seltenheitsstufe (Index in Cards.RARITIES)
var deck: Array = [] # Karten-IDs des eigenen Stapels (Game.DECK_MIN bis DECK_MAX)
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
## Neues Profil: alle neutralen Karten in der Sammlung, 30 davon nach Manakurve im Stapel.
static func starter() -> Profile:
var p := Profile.new()
p.coins = START_COINS
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
var p := Profile.new()
p.coins = START_COINS
p.give_start()
return p
## Alle neutralen Karten in Common werden die Sammlung, ein Kurvenstapel daraus der Stapel.
func give_start() -> void:
collection = {}
for id in DeckGen.element_deck(Cards.NEUTRAL):
collection[id] = 0
deck = DeckGen.curve_deck(Cards.NEUTRAL, Game.make_rng())
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
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
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).
## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und
## Einstellungen und bekommen die Startsammlung.
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
var p := Profile.new()
p.coins = int(d.get("coins", 0))
p.dust = int(d.get("dust", 0))
p.xp = int(d.get("xp", 0))
var sk = d.get("skills", {})
for id in sk:
if Skills.SKILLS.has(id):
p.skills[id] = clampi(int(sk[id]), 0, int(Skills.SKILLS[id].max))
p.campaign_stage = int(d.get("campaign_stage", 0))
if int(d.get("version", 1)) >= SAVE_VERSION:
var coll = d.get("collection", {})
for cid in coll:
if Cards.has_id(cid):
p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
var ids := []
for cid in d.get("deck", []):
if p.collection.has(cid) and not (cid in ids):
ids.append(cid)
p.deck = ids.slice(0, Game.DECK_MAX)
if p.collection.is_empty():
p.give_start()
var se = d.get("settings", {})
p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)),
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
"big_font": bool(se.get("big_font", false))}
if not DIFFICULTIES.has(p.settings.difficulty):
p.settings.difficulty = "normal"
p.perk = str(d.get("perk", ""))
if not Game.PERKS.has(p.perk):
p.perk = ""
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}
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "xp": xp, "skills": skills, "collection": collection,
"deck": deck, "campaign_stage": campaign_stage, "settings": settings, "perk": perk}
## Speichert und sichert den vorherigen Stand als .bak.
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()
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
var out := notices.duplicate()
notices.clear()
return out
# --- Decks und Sammlung -----------------------------------------------------------
# --- Sammlung und Stapel -----------------------------------------------------------
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 owns(cid: String) -> bool:
return collection.has(cid)
func deck_valid(slot: int = -1) -> Array:
return Game.validate_deck(deck_cards(slot), collection)
func level(cid: String) -> int:
return int(collection.get(cid, 0))
func deck_name(slot: int = -1) -> String:
return decks[slot if slot >= 0 else active_deck].name
func rarity(cid: String) -> String:
return Cards.RARITIES[level(cid)]
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 ""
## Karten-ID -> Stufe für den Kampf (nur aufgewertete Karten, siehe Game.Player.looks).
func looks() -> Dictionary:
var out := {}
for cid in collection:
if int(collection[cid]) > 0:
out[cid] = int(collection[cid])
return out
## 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]
## Der Stapel für den Kampf: der gewählte, mit weiteren eigenen Karten bis DECK_MIN aufgefüllt,
## falls er zu klein ist (Karten können durch Zerlegen wegfallen).
func deck_cards() -> Array:
return DeckGen.fill(deck.filter(func(id): return collection.has(id)), collection.keys(), Game.DECK_MIN, collection)
## 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)
## Karte in den Stapel oder heraus. Gibt "" zurück oder eine Fehlermeldung.
func toggle_deck(cid: String) -> String:
if cid in deck:
deck.erase(cid)
return ""
if not owns(cid):
return "Karte nicht in der Sammlung"
if deck.size() >= Game.DECK_MAX:
return "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX
deck.append(cid)
return ""
func owned_cards() -> Array:
return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0)
## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
func upgrade(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid)
if lvl >= Cards.RARITIES.size() - 1:
return "Schon auf der höchsten Stufe"
var cost: int = DUST_UPGRADE[Cards.RARITIES[lvl + 1]]
if dust < cost:
return "Nicht genug Staub (%d nötig)" % cost
dust -= cost
collection[cid] = lvl + 1
return ""
func distinct_owned() -> int:
var n := 0
for cid in collection:
if collection[cid] > 0:
n += 1
return n
## Entzaubern: eine Stufe abgeben bringt Staub; eine Common-Karte verlässt die Sammlung.
## Gibt "" zurück oder eine Fehlermeldung.
func dismantle(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid)
dust += dust_gain(DUST_DISMANTLE[Cards.RARITIES[lvl]])
if lvl == 0:
collection.erase(cid)
deck.erase(cid)
else:
collection[cid] = lvl - 1
return ""
# --- 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.
## Herstellen: eine fehlende Karte für Staub als Common. 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
if owns(cid):
return "Karte schon in der Sammlung"
if dust < CRAFT_COST:
return "Nicht genug Staub (%d nötig)" % CRAFT_COST
dust -= CRAFT_COST
collection[cid] = 0
return ""
# --- 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
## Preis eines Boosters: neutral BOOSTER_COST, je Element ELEMENT_BOOSTER_COST.
static func booster_cost(element: String) -> int:
return BOOSTER_COST if element == Cards.NEUTRAL else ELEMENT_BOOSTER_COST
## Ö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
## Der Booster eines Elements ist zu haben, sobald sein Kampagnenkapitel offen ist (neutral immer).
func booster_unlocked(element: String) -> bool:
return campaign_stage >= Campaign.STAGES_PER_CHAPTER * Campaign.CHAPTER_ELEMENTS.find(element)
# --- 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
## Fünf Karten des Elements mit gewürfelter Seltenheit; luck (Packglück) verschiebt die Chancen.
static func roll_booster(r: RandomNumberGenerator, element: String, luck: int = 0) -> Array:
var rarities := RARITY_WEIGHTS.keys()
var weights := Skills.pack_weights(luck)
var pool := Cards.all().filter(func(c): return c.elements[0] == element)
var out := []
for i in BOOSTER_SIZE:
var rar: String = rarities[r.rand_weighted(weights)]
if i == BOOSTER_SIZE - 1 and out.all(func(x): return x.rarity == "Common"):
rar = "Rare" # mindestens eine Rare pro Booster
out.append({"card": pool[r.randi_range(0, pool.size() - 1)], "rarity": rar})
return out
func _stat(cid: String) -> Dictionary:
if not card_stats.has(cid):
card_stats[cid] = {"played": 0, "games": 0, "wins": 0}
return card_stats[cid]
## Öffnet einen Booster des Elements. Neue Karten kommen in Sammlung und Stapel (solange Platz ist).
## Von jeder Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine
## gleiche oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn der Booster
## noch gesperrt ist oder die Münzen nicht reichen.
func open_booster(element: String, r: RandomNumberGenerator = null) -> Array:
return open_boosters(element, 1, r)
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
## Öffnet n Booster des Elements auf einmal; leer, wenn gesperrt oder die Münzen für alle nicht reichen.
func open_boosters(element: String, n: int, r: RandomNumberGenerator = null) -> Array:
if not booster_unlocked(element) or coins < pack_price(element) * n:
return []
if r == null:
r = Game.make_rng()
var out := []
for i in n:
out += _open_one(element, r)
return out
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 _open_one(element: String, r: RandomNumberGenerator) -> Array:
coins -= pack_price(element)
var out := roll_booster(r, element, skill("packglueck"))
for res in out:
var lvl: int = Cards.RARITIES.find(res.rarity)
res["new"] = not owns(res.card.id)
res["upgraded"] = false
res["dust"] = 0
if res.new:
collection[res.card.id] = lvl
if deck.size() < Game.DECK_MAX:
deck.append(res.card.id)
elif lvl > level(res.card.id):
res["upgraded"] = true
res["dust"] = dust_gain(DUST_DISMANTLE[rarity(res.card.id)])
collection[res.card.id] = lvl
else:
res["dust"] = dust_gain(DUST_DISMANTLE[res.rarity])
dust += res.dust
return out
# --- Kampf und Kampagne -----------------------------------------------------------
## Münzen eines Kampfes: COINS_TURN je eigenem Zug, COINS_CARD je ausgespielter Karte, COINS_FAST
## je schnellem Zug; ein Sieg verdoppelt, factor ist der Münzfaktor der Schwierigkeit.
static func battle_coins(won: bool, turns: int, cards: int, fast: int, factor: float = 1.0) -> int:
var pts := COINS_TURN * turns + COINS_CARD * cards + COINS_FAST * fast
if won:
pts *= 2
return int(round(pts * factor))
## Verbucht ein Kampfergebnis mit den verdienten Münzen (battle_coins, mal Münzsegen) und gibt die
## Gutschrift zurück; sie zählt auch als Erfahrung, ein neues Level meldet sich. stage >= 0:
## Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei und bringt ihre Belohnung obendrauf.
func record_battle(won: bool, earned: int, stage: int = -1) -> int:
var reward := int(round(earned * coin_factor()))
if won and stage >= 0 and stage == campaign_stage:
campaign_stage += 1
reward += Campaign.stage(stage).reward
notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name)
coins += reward
var before := player_level()
xp += reward
if player_level() > before:
var gained := player_level() - before
notices.append(("Level %d erreicht: +%d Skillpunkt" if gained == 1 else "Level %d erreicht: +%d Skillpunkte") % [player_level(), gained])
return reward
# --- Skilltree ----------------------------------------------------------------------
## Spielerlevel aus der Erfahrung (nicht die Kartenstufe level(cid)).
func player_level() -> int:
return Skills.level_for(xp)
## Gelernte Stufe eines Skills.
func skill(id: String) -> int:
return int(skills.get(id, 0))
## Freie Skillpunkte: ein Punkt je Level, abzüglich der gelernten Stufen.
func skill_points() -> int:
var spent := 0
for id in skills:
spent += int(skills[id])
return player_level() - spent
## Lernt die nächste Stufe eines Skills für einen Punkt. Gibt "" zurück oder eine Fehlermeldung.
func learn(id: String) -> String:
if skill(id) >= int(Skills.SKILLS[id].max):
return "Schon auf der höchsten Stufe"
if skill_points() <= 0:
return "Kein Skillpunkt frei"
skills[id] = skill(id) + 1
return ""
## Preis eines Boosters mit Händler-Rabatt.
func pack_price(element: String) -> int:
return int(round(booster_cost(element) * (1.0 - 0.1 * skill("packpreis"))))
func coin_factor() -> float:
return 1.0 + 0.1 * skill("muenzen")
## Staub mit Staubsammler-Bonus.
func dust_gain(base: int) -> int:
return int(round(base * (1.0 + 0.25 * skill("staub"))))
## Merkmale im Kampf: das gewählte (nach Kapitel 1) und die der Kampfskills.
func battle_perks() -> Array:
var out := []
if perk != "" and perk_unlocked():
out.append(perk)
for id in Skills.PERK_OF:
if skill(id) > 0 and not (Skills.PERK_OF[id] in out):
out.append(Skills.PERK_OF[id])
return out
func difficulty() -> Dictionary:
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
## Deine Stufe: die Deckstärke des Kampfstapels (100 Common … 500 Ultimate Rare) als Seltenheitsstufe.
func deck_level() -> int:
return clampi(roundi((DeckGen.strength(deck_cards(), collection) - 100) / 100.0), 0, Cards.RARITIES.size() - 1)
## Stufe der Gegnerkarten im Einzelspieler: deine Stufe plus den Versatz der Schwierigkeit.
func opponent_level() -> int:
return clampi(deck_level() + int(difficulty().level), 0, Cards.RARITIES.size() - 1)
## Elemente der offenen Kampagnenkapitel (Neutral immer).
func unlocked_elements() -> Array:
return Campaign.CHAPTER_ELEMENTS.slice(0, campaign_stage / Campaign.STAGES_PER_CHAPTER + 1)
## Alle Karten der offenen Elemente: was Sammlung, Deck und Booster zeigen.
func unlocked_cards() -> Array:
var open := unlocked_elements()
return Cards.all().filter(func(c): return c.elements[0] in open)
## Einzelspieler-Gegner: ein Element aus den offenen Kapiteln, 30 Karten nach Manakurve in opponent_level;
## auf Schwer ein zufälliges Merkmal. Leben wie deins.
func single_opponent(r: RandomNumberGenerator) -> Game.Player:
var elements := unlocked_elements()
var element: String = elements[r.randi_range(0, elements.size() - 1)]
var opp := Game.Player.new("Gegner", false, Game.START_LIFE, element)
opp.deck = DeckGen.curve_deck(element, r)
var level := opponent_level()
for id in opp.deck:
opp.looks[id] = level
if bool(difficulty().perk):
var keys: Array = Game.PERKS.keys()
opp.perks = [keys[r.randi_range(0, keys.size() - 1)]]
return opp
func perk_unlocked() -> bool:
return campaign_stage >= Campaign.STAGES.size()
## 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
return campaign_stage >= Campaign.STAGES_PER_CHAPTER
+62
View File
@@ -0,0 +1,62 @@
class_name Skills
## Skilltree: Erfahrung sind die verdienten Münzen, je Level ein Skillpunkt, Punkte kaufen Stufen
## der Skills in drei Ästen. Münzen kaufen keine Punkte.
const BRANCHES: Array[String] = ["Sammeln", "Wirtschaft", "Kampf"]
## id -> Name, Ast, Höchststufe, Text.
const SKILLS := {
"packglueck": {"name": "Packglück", "branch": "Sammeln", "max": 3, "text": "Seltenere Karten in Boostern"},
"staub": {"name": "Staubsammler", "branch": "Sammeln", "max": 3, "text": "Mehr Staub aus Doppelten und Zerlegen"},
"muenzen": {"name": "Münzsegen", "branch": "Wirtschaft", "max": 3, "text": "Mehr Münzen nach jedem Kampf"},
"packpreis": {"name": "Händler", "branch": "Wirtschaft", "max": 2, "text": "Booster werden billiger"},
"gratisziehen": {"name": "Freies Ziehen", "branch": "Kampf", "max": 1, "text": "Das zweite Ziehen je Zug ist auch gratis"},
"startmana": {"name": "Starker Start", "branch": "Kampf", "max": 1, "text": "Mehr Mana im ersten Zug"},
}
## Kampfskills geben ein Merkmal (Game.PERKS) im Kampf.
const PERK_OF := {"gratisziehen": "ziehen_gratis", "startmana": "start_energie"}
## Packglück je Stufe: Common gibt ab, die höheren Stufen gewinnen.
const LUCK_SHIFT := {"Common": -8.0, "Rare": 3.0, "Super Rare": 2.0, "Ultra Rare": 2.0, "Ultimate Rare": 1.0}
## Erfahrung, die Level n verlangt: 100, 300, 600, 1000, 1500 … (50 · n · (n + 1)).
static func xp_for_level(n: int) -> int:
return 50 * n * (n + 1)
## Level zu einer Erfahrung.
static func level_for(xp: int) -> int:
var n := 0
while xp >= xp_for_level(n + 1):
n += 1
return n
static func in_branch(branch: String) -> Array:
return SKILLS.keys().filter(func(id): return SKILLS[id].branch == branch)
## Boosterchancen je Stufe (Reihenfolge Cards.RARITIES) mit Packglück luck.
static func pack_weights(luck: int) -> PackedFloat32Array:
var out := PackedFloat32Array()
for rar in Cards.RARITIES:
out.append(float(Profile.RARITY_WEIGHTS[rar]) + luck * float(LUCK_SHIFT[rar]))
return out
## Konkrete Wirkung eines Skills auf einer Stufe, als Text.
static func effect(id: String, rank: int) -> String:
match id:
"packglueck":
var w := pack_weights(rank)
return TranslationServer.translate("Chancen %d/%d/%d/%d/%d %%") % [int(w[0]), int(w[1]), int(w[2]), int(w[3]), int(w[4])]
"staub":
return TranslationServer.translate("+%d %% Staub") % (25 * rank)
"muenzen":
return TranslationServer.translate("+%d %% Münzen") % (10 * rank)
"packpreis":
return TranslationServer.translate("Neutral %d, Element %d Münzen") % [roundi(Profile.BOOSTER_COST * (1.0 - 0.1 * rank)), roundi(Profile.ELEMENT_BOOSTER_COST * (1.0 - 0.1 * rank))]
"gratisziehen":
return TranslationServer.translate("zwei Karten je Zug gratis" if rank > 0 else "eine Karte je Zug gratis")
"startmana":
return TranslationServer.translate("erster Zug: %d Mana") % (Game.START_MANA + (2 if rank > 0 else 0))
return ""
+1
View File
@@ -0,0 +1 @@
uid://bbcmk1gtfjoim
-45
View File
@@ -1,45 +0,0 @@
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())
-1
View File
@@ -1 +0,0 @@
uid://cw4rtcukefdl8
+129 -37
View File
@@ -4,6 +4,8 @@ extends RefCounted
var screen # BattleScreen
var enabled := true
var banners: Array = [] # wartende Ansagen [Text, Farbe]
var banner_running := false
func _init(s) -> void:
@@ -25,45 +27,24 @@ func play_events(evs: Array, centers: Dictionary) -> void:
var atk: CreatureView = screen.tiles.get(ev.attacker.uid)
lunge(atk, center_of(ev.target, centers))
flash_target(ev.target)
shake()
delay = 0.12
"damage":
sfx("damage")
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)
popup(tr("Regeneration"), Style.GREEN, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
"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("click" if ev.get("hidden", false) else "play")
"reveal":
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)
popup(tr("Aufgedeckt!"), Style.GOLD, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
"fuse":
sfx("combo")
popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
"draw":
sfx("click")
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
@@ -73,9 +54,24 @@ func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
return c
## Mehrere Meldungen an derselben Stelle rücken nach unten, sonst liegen sie übereinander.
func _free_spot(at: Vector2) -> Vector2:
for step in 6:
var spot := at + Vector2(0, step * 26)
var taken := false
for l in screen.fx_layer.get_children():
if l is Label and l.get_meta("spot", Vector2(-999, -999)).distance_to(spot) < 20:
taken = true
break
if not taken:
return spot
return at
func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
if not enabled or at.x < 0:
return
at = _free_spot(at)
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 22)
@@ -84,6 +80,7 @@ func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
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
l.set_meta("spot", at)
screen.fx_layer.add_child(l)
l.reset_size()
l.position = at - l.size / 2
@@ -142,14 +139,31 @@ func pop_in(tile: CreatureView) -> void:
func fade_out(tile: CreatureView) -> void:
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
fade_view(tile)
## Blendet eine Ansicht aus und entfernt sie (Wesen, die sterben; Handkarten, die verschmelzen).
func fade_view(view: Control) -> void:
view.mouse_filter = Control.MOUSE_FILTER_IGNORE
if not enabled:
tile.queue_free()
view.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)
var tw := view.create_tween().set_parallel(true)
tw.tween_property(view, "modulate:a", 0.0, 0.3)
tw.tween_property(view, "scale", Vector2(0.6, 0.6), 0.3)
tw.chain().tween_callback(view.queue_free)
## Eine Ansicht gleitet von from an ihren Platz (die neue Handkarte nach dem Verschmelzen).
func glide(view: Control, from: Rect2) -> void:
if not enabled:
return
var to := view.position
view.position = from.position
view.scale = from.size / view.size
var tw := view.create_tween().set_parallel(true)
tw.tween_property(view, "position", to, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.tween_property(view, "scale", Vector2.ONE, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func move_to(tile: CreatureView, pos: Vector2) -> void:
@@ -157,3 +171,81 @@ func move_to(tile: CreatureView, pos: Vector2) -> void:
screen.create_tween().tween_property(tile, "position", pos, 0.2)
else:
tile.position = pos
## Ein Wesen fliegt von der Handkachel auf seinen Platz.
func fly_in(tile: CreatureView, from: Rect2) -> void:
if not enabled:
return
tile.busy = true
tile.position = from.position
tile.scale = from.size / tile.size
var tw := tile.create_tween().set_parallel(true)
tw.tween_property(tile, "position", tile.slot, 0.22).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.tween_property(tile, "scale", Vector2.ONE, 0.22).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tw.chain().tween_callback(func():
tile.busy = false
tile.position = tile.slot)
## Kurzes Zittern des Bretts beim Treffer.
func shake() -> void:
if not enabled:
return
var layer: Control = screen.tile_layer
var tw: Tween = screen.create_tween()
for i in 3:
tw.tween_property(layer, "position", Vector2(3 if i % 2 == 0 else -3, 0), 0.03)
tw.tween_property(layer, "position", Vector2.ZERO, 0.03)
## Ansage quer über die Mitte: Zugbeginn, Feldwechsel. Mehrere folgen nacheinander.
func banner(text: String, color: Color) -> void:
if not enabled:
return
banners.append([text, color])
if not banner_running:
_next_banner()
func _next_banner() -> void:
if banners.is_empty() or not is_instance_valid(screen) or not screen.is_inside_tree():
banner_running = false
return
banner_running = true
var next: Array = banners.pop_front()
var text: String = next[0]
var color: Color = next[1]
var delay := 0.0
var band := ColorRect.new()
band.color = Color(0, 0, 0, 0.55)
band.position = Vector2(0, 355)
band.size = Vector2(1280, 90)
band.mouse_filter = Control.MOUSE_FILTER_IGNORE
band.scale.y = 0.0
band.pivot_offset = Vector2(640, 45)
screen.fx_layer.add_child(band)
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 40)
l.add_theme_font_override("font", Style.FONT_BOLD)
l.add_theme_color_override("font_color", color)
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
l.add_theme_constant_override("outline_size", 8)
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
screen.fx_layer.add_child(l)
l.reset_size()
l.position = Vector2(640, 400) - l.size / 2
l.pivot_offset = l.size / 2
l.scale = Vector2(0.6, 0.6)
l.modulate.a = 0.0
var tw: Tween = screen.create_tween().set_parallel(true)
tw.tween_property(band, "scale:y", 1.0, 0.15).set_delay(delay).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.tween_property(l, "scale", Vector2.ONE, 0.25).set_delay(delay).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tw.tween_property(l, "modulate:a", 1.0, 0.12).set_delay(delay)
tw.tween_property(l, "modulate:a", 0.0, 0.3).set_delay(delay + 0.8)
tw.tween_property(band, "scale:y", 0.0, 0.2).set_delay(delay + 0.9)
tw.chain().tween_callback(func():
l.queue_free()
band.queue_free()
_next_banner())
+163
View File
@@ -0,0 +1,163 @@
class_name BattleGamepad
extends RefCounted
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Stapel, Hand, eigene Wesen
## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y öffnet das Kartenmenü (im
## Gegnerzug: überspringen), X macht rückgängig. Verschmelzen: im Menü „Verschmelzen mit …“,
## dann A auf dem Partner.
var screen # BattleScreen
var active := false
var zone := "deck"
var index := 0
var targets: Array = []
func _init(s) -> void:
screen = s
func button(button: int) -> void:
active = true
if screen.game == null:
return
match button:
JOY_BUTTON_START:
if screen.my_turn() and not screen.any_overlay():
screen.end_turn()
JOY_BUTTON_B:
if screen.confirm.visible:
screen.confirm.visible = false
screen.cancel_all()
zone = "deck"
JOY_BUTTON_Y:
if screen.b_skip.visible:
screen.skip_ai = true
elif screen.menu_layer.visible:
screen.close_menu()
else:
var items := items()
if not items.is_empty() and zone in ["hand", "own"]:
var item = items[clampi(index, 0, items.size() - 1)]
if zone == "hand":
screen.open_menu(item.card, Rect2(item.position, item.size), true)
else:
screen.open_menu(item.creature, Rect2(item.slot, item.size), true)
JOY_BUTTON_X:
if screen.b_undo.visible:
screen.undo()
JOY_BUTTON_A:
confirm()
JOY_BUTTON_DPAD_LEFT:
move(-1)
JOY_BUTTON_DPAD_RIGHT:
move(1)
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
if zone != "target":
_next_zone(1 if button == JOY_BUTTON_DPAD_DOWN else -1)
screen.refresh()
## Wechselt den Bereich und überspringt leere (eine leere Hand ist keine Station).
## step 0: beim aktuellen Bereich bleiben, falls er etwas enthält.
func _next_zone(step: int) -> void:
var order := ["deck", "hand", "own"]
var at := order.find(zone)
index = 0
if step == 0 and not items().is_empty():
return
var dir := -1 if step < 0 else 1
for i in order.size():
at = posmod(at + dir, order.size())
zone = order[at]
if not items().is_empty():
return
func items() -> Array:
match zone:
"deck":
return [screen.deck] if screen.deck.visible else []
"hand":
return screen.hand_views
"own":
return screen.own_views
"target":
return targets
return []
func move(dir: int) -> void:
var n := items().size()
if n > 0:
index = posmod(index + dir, n)
func confirm() -> void:
if screen.overlay.visible:
screen.leave()
return
if screen.confirm.visible:
return
var items := items()
if items.is_empty():
return
index = clampi(index, 0, items.size() - 1)
var item = items[index]
match zone:
"deck":
screen.on_click("deck", null)
"hand":
screen.on_click("hand", item.card)
"own":
screen.on_click("own", item.creature)
if screen.selected != null:
targets = []
for v in screen.enemy_views:
if screen.game.can_attack(screen.selected, v.creature)[0]:
targets.append(v)
for o in screen.others():
if o.alive() and screen.game.can_attack(screen.selected, o)[0]:
targets.append(screen.hero_panels[o])
zone = "target"
index = 0
"target":
if item is CreatureView:
screen.on_tile_click(item.creature)
else:
for o in screen.hero_panels:
if screen.hero_panels[o] == item:
screen.on_click("hero", o)
zone = "own"
_next_zone(0)
## Pulsierender Rahmen als Zeiger.
class Highlight extends Control:
var t := 0.0
func _process(delta: float) -> void:
t += delta
queue_redraw()
func _draw() -> void:
var a := 0.55 + 0.45 * sin(t * 6.0)
Style.border(self, Rect2(Vector2.ZERO, size), Color(Style.GOLD, a), 4, 8)
func draw_cursor() -> void:
Style.clear(screen.cursor_layer)
if not active or screen.game == null or not screen.my_turn():
return
var items := items()
if items.is_empty():
return
index = clampi(index, 0, items.size() - 1)
var item = items[index]
var r: Rect2 = Rect2(item.slot, item.size) if item is CreatureView else Rect2(item.position, item.size)
var hl := Highlight.new()
hl.position = r.position - Vector2(6, 6)
hl.size = r.size + Vector2(12, 12)
hl.mouse_filter = Control.MOUSE_FILTER_IGNORE
screen.cursor_layer.add_child(hl)
+1
View File
@@ -0,0 +1 @@
uid://8wth8k7f7uvk
+61 -28
View File
@@ -1,40 +1,73 @@
class_name BattleLayout
## Feste Bildschirmaufteilung des Kampfes (1280x800).
## Feste Bildschirmaufteilung des Duells (1280x800). Mitte von oben nach unten: Hand des Gegners
## (Rückseiten), sein Brett, eigenes Brett, eigene Hand. Links: Kasten des Gegners, daneben die
## Elementzähler beider Seiten je neben ihrem Brett, Zusatzzeile und Meldungen. Rechts von oben:
## Stapel und Kasten des Gegners, die Vorschau, eigener Kasten und Stapel. Optionen per Esc.
const SIZES := {1: [110, 110, 10], 2: [96, 110, 8], 3: [78, 100, 4], 4: [54, 72, 4], 5: [44, 62, 3]}
const HAND_W := 130
const HAND_H := 185
const HAND_GAP := 8
const BAR := Rect2(335, 415, 610, 68)
const OWN_X := (1280 - 5 * 110 - 4 * 10) / 2
static func compact(n_others: int) -> bool:
return n_others >= 4
static func hero_box_rect(n_others: int, i: int) -> Rect2:
var col_w := 1280 / n_others
var margin := 6 if compact(n_others) else 10
return Rect2(i * col_w + margin, 10, col_w - 2 * margin, 64)
const B_UNDO := Rect2(20, 10, 210, 32) # links oben: Rückgängig, darunter Überspringen (Gegnerzug)
const B_SKIP := Rect2(20, 48, 210, 32)
const OPP_DECK := Rect2(1090, 10, 130, 182) # Stapel des Gegners rechts oben
const OPP_BOX := Rect2(1050, 200, 210, 70) # Kasten des Gegners darunter
const OPP_HAND_Y := 22 # Bogen hebt die äußeren Rückseiten um bis zu 12 Pixel
const OPP_ELEMENTS := Rect2(20, 220, 210, 128) # Elementzähler des Gegners, links von seinem Brett
const OWN_ELEMENTS := Rect2(20, 440, 210, 128) # eigene Elementzähler, links vom eigenen Brett
const INFO := Rect2(20, 580, 210, 60) # Zusatzzeile zur Vorschau
const MSG := Rect2(20, 650, 210, 80) # Meldungen
const STATUS := Rect2(1050, 526, 210, 70) # eigener Kasten rechts über dem Stapel
const DECK := Rect2(1090, 606, 130, 182) # eigener Stapel rechts unten
const OPTIONS := Rect2(440, 180, 400, 420) # Optionsmenü (Esc)
const OWN_X := 295
const HAND_W := 130 # Handkarten so groß wie die Wesen auf dem Feld
const HAND_H := 165
const HAND_GAP := 6
const HAND_Y := 605
const HAND_ARC := 3.0 # Grad Neigung und Pixel Absenkung je Platz von der Mitte (leichter Bogen)
const LOG_PANEL := Rect2(240, 60, 800, 560)
const PREVIEW_SIZE := Vector2(170, 238)
const CENTER_X := 240
const CENTER_W := 800
static func own_slot(j: int) -> Rect2:
return Rect2(OWN_X + j * 120, 250, 110, 140)
return Rect2(OWN_X + j * 140, 410, HAND_W, HAND_H)
static func other_slot(n_others: int, i: int, j: int) -> Rect2:
var col_w := 1280 / n_others
var sz: Array = SIZES[n_others]
var bx: int = i * col_w + (col_w - 5 * sz[0] - 4 * sz[2]) / 2
return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1])
## Brett des Gegners über seiner Elementleiste.
static func opp_slot(j: int) -> Rect2:
return Rect2(OWN_X + j * 140, 205, HAND_W, HAND_H)
## Hand: alle Karten unten, mittig; passen sie nicht nebeneinander, überlappen sie sich
## gleichmäßig. Den Bogen legt hand_arc dazu.
static func hand_rect(count: int, i: int) -> Rect2:
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
var x := (1280 - total) / 2
return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H)
var step: float = HAND_W + HAND_GAP
if count > 1:
step = minf(step, float(CENTER_W - HAND_W) / (count - 1))
var total: float = HAND_W + (count - 1) * step
var x := CENTER_X + (CENTER_W - total) / 2
return Rect2(x + i * step, HAND_Y, HAND_W, HAND_H)
static func trap_rect(i: int) -> Rect2:
return Rect2(400 + i * 78, 490, 70, 98)
## Hand des Gegners oben, gleich groß, nur die Rückseiten.
static func opp_hand_rect(count: int, i: int) -> Rect2:
var r := hand_rect(count, i)
r.position.y = OPP_HAND_Y
return r
## Leichter Bogen: Karte i von count dreht sich um ihre Mitte und sinkt zum Rand hin ab
## (mirrored: für den Gegner oben spiegelverkehrt). Liefert [Grad, y-Versatz].
static func hand_arc(count: int, i: int, mirrored: bool = false) -> Array:
var t := i - (count - 1) / 2.0
var sign := -1.0 if mirrored else 1.0
return [t * HAND_ARC * sign, t * t * HAND_ARC * sign]
## Vorschau der gezeigten Karte: fest rechts zwischen den beiden Kästen.
static func preview_rect(_anchor: Rect2) -> Rect2:
return Rect2(Vector2(1070, 278), PREVIEW_SIZE)
## Zusatzzeile (Merkmale, Eigenschaften) links unter den Elementpaaren.
static func info_rect(_preview: Rect2) -> Rect2:
return INFO
File diff suppressed because it is too large. Load diff
+207 -43
View File
@@ -1,18 +1,32 @@
class_name BoosterScreen
extends Control
## Booster kaufen und öffnen: unten die Pack-Kacheln der offenen Kapitel (Neutral und die Elemente in
## Kapitelreihenfolge, gesperrte sind unsichtbar) mit ×1, ×5, ×10; die Karten fliegen aus dem Pack in ein Raster
## (ein Pack: fünf große, mehrere: zehn je Reihe, beste Stufe zuerst) und liegen verdeckt. Klick
## oder „Alle aufdecken“ dreht sie gestaffelt um, seltene blitzen auf; danach steht das Fazit.
const CARD_W := 200
const CARD_H := 280
const SMALL_W := 108
const SMALL_H := 151
const AREA := Rect2(40, 100, 1200, 460)
const QUANTITIES := [1, 5, 10]
var app
var cards: Array = []
var cards: Array = [] # {"card", "rarity", "new", "upgraded", "dust"} je Karte
var revealed := {}
var new_ids := {}
var coins_label: Label
var msg_label: Label
var b_buy: Button
var summary_label: Label
var b_buy: Button # Neutral ×1 (Tests)
var b_all: Button
var scroll: ScrollContainer
var slots: Control
var pack_tiles := {} # Element -> PackTile (Kapitelreihenfolge)
var pack_buttons := {} # Element -> der ×1-Knopf
var fresh := {} # gerade aufgedeckt: Index -> Verzögerung fürs Umdrehen
var fly_from := Vector2.ZERO # woher die Karten einfliegen (Mitte der gekauften Kachel)
var fly := false # beim nächsten refresh einfliegen
var last_pack := "" # zuletzt gekauftes Pack (pulsiert)
class CardBack extends Control:
@@ -22,37 +36,72 @@ class CardBack extends Control:
size = sz
custom_minimum_size = sz
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
focus_entered.connect(queue_redraw)
focus_exited.connect(queue_redraw)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit()
elif has_focus() and event.is_action_pressed("ui_accept"):
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)
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect)
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
Style.text(self, "?", 48 if size.x >= 180 else 30, Style.GOLD, size / 2, "center", true)
func slot_rect(i: int) -> Rect2:
var gap := 20
var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2
return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H)
## Pack-Kachel eines Elements: Symbol, Name, Preis; die Kaufknöpfe hängt der Bildschirm an.
class PackTile extends Control:
var element: String
var locked := false
var hint := ""
var pulse := false
var buy_buttons: Array = []
func _init(e: String, rect: Rect2) -> void:
element = e
position = rect.position
size = rect.size
pivot_offset = rect.size / 2
func _draw() -> void:
var col: Color = Cards.ELEMENT_COLORS[element]
draw_style_box(Style.box(Style.darker(col, 0.5), 10, Style.GOLD if pulse else Color(col, 0.7), 2), Rect2(Vector2.ZERO, size))
Style.element_icon(self, element, Vector2(32, 40), 34, Style.lighter(col, 0.4))
Style.text(self, tr(element), 16, Style.TEXT, Vector2(60, 30), "midleft", true)
Style.text(self, hint, 13, Style.MUTED, Vector2(60, 52), "midleft")
func _ready() -> void:
Style.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")
Style.header(self, "Booster", func(): app.show_screen(MenuScreen.new()))
coins_label = Style.chip(self, "", Rect2(860, 26, 240, 38))
scroll = ScrollContainer.new()
scroll.position = AREA.position
scroll.size = AREA.size
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
slots = Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(slots)
msg_label = Style.label(self, "", 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()))
slots.mouse_filter = Control.MOUSE_FILTER_PASS
slots.custom_minimum_size = AREA.size
scroll.add_child(slots)
summary_label = Style.label(self, "", 16, Style.GOLD, Rect2(0, 566, 1280, 24), "center", true)
b_all = Style.button(self, "Alle aufdecken", Rect2(540, 594, 200, 40), reveal_all)
for i in Campaign.CHAPTER_ELEMENTS.size():
var e: String = Campaign.CHAPTER_ELEMENTS[i]
var tile := PackTile.new(e, Rect2(70 + i * 230, 644, 220, 136))
add_child(tile)
for k in QUANTITIES.size():
var b := Style.button(tile, "×%d" % QUANTITIES[k], Rect2(10 + k * 68, 88, 64, 36), buy.bind(e, QUANTITIES[k]))
b.add_theme_font_size_override("font_size", 14)
tile.buy_buttons.append(b)
pack_tiles[e] = tile
pack_buttons[e] = tile.buy_buttons[0]
b_buy = pack_buttons[Cards.NEUTRAL]
refresh()
@@ -61,62 +110,177 @@ func _input(event: InputEvent) -> void:
app.show_screen(MenuScreen.new())
func buy() -> void:
## Kauft n Packs eines Elements; die Karten liegen verdeckt, mehrere Packs nach Stufe sortiert.
func buy(element: String, n: int) -> void:
var p: Profile = app.profile
var before: Dictionary = p.collection.duplicate()
var got: Array = p.open_booster()
var before: int = p.coins
var got: Array = p.open_boosters(element, n)
if got.is_empty():
msg_label.text = "Nicht genug Münzen"
msg_label.add_theme_color_override("font_color", Style.RED)
return
app.save()
app.sfx.play("play")
if n > 1:
got.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
cards = got
revealed = {}
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)
fresh = {}
fly = true
var tile: PackTile = pack_tiles[element]
fly_from = tile.position + tile.size / 2
last_pack = element
scroll.scroll_vertical = 0
refresh()
_count_coins(before, p.coins)
## Die Münzen zählen sichtbar herunter.
func _count_coins(from: int, to: int) -> void:
if not Style.animations or from == to:
return
create_tween().tween_method(func(v: float): coins_label.text = "Münzen: %d" % int(round(v)), float(from), float(to), 0.5)
func reveal(i: int) -> void:
revealed[i] = true
app.sfx.play("click")
fresh = {i: 0.0}
app.sfx.play("combo" if cards[i].rarity != "Common" else "click")
refresh()
## Deckt alles auf, von vorn nach hinten gestaffelt; ab Ultra Rare bleibt die Karte kurz allein.
func reveal_all() -> void:
for i in 5:
fresh = {}
var delay := 0.0
var step := 0.12 if cards.size() <= Profile.BOOSTER_SIZE else 0.06
for i in cards.size():
if not revealed.has(i):
fresh[i] = delay
delay += step
if Cards.RARITIES.find(cards[i].rarity) >= 3:
delay += 0.35
revealed[i] = true
app.sfx.play("play")
refresh()
## Umdrehen: die Karte wächst aus der Mitte; seltene Stufen blitzen in ihrer Farbe auf.
func flip(v: CardView, delay: float, rarity: String) -> void:
if not Style.animations:
return
v.scale.x = 0.0
var tw := create_tween()
tw.tween_interval(delay)
tw.tween_property(v, "scale:x", 1.0, 0.18).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
if rarity == "Common":
return
var burst := Style.panel(slots, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12)
burst.pivot_offset = burst.size / 2
burst.modulate.a = 0.0
var bt := create_tween()
bt.tween_interval(delay + 0.1)
bt.tween_property(burst, "modulate:a", 1.0, 0.05)
bt.parallel().tween_property(burst, "scale", Vector2(1.25, 1.25), 0.45).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
bt.tween_property(burst, "modulate:a", 0.0, 0.3)
bt.tween_callback(burst.queue_free)
## Einflug einer verdeckten Karte aus dem Pack auf ihren Platz, reihenweise gestaffelt.
func fly_in(back: Control, target: Vector2, i: int, per_row: int) -> void:
if not Style.animations:
return
back.position = fly_from - AREA.position - back.size / 2
back.scale = Vector2(0.4, 0.4)
var tw := create_tween().set_parallel()
var delay := (i / per_row) * 0.1 + (i % per_row) * 0.03
tw.tween_property(back, "position", target, 0.3).set_delay(delay).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.tween_property(back, "scale", Vector2.ONE, 0.3).set_delay(delay).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
## Platz der i-ten Karte: ein Pack fünf große in einer Reihe, sonst zehn kleine je Reihe.
func slot_rect(i: int) -> Rect2:
var single := cards.size() <= Profile.BOOSTER_SIZE
var w := CARD_W if single else SMALL_W
var h := CARD_H if single else SMALL_H
var per_row := 5 if single else 10
var gap := 20 if single else 12
var x0 := (AREA.size.x - per_row * w - (per_row - 1) * gap) / 2
var row_h := h + (60 if single else 44)
return Rect2(x0 + (i % per_row) * (w + gap), 10 + (i / per_row) * row_h, w, h)
## Fazit nach dem Aufdecken: neue Karten, Aufwertungen, Staub.
func summary() -> String:
if cards.is_empty() or revealed.size() < cards.size():
return ""
var fresh_n := 0
var up := 0
var dust := 0
for res in cards:
if res.get("new", false):
fresh_n += 1
elif res.upgraded:
up += 1
dust += int(res.dust)
return tr("%d neu · %d aufgewertet · +%d Staub") % [fresh_n, up, dust]
func refresh() -> void:
var p: Profile = app.profile
coins_label.text = "Münzen: %d" % p.coins
b_buy.disabled = p.coins < Profile.BOOSTER_COST
b_all.disabled = cards.is_empty() or revealed.size() >= 5
var open: Array = p.unlocked_elements()
var x0 := (1280 - open.size() * 220 - (open.size() - 1) * 10) / 2
for e in pack_tiles:
var tile: PackTile = pack_tiles[e]
tile.locked = not p.booster_unlocked(e)
tile.visible = not tile.locked
if not tile.locked:
tile.position = Vector2(x0 + open.find(e) * 230, 644)
tile.hint = tr("%d Münzen") % p.pack_price(e)
for k in QUANTITIES.size():
tile.buy_buttons[k].disabled = tile.locked or p.coins < p.pack_price(e) * QUANTITIES[k]
tile.pulse = e == last_pack and not cards.is_empty() and revealed.size() >= cards.size()
tile.queue_redraw()
b_all.disabled = cards.is_empty() or revealed.size() >= cards.size()
summary_label.text = summary()
Style.clear(slots)
for i in 5:
var single := cards.size() <= Profile.BOOSTER_SIZE
var per_row := 5 if single else 10
var n := maxi(cards.size(), Profile.BOOSTER_SIZE)
var rows := (n + per_row - 1) / per_row
slots.custom_minimum_size = Vector2(AREA.size.x, maxf(AREA.size.y, slot_rect(n - 1).end.y + 60))
for i in n:
var r := slot_rect(i)
if cards.is_empty():
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
elif revealed.has(i):
var c: Cards.Card = cards[i]
var v := CardView.new(c, r.size)
var res: Dictionary = cards[i]
var c: Cards.Card = res.card
var v := CardView.new(c.leveled(Cards.RARITIES.find(res.rarity)), r.size, false, res.rarity)
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)
if fresh.has(i):
flip(v, fresh[i], res.rarity)
var fs := 16 if single else 12
Style.label(slots, tr(res.rarity), fs, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x - 10, r.end.y + 6, r.size.x + 20, 20), "center", true)
var y2 := r.end.y + (30 if single else 24)
if res.get("new", false):
Style.label(slots, "Neu!", fs, Style.GOLD, Rect2(r.position.x, y2, r.size.x, 20), "center", true)
elif res.upgraded:
Style.label(slots, "Aufgewertet", fs, Style.GREEN, Rect2(r.position.x - 10, y2, r.size.x + 20, 20), "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")
Style.label(slots, "+%d Staub" % res.dust, fs - 2, Style.MUTED, Rect2(r.position.x, y2, r.size.x, 20), "center")
else:
var back := CardBack.new(r.size)
back.position = r.position
back.pivot_offset = r.size / 2
back.clicked.connect(reveal.bind(i))
slots.add_child(back)
if fly:
fly_in(back, r.position, i, per_row)
fly = false
fresh = {}
if rows > 0 and last_pack != "" and pack_tiles[last_pack].pulse and Style.animations:
var tile: PackTile = pack_tiles[last_pack]
var tw := tile.create_tween().set_loops(3)
tw.tween_property(tile, "scale", Vector2(1.04, 1.04), 0.35).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(tile, "scale", Vector2.ONE, 0.35).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+108 -41
View File
@@ -1,57 +1,124 @@
class_name CampaignScreen
extends Control
## Kampagne als Pfad: zehn Stationen je Kapitel (fünf Kapitel: Neutral, dann je ein Element),
## darunter die gewählte Stufe im Detail.
var app
var chapter := 1
var selected := 0 # gewählte Stufe (Index über alle Kapitel)
var perk_box: OptionButton
var node_buttons: Array = []
func _ready() -> void:
var p: Profile = app.profile
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]
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTER_ELEMENTS.size())
selected = mini(p.campaign_stage, Campaign.total() - 1)
build()
func build() -> void:
Style.clear(self)
node_buttons = []
var p: Profile = app.profile
Style.header(self, "Kampagne", func(): app.show_screen(MenuScreen.new()))
var n := Campaign.STAGES_PER_CHAPTER
var x := 40
for c in Campaign.CHAPTER_ELEMENTS.size():
var b := Style.button(self, "Kapitel %d" % (c + 1), Rect2(x, 88, 130, 38), set_chapter.bind(c + 1), Style.PRIMARY if chapter == c + 1 else null)
b.disabled = p.campaign_stage < c * n
x += 138
var element := Campaign.element_of(chapter)
Style.label(self, tr(element), 20, Cards.ELEMENT_COLORS[element], Rect2(0, 136, 1280, 30), "center", true)
if p.perk_unlocked():
Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(740, 96, 150, 24), "right")
perk_box = OptionButton.new()
perk_box.position = Vector2(900, 88)
perk_box.size = Vector2(340, 38)
perk_box.add_item("keins")
var keys: Array = Game.PERKS.keys()
for k in keys:
perk_box.add_item(Game.PERKS[k])
perk_box.select(keys.find(p.perk) + 1 if p.perk != "" else 0)
perk_box.item_selected.connect(func(i: int):
p.perk = "" if i == 0 else keys[i - 1]
app.save())
add_child(perk_box)
# Pfad mit Stationen
var offset := (chapter - 1) * n
var y := 190.0
var step := (1280 - 200) / float(n - 1)
var centers: Array[Vector2] = []
for k in n:
centers.append(Vector2(100 + k * step, y))
var path := Control.new()
path.mouse_filter = Control.MOUSE_FILTER_IGNORE
path.draw.connect(func():
for k in n - 1:
var done: bool = offset + k + 1 <= p.campaign_stage
path.draw_line(centers[k], centers[k + 1], Style.GREEN if done else Style.PANEL_LIGHT, 4.0))
add_child(path)
for k in n:
var i := offset + k
var 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()))
var b := Button.new()
b.text = str(i + 1)
b.position = centers[k] - Vector2(24, 24)
b.size = Vector2(48, 48)
var base: Color = Style.PRIMARY if done else (Color("5a4628") if next else Style.PANEL)
var edge: Color = Style.GOLD if i == selected else (Style.GREEN if done else Style.MUTED)
b.add_theme_stylebox_override("normal", Style.box(base, 24, edge, 3 if i == selected else 2))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 24, edge, 3))
b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 24, edge, 3))
b.add_theme_stylebox_override("disabled", Style.box(Style.darker(Style.PANEL, 0.8), 24, Style.PANEL_LIGHT, 2))
b.add_theme_stylebox_override("focus", Style.box(Color(0, 0, 0, 0), 24, Style.GOLD, 3))
b.disabled = i > p.campaign_stage
b.pressed.connect(func():
selected = i
build())
Style.juice(b)
add_child(b)
node_buttons.append(b)
if next and Style.animations:
b.pivot_offset = b.size / 2
var tw := b.create_tween().set_loops()
tw.tween_property(b, "scale", Vector2(1.1, 1.1), 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(b, "scale", Vector2.ONE, 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
# Gewählte Stufe
var st := Campaign.stage(selected)
var done := selected < p.campaign_stage
var next := selected == p.campaign_stage
Style.panel(self, Rect2(240, 270, 800, 300))
Style.label(self, "%s · %s" % [st.name, st.title], 28, Style.TEXT, Rect2(270, 290, 740, 40), "center", true)
Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 350, 740, 24), "center")
var perks: Array = st.perks
var py := 390
for k in perks:
Style.label(self, "★ " + tr(Game.PERKS[k]), 15, Style.GOLD, Rect2(270, py, 740, 22), "center")
py += 24
if done:
Style.button(self, "Wiederholen", Rect2(540, 500, 200, 50), _start.bind(selected))
elif next:
var b := Style.button(self, "Kämpfen", Rect2(540, 500, 200, 50), _start.bind(selected), Style.PRIMARY)
b.add_theme_font_size_override("font_size", 20)
else:
var b := Style.button(self, "Gesperrt", Rect2(540, 500, 200, 50), func(): pass)
b.disabled = true
if p.campaign_stage >= Campaign.total():
Style.label(self, "Alle Kapitel abgeschlossen", 16, Style.GREEN, Rect2(0, 600, 1280, 24), "center", true)
func set_chapter(c: int) -> void:
chapter = c
var p: Profile = app.profile
var first := (c - 1) * Campaign.STAGES_PER_CHAPTER
selected = p.campaign_stage if Campaign.chapter(mini(p.campaign_stage, Campaign.total() - 1)) == c else first
build()
func _start(stage: int) -> void:
app.show_screen(BattleScreen.new(0, stage))
app.show_screen(BattleScreen.new(stage))
func _input(event: InputEvent) -> void:
+10 -14
View File
@@ -1,14 +1,13 @@
class_name CardGrid
extends ScrollContainer
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte.
## Scrollbares Kartenraster.
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
const COLS := 6
const CW := 118
const CH := 165
const GAP := 10
var grid: GridContainer
@@ -17,6 +16,7 @@ func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
follow_focus = true # Gamepad: der fokussierten Karte hinterherscrollen
grid = GridContainer.new()
grid.columns = COLS
grid.add_theme_constant_override("h_separation", GAP)
@@ -24,20 +24,16 @@ func _init(rect: Rect2) -> void:
add_child(grid)
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool
func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
## cards: Karten; rarity_fn(card) -> String: die Stufe, die die Karte zeigt (Optik)
func fill(cards: Array, rarity_fn: Callable) -> void:
Style.clear(grid)
for c in cards:
var cell := Control.new()
cell.custom_minimum_size = Vector2(CW, CH + 16)
cell.custom_minimum_size = Vector2(CW, CH)
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
var v := CardView.new(c, Vector2(CW, CH))
v.dim = dim_fn.call(c)
var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c))
v.clicked.connect(func(_view): card_clicked.emit(c))
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)
+83 -37
View File
@@ -1,65 +1,111 @@
class_name CardView
extends Control
## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau).
## Oben Kosten (links) und Name, in der Mitte das Bild im Kreis, unten Angriff (links) und
## Leben (rechts). Die Elemente färben den Hintergrund (verschmolzen: ein Streifen je Element),
## das Bild trägt bei Verschmelzungen der stärkere Elternteil; die Seltenheit steckt in Name,
## Bild, Werten und Rahmen (siehe Style).
signal clicked(view: CardView)
signal pressed(view: CardView, pos: Vector2) # nur mit drag_enabled: der Kampf macht daraus Klick oder Ziehen
signal hover(card: Cards.Card, entered: bool)
var card: Cards.Card
var border = null # Color oder null (dann Seltenheitsfarbe)
var border = null # Color oder null (dann Rahmen der Seltenheitsstufe)
var rarity := "Common" # nur Optik
var dim := false
var lift := false # hebt sich beim Zeigen an (Hand)
var drag_enabled := false # Hand im Kampf: Drücken meldet pressed statt clicked
var drop := false # Ziel der gerade gezogenen Karte
var marked := false # Haken: die Karte ist im Stapel (Deck)
func _init(c: Cards.Card, sz: Vector2) -> void:
## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "Common") -> void:
card = c
rarity = rar
set_process(Style.rarity_animated(rarity)) # bewegter Glanz
custom_minimum_size = sz
size = sz
pivot_offset = sz / 2
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(card, true))
mouse_exited.connect(func(): hover.emit(card, false))
mouse_entered.connect(func():
hover.emit(card, true)
_lift(true))
mouse_exited.connect(func():
hover.emit(card, false)
_lift(false))
if focusable:
focus_mode = Control.FOCUS_ALL
focus_entered.connect(func():
hover.emit(card, true)
queue_redraw())
focus_exited.connect(func():
hover.emit(card, false)
queue_redraw())
func _lift(up: bool) -> void:
if not lift or not Style.animations:
return
z_index = 1 if up else 0
create_tween().tween_property(self, "scale", Vector2(1.07, 1.07) if up else Vector2.ONE, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func _process(_delta: float) -> void:
queue_redraw()
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
if drag_enabled:
pressed.emit(self, event.global_position)
else:
clicked.emit(self)
elif has_focus() and event.is_action_pressed("ui_accept"):
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)
var seed := hash(card.id)
var frame: Color = border if border != null else Style.rarity_frame(rarity, seed)
if rarity != "Common":
Style.glow(self, rect, Style.rarity_color(rarity, seed), 8, 8 if size.x >= 180 else 6)
Style.fill_elements(self, rect, card.elements, 8)
var bcol: Color = border if border != null else Cards.RARITY_COLORS[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)
var big := size.x >= 180
var mid := size.x >= 140
var f_name := 17 if big else (14 if mid else 11)
var f_stat := 20 if big else (17 if mid else 14)
var r_cost := 17.0 if big else (14.0 if mid else 12.0)
var r_stat := 18.0 if big else (15.0 if mid else 12.0)
var pad := 8.0 if mid else 6.0
var shiny := Style.rarity_stats_shiny(rarity)
# Oben: Kosten links, daneben der Name; das Namensfeld ist genau so hoch wie der Kristall
var top_h := r_cost * 2 + 4
draw_rect(Rect2(2, 2, size.x - 4, top_h), Color(0.05, 0.05, 0.08, 0.6))
Style.card_name(self, card.title(), f_name, rarity, Rect2(r_cost * 2 + 10, 2, size.x - r_cost * 2 - pad - 10, top_h), seed)
# Mitte: das Bild im Kreis, so groß wie der Platz bis zu den Werten erlaubt
var pic_top := top_h + 4
var pic_bottom := size.y - r_stat - 4
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
Style.picture(self, card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rarity, seed)
Style.rarity_sparkle(self, rect, rarity, seed) # vor den Werten, damit nichts die Zahlen überlagert
# Unten: Angriff links, Leben rechts
Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat, Color.WHITE, shiny)
Style.stat_circle(self, Vector2(size.x - r_stat + 2, size.y - r_stat + 2), r_stat, str(card.health), Style.LIFE, f_stat, Color.WHITE, shiny)
Style.card_sheen(self, rect, rarity, seed)
Style.border(self, rect, frame, 3 if big else 2, 8)
Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, card.cost, f_stat, shiny)
if marked:
var mc := Vector2(size.x / 2, size.y - r_stat + 2)
draw_circle(mc, r_stat * 0.6, Style.GREEN)
Style.text(self, "✓", f_stat - 4, Color.WHITE, mc, "center", true)
if dim:
draw_rect(rect, Color(0, 0, 0, 0.47))
if has_focus():
Style.border(self, rect, Style.GOLD, 4, 8)
if drop:
Style.border(self, rect, Style.GOLD, 5, 8)
+69
View File
@@ -0,0 +1,69 @@
class_name CheatScreen
extends Control
## Cheats zum Ausprobieren: Münzen, Staub, Level, Skills, Kampagnenkapitel und Sammlung direkt setzen.
## Jeder Knopf speichert sofort; oben steht der aktuelle Stand.
var app
var status_label: Label
var msg_label: Label
func _ready() -> void:
Style.header(self, "Cheats", func(): app.show_screen(MenuScreen.new()))
status_label = Style.label(self, "", 16, Style.GOLD, Rect2(40, 90, 1200, 48), "left", true)
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD
var groups := [
["Währung", [["+1.000 Münzen", func(): app.profile.coins += 1000], ["+10.000 Münzen", func(): app.profile.coins += 10000],
["+1.000 Staub", func(): app.profile.dust += 1000], ["+10.000 Staub", func(): app.profile.dust += 10000]]],
["Erfahrung", [["+1 Level", func(): app.profile.xp = Skills.xp_for_level(app.profile.player_level() + 1)],
["+5 Level", func(): app.profile.xp = Skills.xp_for_level(app.profile.player_level() + 5)],
["Skills zurücksetzen", func(): app.profile.skills = {}], ["Erfahrung auf 0", func():
app.profile.xp = 0
app.profile.skills = {}]]],
["Kampagne", [["Kapitel 2 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 10)],
["Kapitel 3 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 20)],
["Kapitel 4 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 30)],
["Kapitel 5 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 40)],
["Kampagne zurücksetzen", func(): app.profile.campaign_stage = 0]]],
["Sammlung", [["Alle Karten (Common)", func(): _give_all(0)], ["Alle Karten Ultimate Rare", func(): _give_all(4)],
["Eigene Karten +1 Stufe", func():
for id in app.profile.collection:
app.profile.collection[id] = mini(int(app.profile.collection[id]) + 1, Cards.RARITIES.size() - 1)],
["Sammlung zurücksetzen", func(): app.profile.give_start()]]],
]
var y := 160
for g in groups:
Style.label(self, g[0], 18, Style.TEXT, Rect2(40, y, 200, 30), "left", true)
var x := 250
for entry in g[1]:
var b := Style.button(self, entry[0], Rect2(x, y - 4, 190, 40), _apply.bind(entry[1]))
b.add_theme_font_size_override("font_size", 14)
x += 198
y += 70
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(40, y + 10, 1200, 24), "left")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
## Alle Karten der Sammlung geben, in Stufe level; der Stapel bleibt.
func _give_all(level: int) -> void:
for c in Cards.all():
app.profile.collection[c.id] = level
func _apply(action: Callable) -> void:
action.call()
app.save()
app.sfx.play("click")
msg_label.text = tr("Gespeichert")
refresh()
func refresh() -> void:
var p: Profile = app.profile
status_label.text = tr("Münzen %d · Staub %d · EP %d (Level %d, %d Skillpunkte frei) · Kampagne %d/%d · Sammlung %d Karten, Stapel %d") % [
p.coins, p.dust, p.xp, p.player_level(), p.skill_points(), p.campaign_stage, Campaign.total(), p.collection.size(), p.deck.size()]
+1
View File
@@ -0,0 +1 @@
uid://dluwr50n0sd17
+175
View File
@@ -0,0 +1,175 @@
class_name CollectionScreen
extends Control
## Sammlung: alle Karten des Spiels, die fehlenden abgedunkelt. Eigene Karten lassen sich
## aufwerten (Staub) oder zerlegen (bringt Staub), fehlende herstellen (Staub).
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_upgrade: Button
var b_craft: Button
func _ready() -> void:
Style.header(self, "Sammlung", func(): app.show_screen(MenuScreen.new()))
Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
var filters: Array = app.profile.unlocked_elements() # nur Elemente offener Kapitel
for i in filters.size():
var e: String = filters[i]
var fb := Style.button(self, e, Rect2(108 + i * 82, 86, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14)
search_box = LineEdit.new()
search_box.position = Vector2(526, 86)
search_box.size = Vector2(190, 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(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
grid.card_clicked.connect(select)
add_child(grid)
Style.panel(self, Rect2(820, 86, 420, 696))
dust_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
preview_holder = Control.new()
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(preview_holder)
info_label = Style.label(self, "", 15, Style.MUTED, Rect2(840, 476, 380, 30), "center")
b_upgrade = Style.button(self, "", Rect2(840, 530, 380, 46), upgrade, Style.PRIMARY)
b_dismantle = Style.button(self, "", Rect2(840, 586, 380, 46), dismantle, Color("5a3c28"))
b_craft = Style.button(self, "", Rect2(840, 530, 380, 46), craft, Style.PRIMARY)
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(840, 650, 380, 48), "center")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel") and not search_box.has_focus():
app.show_screen(MenuScreen.new())
func set_filter(e: String) -> void:
filter = e
refresh()
## Karten der offenen Elemente: eigene zuerst (höhere Stufen vorn), dann die fehlenden, sonst in
## Kartenreihenfolge.
func cards() -> Array:
var out: Array = app.profile.unlocked_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.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
var p: Profile = app.profile
var order := {}
for i in out.size():
order[out[i].id] = i
out.sort_custom(func(a, b):
if p.owns(a.id) != p.owns(b.id):
return p.owns(a.id)
return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id])
return out
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 upgrade() -> void:
if selected == null:
return
var err: String = app.profile.upgrade(selected.id)
if err == "":
app.sfx.play("play")
app.save()
say(tr("%s ist jetzt %s") % [selected.title(), tr(app.profile.rarity(selected.id))], true)
else:
say(err, false)
refresh()
func dismantle() -> void:
if selected == null:
return
var lvl: int = app.profile.level(selected.id)
var err: String = app.profile.dismantle(selected.id)
if err == "":
app.sfx.play("click")
app.save()
say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]], 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("combo")
app.save()
say(tr("%s hergestellt") % selected.title(), true)
else:
say(err, false)
refresh()
func refresh() -> void:
var p: Profile = app.profile
dust_label.text = "Staub: %d" % p.dust
var shown := cards()
grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
var views := grid.views()
for i in views.size():
views[i].dim = not p.owns(shown[i].id)
Style.clear(preview_holder)
b_craft.visible = false
b_upgrade.visible = true
b_dismantle.visible = true
if selected == null:
info_label.text = "Keine Karte gewählt"
b_upgrade.text = "Aufwerten"
b_dismantle.text = "Zerlegen"
b_upgrade.disabled = true
b_dismantle.disabled = true
return
var v := CardView.new(selected.leveled(p.level(selected.id)), Vector2(220, 308), false, p.rarity(selected.id))
v.position = Vector2(920, 152)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
var lvl := p.level(selected.id)
if not p.owns(selected.id):
info_label.text = tr("Nicht in der Sammlung")
b_upgrade.visible = false
b_dismantle.visible = false
b_craft.visible = true
b_craft.text = tr("Herstellen (%d Staub)") % Profile.CRAFT_COST
b_craft.disabled = p.dust < Profile.CRAFT_COST
return
info_label.text = "%s · %s" % [tr(selected.combo()), tr(p.rarity(selected.id))]
if lvl >= Cards.RARITIES.size() - 1:
b_upgrade.text = "Höchste Stufe"
b_upgrade.disabled = true
else:
var next: String = Cards.RARITIES[lvl + 1]
b_upgrade.text = "Aufwerten zu %s (%d Staub)" % [tr(next), Profile.DUST_UPGRADE[next]]
b_upgrade.disabled = p.dust < Profile.DUST_UPGRADE[next]
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]
b_dismantle.disabled = false
+1
View File
@@ -0,0 +1 @@
uid://dsqeag3p0ehbq
+100 -52
View File
@@ -2,7 +2,8 @@ class_name CreatureView
extends Control
## Zeichnet ein Wesen auf dem Feld. Bleibt über Zustandsänderungen bestehen (für Animationen).
signal clicked(view: CreatureView)
signal pressed(view: CreatureView, pos: Vector2) # linke Maustaste gedrückt; der Kampf macht daraus Klick oder Ziehen
signal double_clicked(view: CreatureView) # Doppelklick: eigenes Wesen greift das Leben des Gegners an
signal hover(card: Cards.Card, entered: bool)
var creature: Game.Creature
@@ -11,6 +12,11 @@ var width := 2
var flash := false # kurz rot: wurde gerade angegriffen
var slot: Vector2 # Zielposition im Raster
var busy := false # wird gerade animiert (kein Neupositionieren)
var matchup := 0 # fremdes Wesen: sein Konter gegen den gewählten Angreifer (Game.matchup_bonus); die Werte zeigen, was ihn erwartet
var attack_shift := 0 # eigener Angreifer: Elementvorteil gegen das Ziel unter der Maus
var doomed := false # stirbt bei dem Angriff, der gerade unter der Maus liegt
var secret := false # verdecktes fremdes Wesen: nur der Rücken ist zu sehen
var drop := false # Ziel der gerade gezogenen Karte
func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
@@ -20,73 +26,115 @@ func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
custom_minimum_size = sz
size = sz
pivot_offset = sz / 2
set_process(Style.rarity_animated(rarity())) # bewegter Glanz
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(creature.card, true))
mouse_exited.connect(func(): hover.emit(creature.card, false))
func _process(_delta: float) -> void:
queue_redraw()
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit(self)
if event.double_click:
double_clicked.emit(self)
else:
pressed.emit(self, event.global_position)
func rarity() -> String:
var owner = creature.owner()
return creature.card.rarity_in(owner.looks if owner != null else {})
func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size)
if secret:
_draw_back(rect)
_draw_drop()
return
var small := size.x < 100
var mini := size.x < 60
var rar := rarity()
var seed := hash(creature.card.id) + creature.uid
if rar != "Common":
Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 6)
Style.fill_elements(self, rect, creature.card.elements, 6)
var bcol: Color = border if border != null else Cards.RARITY_COLORS[creature.card.rarity]
var bcol: Color = border if border != null else Style.rarity_frame(rar, seed)
if flash:
bcol = Style.RED
var shiny := Style.rarity_stats_shiny(rar)
# Oben: Kosten links, daneben der Name; das Namensfeld ist genau so hoch wie der Kristall
var fs := 12 if small else 13
var r_cost := 10.0 if small else 11.0
var top_h := r_cost * 2 + 4
draw_rect(Rect2(2, 2, size.x - 4, top_h), Color(0.05, 0.05, 0.08, 0.6))
Style.card_name(self, creature.card.title(), fs, rar, Rect2(r_cost * 2 + 10, 2, size.x - r_cost * 2 - 16, top_h), seed)
# Mitte: das Bild im Kreis; unten Angriff links, Leben rechts
var r_stat := 12.0 if small else 14.0
var pic_top := top_h + 4
var pic_bottom := size.y - r_stat - 4
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
Style.picture(self, creature.card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rar, seed)
# Unter dem Namen links die Eigenschaften (in der Farbe des Elements, das sie gibt)
var icon := 6.0 if small else 7.0
var y := top_h + 6 + icon
for k in creature.keywords():
var col := Color("f2f2f6")
for e in creature.card.elements:
if Cards.ELEMENT_KEYWORD.get(e, "") == k:
col = Style.lighter(Cards.ELEMENT_COLORS[e], 0.45)
Style.keyword_icon(self, k, Vector2(6 + icon, y), icon, col)
y += icon * 2 + 6
Style.rarity_sparkle(self, rect, rar, seed) # vor den Werten, damit nichts die Zahlen überlagert
_draw_stats(15 if small else 17, r_stat, shiny)
Style.card_sheen(self, rect, rar, seed)
Style.border(self, rect, bcol, 4 if flash else width, 6)
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))
Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, creature.card.cost, 13, shiny)
if creature.hidden:
draw_rect(rect, Color(0.05, 0.05, 0.1, 0.55))
Style.text_outlined(self, tr("verdeckt"), 12, Style.TEXT, Vector2(size.x / 2, size.y / 2), "center", 3)
if doomed:
draw_rect(Rect2(0, size.y * 0.5 - 12, size.x, 24), Color(0.05, 0.02, 0.02, 0.8))
Style.text_outlined(self, tr("stirbt"), 14, Style.RED, Vector2(size.x / 2, size.y * 0.5), "center", 3)
_draw_drop()
## 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))
## Goldener Rahmen, solange eine Karte zum Verschmelzen darüber schwebt.
func _draw_drop() -> void:
if drop:
Style.border(self, Rect2(Vector2.ZERO, size), Style.GOLD, 5, 6)
## Verdecktes fremdes Wesen: ein Kartenrücken.
func _draw_back(rect: Rect2) -> void:
var bcol: Color = border if border != null else Style.GOLD
if flash:
bcol = Style.RED
draw_style_box(Style.box(Color("3c285a"), 6, bcol, 4 if flash else maxi(width, 2)), rect)
Style.border(self, rect.grow(-10), Color("5a3c82"), 2, 6)
Style.text(self, "?", 36, Style.GOLD, size / 2, "center", true)
## Angriff und Leben für den Angriff, der gerade vorbereitet wird. Fremdes Wesen bei gewähltem
## Angreifer: sein Konter gegen ihn (im Nachteil 1 weniger, im Vorteil 1 mehr, auch bei 0 Angriff),
## das Leben bleibt echt. Eigener Angreifer über einem Ziel: sein Angriff mit Elementvorteil.
func shown_stats() -> Array:
if matchup != 0:
return [maxi(0, creature.power() + matchup), creature.health]
if attack_shift != 0:
return [maxi(0, creature.power() + attack_shift), creature.health]
return [creature.power(), creature.health]
## Angriff gelb, Leben blau; die Zahl wird grün, wenn sie für den aktuellen Angriff steigt, rot, wenn sie sinkt.
func _draw_stats(f: int, r: float, shiny: bool) -> void:
var shown := shown_stats()
var atk: int = shown[0]
var hp: int = shown[1]
var atk_text := Style.GREEN if atk > creature.power() else (Style.RED if atk < creature.power() else Color.WHITE)
var hp_text := Style.GREEN if hp > creature.health else (Style.RED if hp < creature.health else Color.WHITE)
Style.stat_circle(self, Vector2(r - 1, size.y - r + 1), r, str(atk), Style.AMBER, f, atk_text, shiny)
Style.stat_circle(self, Vector2(size.x - r + 1, size.y - r + 1), r, str(hp), Style.LIFE, f, hp_text, shiny)
+207 -292
View File
@@ -1,351 +1,266 @@
class_name DeckScreen
extends Control
## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten.
## Deck zusammenstellen: alle eigenen Karten im Raster (Filter nach Element und Mana, Stapelkarten
## zuerst, dann höchste Stufen vorn), ein Klick nimmt eine Karte in den Stapel oder wieder heraus. Stapelkarten tragen einen Haken, die anderen sind
## abgedunkelt. Rechts Stapelgröße, Stärke (mittlerer Kartenwert, 100 Grundkarten … 500 Ultimate
## Rare), Manakurve, Elementanteile und die Vorschau der gezeigten Karte. Der Stapel hält DECK_MIN
## bis DECK_MAX Karten; „Auffüllen“ nimmt die wertvollsten eigenen Karten des gewählten Filters.
const SORTS := ["Kosten", "Name", "Element", "Seltenheit"]
const PREVIEW := Rect2(940, 362, 180, 252)
const MAX_COST := 10
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 filter := "" # Element oder ""
var cost_filter := -1 # Kosten 0 bis 10 oder -1
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
var all_button: Button
var element_buttons := {} # Element -> Knopf mit Symbol
var cost_buttons: Array = [] # je Kosten ein Knopf
var count_label: Label
var strength_label: Label
var curve: CurveView
var share: ElementShare
var preview_holder: Control
var shown_card: Cards.Card = null # Karte in der Vorschau
var msg_label: Label
class DeckRow extends Control:
signal clicked(card)
signal hover(card, entered: bool)
var card: Cards.Card
var count: int
var lit := false
## Filterknopf mit dem Symbol eines Elements.
class ElementButton extends Button:
var element: String
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 _init(e: String) -> void:
element = e
tooltip_text = tr(e)
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)
Style.element_icon(self, element, size / 2, size.y * 0.6, Style.lighter(Cards.ELEMENT_COLORS[element], 0.4))
class EnergyCurve extends Control:
var cards: Array = []
## Manakurve: ein Balken je Kosten 0 bis 10 mit der Zahl der Stapelkarten.
class CurveView extends Control:
var counts: Array = []
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_counts(c: Array) -> void:
counts = c
queue_redraw()
func _draw() -> void:
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")
var n := 11
var gap := 4.0
var bw := (size.x - gap * (n - 1)) / n
var top := 16.0
var base := size.y - 16.0
var mx := 1
for v in counts:
mx = maxi(mx, int(v))
for i in n:
var v: int = int(counts[i]) if i < counts.size() else 0
var x := i * (bw + gap)
var h := (base - top) * v / mx
draw_rect(Rect2(x, base - 2, bw, 2), Style.PANEL_LIGHT)
if v > 0:
draw_rect(Rect2(x, base - h, bw, h), Style.BLUE)
Style.text(self, str(v), 11, Style.TEXT, Vector2(x + bw / 2, base - h - 8), "center", true)
Style.text(self, str(i), 11, Style.MUTED, Vector2(x + bw / 2, base + 8), "center")
## Elementanteile des Stapels: je Element eine farbige Kachel mit der Kartenzahl.
class ElementShare extends Control:
var counts := {}
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_counts(c: Dictionary) -> void:
counts = c
queue_redraw()
func shown() -> Array:
return ElementView.all().filter(func(e): return int(counts.get(e, 0)) > 0)
func _draw() -> void:
var items := shown()
if items.is_empty():
return
var w := size.x / items.size()
for i in items.size():
var e: String = items[i]
var col: Color = Cards.ELEMENT_COLORS[e]
var r := Rect2(i * w + 2, 0, w - 4, size.y)
draw_style_box(Style.box(Color(col, 0.25), 6, Color(col, 0.7), 1), r)
Style.text(self, "%s %d" % [tr(e), int(counts[e])], 12, Style.TEXT, r.get_center(), "center", true)
func _ready() -> void:
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)
Style.header(self, "Deck", func(): app.show_screen(MenuScreen.new()))
all_button = Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
var filters: Array = Cards.ELEMENTS + [Cards.NEUTRAL]
for i in filters.size():
var e: String = filters[i]
var fb := ElementButton.new(e)
fb.position = Vector2(108 + i * 40, 86)
fb.size = Vector2(34, 34)
fb.pressed.connect(set_filter.bind(e))
Style.juice(fb)
add_child(fb)
element_buttons[e] = fb
for c in MAX_COST + 1:
var cb := Style.button(self, str(c), Rect2(330 + c * 40, 86, 36, 34), set_cost.bind(c))
cb.add_theme_font_size_override("font_size", 14)
cost_buttons.append(cb)
grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
grid.card_clicked.connect(toggle)
add_child(grid)
# 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
Style.panel(self, Rect2(820, 86, 420, 696))
count_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
strength_label = Style.chip(self, "", Rect2(860, 144, 340, 36), Style.GOLD)
Style.label(self, "Manakurve", 13, Style.MUTED, Rect2(850, 190, 360, 20), "center")
curve = CurveView.new(Rect2(850, 212, 360, 100))
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)
share = ElementShare.new(Rect2(850, 322, 360, 30))
add_child(share)
preview_holder = Control.new()
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(preview_holder)
Style.button(self, "Auffüllen", Rect2(840, 626, 380, 44), fill_up, Style.PRIMARY)
Style.button(self, "Leeren", Rect2(840, 678, 380, 40), clear_deck)
msg_label = Style.label(self, "", 14, Style.MUTED, Rect2(840, 726, 380, 48), "center")
msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD
refresh()
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():
if event.is_action_pressed("ui_cancel"):
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)
## Manafilter: derselbe Knopf noch einmal hebt ihn auf.
func set_cost(c: int) -> void:
cost_filter = -1 if cost_filter == c else c
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)
## Hebt den aktiven Filterknopf hervor.
func _mark(b: Button, active: bool, base: Color) -> void:
b.add_theme_stylebox_override("normal", Style.box(Style.lighter(base) if active else base, 8, Style.GOLD if active else Style.MUTED, 2 if active else 1))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 8, Style.GOLD if active else Style.MUTED, 2))
b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 8, Style.GOLD if active else Style.MUTED, 2))
func toggle(card: Cards.Card) -> void:
var err: String = app.profile.toggle_deck(card.id)
say(err)
if err == "":
app.sfx.play("click")
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()
## Fehler rot, sonst der Hinweis.
func say(err: String) -> void:
msg_label.text = tr(err) if err != "" else tr("Klick auf eine Karte: rein oder raus.")
msg_label.add_theme_color_override("font_color", Style.RED if err != "" else Style.MUTED)
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()
## Füllt mit den wertvollsten eigenen Karten des gewählten Filters bis zum Höchstwert auf.
func fill_up() -> void:
var p: Profile = app.profile
var pool: Array = p.collection.keys()
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)
pool = pool.filter(func(id): return filter in Cards.by_id(id).elements)
p.deck = DeckGen.fill(p.deck, pool, Game.DECK_MAX, p.collection)
app.save()
refresh()
func clear_deck() -> void:
deck = []
app.profile.deck = []
app.save()
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()
## Sortierschlüssel einer Karte: im Stapel zuerst, dann höchste Stufen vorn, dann Kosten, dann ID.
func sort_key(c: Cards.Card) -> Array:
var p: Profile = app.profile
return [0 if c.id in p.deck else 1, -p.level(c.id), c.cost, c.id]
func deck_cards() -> Array:
var out := []
for cid in deck:
out.append(Cards.by_id(cid))
## Eigene Karten nach Element- und Manafilter, sortiert (sort_key).
func cards() -> Array:
var p: Profile = app.profile
var out: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
if filter != "":
out = out.filter(func(c): return filter in c.elements)
if cost_filter >= 0:
out = out.filter(func(c): return c.cost == cost_filter)
out.sort_custom(func(a, b): return sort_key(a) < sort_key(b))
return out
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
## Vorschau der gezeigten Karte in ihrer Stufe.
func show_card(card: Cards.Card) -> void:
var p: Profile = app.profile
shown_card = card
Style.clear(preview_holder)
var v := CardView.new(card.leveled(p.level(card.id)), PREVIEW.size, false, p.rarity(card.id))
v.position = PREVIEW.position
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
func refresh() -> void:
var p: Profile = app.profile
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()
count_label.text = tr("Stapel: %d Karten (%d bis %d)") % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX]
count_label.add_theme_color_override("font_color", Style.RED if p.deck.size() < Game.DECK_MIN else Style.BLUE)
strength_label.text = tr("Stärke %d") % DeckGen.strength(p.deck, p.collection)
_mark(all_button, filter == "", Style.PANEL_LIGHT)
var open: Array = p.unlocked_elements() # nur Elemente offener Kapitel
for e in element_buttons:
var eb: Button = element_buttons[e]
eb.visible = e in open
if eb.visible:
eb.position = Vector2(108 + open.find(e) * 40, 86)
_mark(eb, filter == e, Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
for c in cost_buttons.size():
_mark(cost_buttons[c], cost_filter == c, Style.PANEL_LIGHT)
var counts := []
counts.resize(11)
counts.fill(0)
var per_element := {}
for id in p.deck:
var c := Cards.by_id(id)
counts[clampi(c.cost, 0, 10)] += 1
per_element[c.elements[0]] = int(per_element.get(c.elements[0], 0)) + 1
curve.set_counts(counts)
share.set_counts(per_element)
if msg_label.text == "":
say("")
var shown := cards()
grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
var views := grid.views()
for i in views.size():
var in_deck: bool = shown[i].id in p.deck
views[i].dim = not in_deck
views[i].marked = in_deck
views[i].hover.connect(func(c, entered):
if entered:
show_card(c))
if shown_card != null and p.owns(shown_card.id):
show_card(shown_card)
elif shown.is_empty():
Style.clear(preview_holder)
else:
show_card(shown[0])
+1 -1
View File
@@ -1 +1 @@
uid://yg8pbj56mib5
uid://ctt36od26uyea
+65
View File
@@ -0,0 +1,65 @@
class_name ElementView
extends Control
## Elementpaare einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente mit offenen Wesen
## da: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je offenem Wesen, beide:
## Effekt aktiv; Neutral hat keinen) und die Zahl der offenen Wesen.
const ROW := 32.0
var own := {} # offene eigene Wesen je Element
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
for e in all():
own[e] = 0
static func all() -> Array:
return Cards.ELEMENTS + [Cards.NEUTRAL]
func update_from(_g: Game, me: Game.Player) -> void:
for e in all():
own[e] = me.element_count(e) if me != null else 0
queue_redraw()
func paired(e: String) -> bool:
return Cards.ELEMENT_KEYWORD.has(e) and int(own.get(e, 0)) >= Game.ELEMENT_PAIR
## Elemente mit offenen Wesen.
func shown() -> Array:
return all().filter(func(e): return int(own.get(e, 0)) > 0)
## Der Inhalt als Text (Tests, Protokoll): Element und offene Wesen, dazu der Effekt.
func text() -> String:
var parts: Array[String] = []
for e in shown():
var s := "%s %d" % [tr(e), int(own.get(e, 0))]
if paired(e):
s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e])
parts.append(s)
return " · ".join(parts)
func _draw() -> void:
var y := ROW / 2
for e in shown():
var col: Color = Cards.ELEMENT_COLORS[e]
var active := paired(e)
draw_style_box(Style.box(Color(col, 0.18 if active else 0.1), 6, Color(col, 0.6 if active else 0.0), 1 if active else 0), Rect2(0, y - ROW / 2 + 2, size.x, ROW - 4))
var px := 10.0
Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, y), "midleft", active)
px = 76
if Cards.ELEMENT_KEYWORD.has(e):
for k in Game.ELEMENT_PAIR:
var filled := k < int(own.get(e, 0))
Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, y), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3))
px += Game.ELEMENT_PAIR * 24 + 8
Style.text(self, str(int(own.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true)
y += ROW
+1
View File
@@ -0,0 +1 @@
uid://c2tr1bg65rhk6
-111
View File
@@ -1,111 +0,0 @@
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))
-1
View File
@@ -1 +0,0 @@
uid://crlne4y0uiygl
-112
View File
@@ -1,112 +0,0 @@
class_name LobbyScreen
extends Control
## Netzwerkspiel im lokalen Netz: Spiel eröffnen oder beitreten.
var app
var net: Net
var status_label: Label
var list_label: Label
var ip_box: LineEdit
var b_host: Button
var b_join: Button
var b_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()
-1
View File
@@ -1 +0,0 @@
uid://bhnnxjh6rsxnf
+91 -14
View File
@@ -5,10 +5,10 @@ var profile: Profile
var screen: Control
var save_path: String = Profile.SAVE_PATH
var sfx: Sfx
var net: Net
func _ready() -> void:
_map_gamepad()
theme = Style.make_theme()
var bg := Style.Backdrop.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -16,16 +16,25 @@ func _ready() -> void:
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()
apply_settings()
show_screen(MenuScreen.new())
func apply_settings() -> void:
sfx.set_volume(profile.settings.volume)
sfx.set_music_volume(profile.settings.music)
set_language(profile.settings.language)
Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0
Style.animations = bool(profile.settings.get("animations", true))
theme = Style.make_theme()
## Neues Profil: Sammlung und Fortschritt werden verworfen, zurück bleiben die neutralen Karten.
func reset_profile() -> void:
profile = Profile.starter()
save()
apply_settings()
show_screen(MenuScreen.new())
@@ -33,20 +42,88 @@ func set_language(code: String) -> void:
TranslationServer.set_locale(code)
func show_screen(s: Control) -> void:
var kept: Array = [] # vorige Bildschirme, zu denen back() zurückführt (Einstellungen aus dem Kampf)
## keep: der aktuelle Bildschirm bleibt erhalten und kommt mit back() zurück.
func show_screen(s: Control, keep: bool = false) -> void:
if screen != null:
screen.queue_free()
if keep:
remove_child(screen)
kept.append(screen)
else:
screen.queue_free()
for k in kept:
k.queue_free()
kept.clear()
screen = s
s.app = self
s.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(s)
if Style.animations:
s.modulate.a = 0.0
create_tween().tween_property(s, "modulate:a", 1.0, 0.15)
if pad_mode:
focus_first_button.call_deferred()
var pad_mode := false # zuletzt Gamepad benutzt: Fokusrahmen sichtbar, Steuerkreuz wechselt Knöpfe
## Gamepad in Menüs: erster Tastendruck setzt den Fokus, Maus nimmt ihn wieder weg.
func _input(event: InputEvent) -> void:
if event is InputEventJoypadButton or (event is InputEventJoypadMotion and absf(event.axis_value) > 0.5):
if not pad_mode:
pad_mode = true
if get_viewport().gui_get_focus_owner() == null:
focus_first_button()
elif event is InputEventMouseMotion and pad_mode:
pad_mode = false
get_viewport().gui_release_focus()
## Gamepad-Tasten für die Menünavigation: A bestätigt, B bricht ab (falls nicht schon zugeordnet).
func _map_gamepad() -> void:
for pair in [["ui_accept", JOY_BUTTON_A], ["ui_cancel", JOY_BUTTON_B]]:
var action: String = pair[0]
if not InputMap.has_action(action):
InputMap.add_action(action)
if not InputMap.action_get_events(action).any(func(e): return e is InputEventJoypadButton):
var ev := InputEventJoypadButton.new()
ev.button_index = pair[1]
ev.device = -1 # jedes Gamepad
InputMap.action_add_event(action, ev)
func focus_first_button() -> void:
if screen == null or screen is BattleScreen:
return
var b := _first_button(screen)
if b != null:
b.grab_focus()
func _first_button(node: Node) -> Button:
for c in node.get_children():
if c is Button and not c.disabled and c.is_visible_in_tree():
return c
var found := _first_button(c)
if found != null:
return found
return null
## Zurück: zum gemerkten Bildschirm, sonst ins Hauptmenü.
func back() -> void:
if kept.is_empty():
show_screen(MenuScreen.new())
return
var prev: Control = kept.pop_back()
screen.queue_free()
screen = prev
add_child(prev)
func save() -> void:
profile.save(save_path)
func _exit_tree() -> void:
if net != null and net.is_inside_tree():
net.leave()
net.queue_free()
+51 -64
View File
@@ -1,83 +1,70 @@
class_name MenuScreen
extends Control
## Hauptmenü: links Titel und Spielarten (darunter Booster, Sammlung, Skilltree, dann Einstellungen und
## Beenden), rechts drei Karten der eigenen Sammlung als Schaufenster, unten rechts die Cheats.
var app
var 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)
var p: Profile = app.profile
Style.label(self, "Dinger", 76, Style.GOLD, Rect2(80, 64, 600, 90), "left", true)
Style.label(self, "Ein Kartenspiel: Duell um das Feld", 22, Style.MUTED, Rect2(84, 156, 600, 30), "left")
coins_label = Style.chip(self, "", Rect2(930, 20, 310, 38))
coll_label = Style.label(self, "", 15, Style.MUTED, Rect2(700, 64, 540, 24), "right")
var y := 230
for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())],
["Kampagne", func(): app.show_screen(CampaignScreen.new())],
["Deck", func(): app.show_screen(DeckScreen.new())]]:
var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY)
b.add_theme_font_size_override("font_size", 24)
y += 74
y += 18
var x := 80
for entry in [["Booster öffnen", func(): app.show_screen(BoosterScreen.new())],
["Sammlung", func(): app.show_screen(CollectionScreen.new())],
["Skilltree", func(): app.show_screen(SkillScreen.new())]]:
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
x += 176
y += 56
x = 80
for entry in [["Einstellungen", func(): app.show_screen(SettingsScreen.new())],
["Beenden", func(): get_tree().quit()]]:
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
x += 176
var cheats := Style.button(self, "Cheats", Rect2(1120, 744, 120, 36), func(): app.show_screen(CheatScreen.new()))
cheats.add_theme_font_size_override("font_size", 14)
notice_label = Style.label(self, "", 16, Style.GREEN, Rect2(80, y + 70, 760, 60), "left")
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
_showcase(p)
refresh()
## Drei eigene Karten, die höchsten Stufen zuerst, leicht gefächert.
func _showcase(p: Profile) -> void:
var cards: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
cards.sort_custom(func(a, b):
if p.level(a.id) != p.level(b.id):
return p.level(a.id) > p.level(b.id)
return hash(a.id) % 97 < hash(b.id) % 97)
var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]]
for i in 3:
var c: Cards.Card = cards[i]
var v := CardView.new(c.leveled(p.level(c.id)), Vector2(180, 252), false, p.rarity(c.id))
v.position = spots[i][0]
v.rotation = spots[i][1]
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
func refresh() -> void:
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)
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d · Level %d") % [
p.collection.size(), p.unlocked_cards().size(), p.campaign_stage, Campaign.total(), p.player_level()]
var notes := p.take_notices()
notice_label.text = " · ".join(notes)
if not notes.is_empty():
+46 -17
View File
@@ -1,29 +1,58 @@
class_name OpponentSelectScreen
extends Control
## Einzelspieler: Auswahl, gegen wie viele Gegner gekämpft wird.
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Der Gegner spielt ein Element aus den
## offenen Kapiteln in einer Stufe relativ zu deiner Deckstärke (Profile.single_opponent).
var app
var diff_buttons := {}
var info_label: Label
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)
Style.header(self, "Einzelspieler", func(): app.show_screen(MenuScreen.new()))
var col := Style.column(self, 1000, 170, 12)
Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true)
var dr := Style.row(col, 10)
dr.alignment = BoxContainer.ALIGNMENT_CENTER
for key in Profile.DIFFICULTIES:
var k: String = key
diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k))
info_label = Style.clabel(col, "", 15, Style.MUTED)
Style.spacer(col, 24)
var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
var b := Style.cbutton(br, "Starten", Vector2(260, 56), start, Style.PRIMARY)
b.add_theme_font_size_override("font_size", 22)
refresh()
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
func set_difficulty(key: String) -> void:
app.profile.settings.difficulty = key
app.save()
refresh()
func start() -> void:
app.show_screen(BattleScreen.new())
func refresh() -> void:
var p: Profile = app.profile
for key in diff_buttons:
_mark(diff_buttons[key], key == p.settings.difficulty)
var d := p.difficulty()
var parts: Array[String] = [tr("Gegnerkarten: %s") % tr(Cards.RARITIES[p.opponent_level()]), tr("Vorausschau-KI") if bool(d.smart) else tr("einfache KI")]
if bool(d.perk):
parts.append(tr("ein Merkmal"))
parts.append(tr("Münzen %s") % Style.factor(float(d.reward)))
info_label.text = " · ".join(parts)
func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void:
var base: Color = col if active else Style.PANEL_LIGHT
b.add_theme_stylebox_override("normal", Style.box(base, 8, Style.GOLD if active else Style.MUTED, 2))
-25
View File
@@ -1,25 +0,0 @@
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())
-1
View File
@@ -1 +0,0 @@
uid://bkemrytc2pjem
+29 -9
View File
@@ -7,6 +7,8 @@ var music: HSlider
var speed: HSlider
var language: OptionButton
var music_label: Label
var big_font: CheckButton
var confirm: Control
var anim: CheckButton
var volume_label: Label
var speed_label: Label
@@ -14,9 +16,8 @@ var speed_label: Label
func _ready() -> void:
var s: Dictionary = app.profile.settings
var col := Style.column(self, 700, 50, 24)
Style.clabel(col, "Einstellungen", 36, Style.GOLD, "center", true)
Style.spacer(col, 30)
Style.header(self, "Einstellungen", func(): app.back())
var col := Style.column(self, 700, 110, 24)
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 24)
@@ -58,24 +59,42 @@ func _ready() -> void:
anim.toggled.connect(_on_anim)
grid.add_child(anim)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "Große Schrift", 20, Style.TEXT, "left", false, 200)
big_font = CheckButton.new()
big_font.button_pressed = bool(s.get("big_font", false))
big_font.toggled.connect(_on_big_font)
grid.add_child(big_font)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200)
language = OptionButton.new()
language.custom_minimum_size = Vector2(280, 36)
language.add_item("Deutsch", 0)
language.add_item("English (Oberfläche)", 1)
language.add_item("English", 1)
language.select(1 if s.language == "en" else 0)
language.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)
Style.spacer(col, 30)
var r := Style.row(col, 16)
r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.",
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
_labels()
func _reset() -> void:
app.reset_profile()
func _on_big_font(on: bool) -> void:
app.profile.settings.big_font = on
app.save()
app.apply_settings()
app.show_screen(SettingsScreen.new())
func _labels() -> void:
volume_label.text = "%d %%" % int(round(volume.value * 100))
music_label.text = "%d %%" % int(round(music.value * 100))
@@ -113,9 +132,10 @@ func _on_speed(v: float) -> void:
func _on_anim(on: bool) -> void:
app.profile.settings.animations = on
Style.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())
app.back()
+26 -10
View File
@@ -2,23 +2,25 @@ 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 volume := 0.0 # Ton ist standardmäßig aus
var muted := false # Tests: nie etwas abspielen, egal was die Regler sagen
var music_player: AudioStreamPlayer
var music_volume := 0.5
var music_volume := 0.0
var music_task := -1 # die Musik wird im Hintergrund berechnet (spart ~150 ms beim Start)
var music_box := {}
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["buy"] = _tone(1200, 0.08, "sine", 1700)
streams["turn"] = _tone(520, 0.3, "sine", 1040)
streams["win"] = _tone(400, 0.6, "sine", 1200)
streams["lose"] = _tone(320, 0.7, "square", 90)
for i in 4:
@@ -26,9 +28,23 @@ func _ready() -> void:
add_child(p)
players.append(p)
music_player = AudioStreamPlayer.new()
music_player.stream = _music()
add_child(music_player)
set_music_volume(music_volume)
var box := music_box
music_task = WorkerThreadPool.add_task(func(): box["wav"] = _music())
func _process(_delta: float) -> void:
if music_task >= 0 and WorkerThreadPool.is_task_completed(music_task):
WorkerThreadPool.wait_for_task_completion(music_task)
music_task = -1
music_player.stream = music_box.wav
set_music_volume(music_volume)
func _exit_tree() -> void:
if music_task >= 0:
WorkerThreadPool.wait_for_task_completion(music_task)
music_task = -1
## Leise Hintergrundmusik: Arpeggio über vier Akkorde, als Schleife erzeugt.
@@ -63,11 +79,11 @@ func _music() -> AudioStreamWAV:
func set_music_volume(v: float) -> void:
music_volume = clampf(v, 0.0, 1.0)
if music_volume <= 0.0:
if music_volume <= 0.0 or muted:
music_player.stop()
else:
music_player.volume_db = linear_to_db(music_volume * 0.6)
if not music_player.playing:
if not music_player.playing and music_player.stream != null:
music_player.play()
@@ -76,7 +92,7 @@ func set_volume(v: float) -> void:
func play(kind: String) -> void:
if volume <= 0.0 or not streams.has(kind):
if volume <= 0.0 or muted or not streams.has(kind):
return
for p in players:
if not p.playing:
+70
View File
@@ -0,0 +1,70 @@
class_name SkillScreen
extends Control
## Skilltree: oben Level, Erfahrung bis zum nächsten Level und freie Skillpunkte; darunter drei
## Äste (Skills.BRANCHES) mit ihren Skills als Karten: Name, Wirkung, aktuelle und nächste Stufe
## in Zahlen (Skills.effect), Stufe und „Lernen“.
var app
var level_label: Label
var xp_label: Label
var points_label: Label
var msg_label: Label
var learn_buttons := {} # Skill-ID -> Knopf
var rank_labels := {} # Skill-ID -> Label
var now_labels := {} # Skill-ID -> Wirkung jetzt
var next_labels := {} # Skill-ID -> Wirkung der nächsten Stufe
func _ready() -> void:
Style.header(self, "Skilltree", func(): app.show_screen(MenuScreen.new()))
level_label = Style.chip(self, "", Rect2(40, 86, 220, 38))
xp_label = Style.chip(self, "", Rect2(276, 86, 400, 38), Style.BLUE)
points_label = Style.chip(self, "", Rect2(692, 86, 300, 38), Style.GREEN)
Style.label(self, "Erfahrung sind deine verdienten Münzen. Jedes Level bringt einen Skillpunkt.", 14, Style.MUTED, Rect2(40, 134, 1200, 22), "left")
for b in Skills.BRANCHES.size():
var branch: String = Skills.BRANCHES[b]
var x := 40 + b * 410
Style.panel(self, Rect2(x, 170, 390, 580))
Style.label(self, branch, 22, Style.GOLD, Rect2(x, 184, 390, 32), "center", true)
var y := 236
for id in Skills.in_branch(branch):
var sk: Dictionary = Skills.SKILLS[id]
Style.panel(self, Rect2(x + 16, y, 358, 176), Style.PANEL_LIGHT, 8)
Style.label(self, sk.name, 18, Style.TEXT, Rect2(x + 32, y + 10, 326, 26), "left", true)
Style.label(self, sk.text, 14, Style.MUTED, Rect2(x + 32, y + 38, 326, 22), "left")
now_labels[id] = Style.label(self, "", 14, Style.TEXT, Rect2(x + 32, y + 64, 326, 22), "left")
next_labels[id] = Style.label(self, "", 14, Style.GREEN, Rect2(x + 32, y + 86, 326, 22), "left")
rank_labels[id] = Style.label(self, "", 15, Style.GOLD, Rect2(x + 32, y + 126, 150, 30), "left", true)
learn_buttons[id] = Style.button(self, "Lernen", Rect2(x + 214, y + 122, 144, 40), learn.bind(id), Style.PRIMARY)
y += 192
msg_label = Style.label(self, "", 15, Style.RED, Rect2(0, 760, 1280, 24), "center")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
func learn(id: String) -> void:
var err: String = app.profile.learn(id)
msg_label.text = tr(err) if err != "" else ""
if err == "":
app.sfx.play("combo")
app.save()
refresh()
func refresh() -> void:
var p: Profile = app.profile
var lvl := p.player_level()
var base := Skills.xp_for_level(lvl)
level_label.text = tr("Level %d") % lvl
xp_label.text = tr("%d / %d EP bis Level %d") % [p.xp - base, Skills.xp_for_level(lvl + 1) - base, lvl + 1]
points_label.text = tr("Skillpunkte: %d") % p.skill_points()
for id in Skills.SKILLS:
var sk: Dictionary = Skills.SKILLS[id]
rank_labels[id].text = tr("Stufe %d / %d") % [p.skill(id), int(sk.max)]
now_labels[id].text = tr("Jetzt: %s") % Skills.effect(id, p.skill(id))
next_labels[id].text = tr("Nächste Stufe: %s") % Skills.effect(id, p.skill(id) + 1) if p.skill(id) < int(sk.max) else tr("Höchste Stufe erreicht")
learn_buttons[id].disabled = p.skill_points() <= 0 or p.skill(id) >= int(sk.max)
+1
View File
@@ -0,0 +1 @@
uid://b0dgryn2kpic5
+425 -91
View File
@@ -3,6 +3,8 @@ class_name Style
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
static var font_scale := 1.0 # Einstellung „Große Schrift“
static var animations := true # Einstellung „Animationen“: der Glanz seltener Karten bewegt sich
const BG := Color("1a1c24")
const PANEL := Color("282b36")
@@ -15,6 +17,22 @@ const RED := Color("e65555")
const BLUE := Color("6eaaf0")
const YELLOW := Color("ffe65a")
const ORANGE := Color("ff963c")
const PURPLE := Color("8c78dc")
const AMBER := Color("f0b429") # Angriff
const LIFE := Color("4a86d8") # Leben
const MANA := Color("3a78d2") # Kostenkristall
const PRIMARY := Color("285a3c") # Hauptaktion
const DANGER := Color("5a2828")
## Schriftgröße mit der Einstellung „Große Schrift“.
## Faktor als Text: ×2, ×1,5 (Dezimaltrenner der Sprache).
static func factor(f: float) -> String:
return "×" + String.num(f, 1).trim_suffix(".0").replace(".", TranslationServer.translate(","))
static func fs(size: int) -> int:
return int(round(size * font_scale))
static func darker(c: Color, f: float = 0.65) -> Color:
@@ -38,16 +56,16 @@ static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0
static func make_theme() -> Theme:
var t := Theme.new()
t.default_font = FONT
t.default_font_size = 16
t.default_font_size = fs(16)
t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2))
t.set_stylebox("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())
t.set_stylebox("focus", "Button", box(Color(0, 0, 0, 0), 8, GOLD, 3))
for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
t.set_color(name, "Button", TEXT)
t.set_color("font_disabled_color", "Button", MUTED)
t.set_font_size("font_size", "Button", 18)
t.set_font_size("font_size", "Button", fs(18))
t.set_color("font_color", "Label", TEXT)
t.set_stylebox("panel", "Panel", box(PANEL, 8))
t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new())
@@ -73,7 +91,11 @@ static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8,
var p := Panel.new()
p.position = rect.position
p.size = rect.size
p.add_theme_stylebox_override("panel", box(bg, radius, border_col, border_w))
var sb := box(bg, radius, border_col, border_w)
sb.shadow_color = Color(0, 0, 0, 0.35)
sb.shadow_size = 6
sb.shadow_offset = Vector2(0, 3)
p.add_theme_stylebox_override("panel", sb)
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(p)
return p
@@ -84,7 +106,7 @@ static func label(parent: Node, text: String, size: int, color: Color, rect: Rec
l.text = text
l.position = rect.position
l.size = rect.size
l.add_theme_font_size_override("font_size", size)
l.add_theme_font_size_override("font_size", fs(size))
l.add_theme_color_override("font_color", color)
if bold:
l.add_theme_font_override("font", FONT_BOLD)
@@ -108,10 +130,37 @@ static func button(parent: Node, text: String, rect: Rect2, on_pressed: Callable
b.add_theme_stylebox_override("normal", box(color, 8, MUTED, 2))
b.add_theme_stylebox_override("hover", box(lighter(color), 8, MUTED, 2))
b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2))
juice(b)
parent.add_child(b)
return b
## Knöpfe federn beim Drücken kurz ein.
static func juice(b: Button) -> void:
b.button_down.connect(func():
if animations:
b.pivot_offset = b.size / 2
b.create_tween().tween_property(b, "scale", Vector2(0.96, 0.96), 0.05))
b.button_up.connect(func():
if animations:
b.pivot_offset = b.size / 2
b.create_tween().tween_property(b, "scale", Vector2.ONE, 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT))
## Kopfzeile eines Unterbildschirms: Titel links, Zurück rechts oben.
static func header(parent: Node, title: String, on_back: Callable) -> Button:
label(parent, title, 34, GOLD, Rect2(40, 22, 700, 46), "left", true)
return button(parent, "Zurück", Rect2(1120, 26, 120, 40), on_back)
## Kleine Kachel mit Text, etwa für Münzen und Staub.
static func chip(parent: Node, text: String, rect: Rect2, color: Color = GOLD) -> Label:
panel(parent, rect, darker(PANEL, 0.9), rect.size.y / 2, PANEL_LIGHT, 1)
var l := label(parent, text, 17, color, rect, "center", true)
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
return l
## 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()
@@ -160,6 +209,31 @@ static func spacer(parent: Node, h: float) -> Control:
return c
## Unsichtbare Vollbild-Ebene mit abgedunkeltem Hintergrund, die Klicks schluckt.
static func overlay(parent: Node) -> Control:
var o := Control.new()
o.set_anchors_preset(Control.PRESET_FULL_RECT)
o.visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
o.add_child(dark)
parent.add_child(o)
return o
## Bestätigungsdialog (unsichtbar): Titel, Hinweis, Ja-Knopf mit Aktion und ein Abbrechen-Knopf.
static func confirm(parent: Node, title: String, text: String, yes: String, on_yes: Callable, no: String = "Abbrechen", color: Color = MUTED) -> Control:
var o := overlay(parent)
panel(o, Rect2(340, 290, 600, 200), PANEL, 10, color, 2)
label(o, title, 26, TEXT, Rect2(340, 312, 600, 40), "center", true)
label(o, text, 16, MUTED, Rect2(340, 360, 600, 24), "center")
button(o, yes, Rect2(380, 420, 240, 46), on_yes, Color("5a2828"))
button(o, no, Rect2(660, 420, 240, 46), func(): o.visible = false)
return o
static func clear(node: Node) -> void:
for c in node.get_children():
node.remove_child(c)
@@ -168,7 +242,8 @@ static func clear(node: Node) -> void:
# --- Zeichenhilfen für _draw() ---------------------------------------------------
static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
var size := fs(size_in)
var font: Font = FONT_BOLD if bold else FONT
var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var x := pos.x
@@ -197,6 +272,33 @@ static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector
ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
## Text mit dunklem Rand: bleibt auf jeder Farbe lesbar (Werte, Kosten).
static func text_outlined(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "center", outline: int = 4) -> void:
var size := fs(size_in)
var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var x := pos.x
var y := pos.y
match anchor:
"center":
x -= dim.x / 2
y -= dim.y / 2
"midtop":
x -= dim.x / 2
"midleft":
y -= dim.y / 2
var at := Vector2(x, y + FONT_BOLD.get_ascent(size))
ci.draw_string_outline(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, outline, Color(0.05, 0.05, 0.08, 0.95))
ci.draw_string(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
## Schriftgröße, mit der ein Text in eine Breite passt (bis zur Mindestgröße).
static func fit_size(s: String, bold: bool, size: int, max_w: float, min_size: int = 9) -> int:
var font: Font = FONT_BOLD if bold else FONT
while size > min_size and font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, fs(size)).x > max_w:
size -= 1
return size
static func fit(s: String, bold: bool, size: int, max_w: float) -> String:
var font: Font = FONT_BOLD if bold else FONT
while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3:
@@ -213,102 +315,329 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, 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))
# --- Seltenheit als Aussehen ------------------------------------------------------
# Common matt; Rare silbern glänzender Name; Super Rare Holo-Bild (wandernder Regenbogen im
# Bildkreis); Ultra Rare goldener Name und Holo-Bild; Ultimate Rare goldener Name, goldenes
# Bild, glänzende Werte, Schimmer über die ganze Karte, Funkeln und schillernder Rahmen.
# Rahmen und Schein tragen ab Rare die Stufenfarbe.
## Ab Rare bewegt sich der Glanz; dann zeichnet die Karte jeden Frame neu.
static func rarity_animated(rar: String) -> bool:
return animations and Cards.RARITIES.find(rar) >= 1
## Farbe einer Stufe; Ultimate Rare schillert über die Zeit durch alle Farben.
static func rarity_color(rar: String, seed: int = 0) -> Color:
if rar == "Ultimate Rare" and animations:
return Color.from_hsv(fmod(Time.get_ticks_msec() / 1000.0 * 0.12 + (seed % 97) / 97.0, 1.0), 0.55, 1.0)
return Cards.RARITY_COLORS[rar]
## Rahmenfarbe einer Stufe: Common bleibt schlicht, alles darüber trägt seine Farbe.
static func rarity_frame(rar: String, seed: int = 0) -> Color:
return Color("3a3e4c") if rar == "Common" else rarity_color(rar, seed)
## Farbe des Kartennamens: Rare silbern, Ultra und Ultimate Rare golden, sonst weiß.
static func rarity_name_color(rar: String) -> Color:
match rar:
"Rare":
return Cards.RARITY_COLORS["Rare"]
"Ultra Rare", "Ultimate Rare":
return Cards.RARITY_COLORS["Ultra Rare"]
return TEXT
## Der Name glänzt bei Rare (silbern), Ultra und Ultimate Rare (golden).
static func rarity_name_shiny(rar: String) -> bool:
return rar in ["Rare", "Ultra Rare", "Ultimate Rare"]
## Ultimate Rare: auch Kosten, Angriff und Leben glänzen.
static func rarity_stats_shiny(rar: String) -> bool:
return rar == "Ultimate Rare"
## Ultimate Rare zeigt das Bild als Relief (Licht- und Schattenkante).
static func rarity_relief(rar: String) -> bool:
return rar == "Ultimate Rare"
## Kartenname links in rect, immer eine Zeile, nie abgeschnitten: die Schrift schrumpft bis
## drei Punkte unter die Vorgabe, danach wird der Text schmaler gezeichnet (wie bei langen
## Yu-Gi-Oh-Namen).
static func card_name(ci: CanvasItem, s: String, size_in: int, rar: String, rect: Rect2, seed: int) -> void:
var fsize := fit_size(s, true, size_in, rect.size.x, maxi(8, size_in - 3))
var w := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, fs(fsize)).x
var squeeze := minf(1.0, rect.size.x / maxf(w, 1.0))
var y := rect.position.y + (rect.size.y - FONT_BOLD.get_height(fs(fsize))) / 2
ci.draw_set_transform(Vector2(rect.position.x, y), 0.0, Vector2(squeeze, 1.0))
if rarity_name_shiny(rar):
shiny_text(ci, s, fsize, rarity_name_color(rar), Vector2.ZERO, "topleft", seed)
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)))
text(ci, s, fsize, rarity_name_color(rar), Vector2.ZERO, "topleft", true)
ci.draw_set_transform(Vector2.ZERO)
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)
## Metallisch glänzender fetter Text: dunkler Schatten, Grundfarbe, darüber Buchstabe für
## Buchstabe ein heller Streifen, der mit Animation über den Text läuft.
static func shiny_text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String, seed: int) -> void:
var size := fs(size_in)
var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var x := pos.x
var y := pos.y
match anchor:
"center":
x -= dim.x / 2
y -= dim.y / 2
"midtop":
x -= dim.x / 2
"midleft":
y -= dim.y / 2
var phase := (seed % 100) / 100.0
if animations:
phase = fmod(Time.get_ticks_msec() / 1800.0 + phase, 1.0)
var at := Vector2(x, y + FONT_BOLD.get_ascent(size))
ci.draw_string(FONT_BOLD, at + Vector2(1, 1), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, Color(0, 0, 0, 0.55))
var cx := x
for i in s.length():
var ch := s[i]
var w := FONT_BOLD.get_string_size(ch, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
var d := absf(fmod((cx + w / 2 - x) / maxf(dim.x, 1.0) - phase * 1.6 + 0.3 + 2.0, 1.0) - 0.5) * 2.0 # 0 am Streifen
var k := clampf(1.0 - d * 2.4, 0.0, 1.0)
ci.draw_string(FONT_BOLD, Vector2(cx, at.y), ch, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color.lerp(Color.WHITE, 0.1 + 0.75 * k))
cx += w
# --- Kartensymbole (prozedural, je Element und Kartenart) --------------------------
## Glanz wie auf einer Folienkarte, auf clip beschnitten. lvl 2 und 3: Holo, ein wandernder
## Regenbogenschimmer; lvl 4: goldene Bänder.
static func sheen(ci: CanvasItem, clip: PackedVector2Array, bounds: Rect2, seed: int, gold: bool = false) -> void:
var phase := (seed % 100) / 100.0
if animations:
phase = fmod(Time.get_ticks_msec() / 2500.0 + phase, 1.0)
var layers := []
for i in 4:
var col := Color(1.0, 0.86, 0.45, 0.2 - i * 0.03) if gold else Color.from_hsv(fmod(phase + i * 0.17, 1.0), 0.6, 1.0, 0.16)
layers.append([1.0 - i * 0.22, col])
_sheen_bands(ci, clip, bounds, phase, layers)
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
## Ultimate Rare: schwacher schillernder Schimmer über die ganze Karte.
static func card_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> void:
if rar != "Ultimate Rare":
return
var phase := (seed % 100) / 100.0
if animations:
phase = fmod(Time.get_ticks_msec() / 2500.0 + phase, 1.0)
var soft := []
for i in 3:
soft.append([1.0 - i * 0.3, Color.from_hsv(fmod(phase + 0.5 + i * 0.2, 1.0), 0.5, 1.0, 0.07)])
_sheen_bands(ci, rect_poly(rect), rect, fmod(phase + 0.5, 1.0), soft)
static func rect_poly(rect: Rect2) -> PackedVector2Array:
return PackedVector2Array([rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)])
static func circle_poly(c: Vector2, r: float, steps: int = 40) -> PackedVector2Array:
var pts := PackedVector2Array()
for i in steps:
var a := TAU * i / steps
pts.append(c + Vector2(cos(a), sin(a)) * r)
return pts
## Rechteck mit runden Ecken als Polygon (für Beschnitt).
static func rounded_poly(rect: Rect2, radius: float) -> PackedVector2Array:
var pts := PackedVector2Array()
var r := minf(radius, minf(rect.size.x, rect.size.y) / 2)
var corners := [[rect.end - Vector2(r, r), 0.0], [Vector2(rect.position.x + r, rect.end.y - r), PI / 2],
[rect.position + Vector2(r, r), PI], [Vector2(rect.end.x - r, rect.position.y + r), PI * 1.5]]
for corner in corners:
for k in 7:
var a: float = corner[1] + PI / 2 * k / 6
pts.append(corner[0] + Vector2(cos(a), sin(a)) * r)
return pts
## Schräge Streifen über bounds, auf clip beschnitten. layers: [Breitenanteil, Farbe].
static func _sheen_bands(ci: CanvasItem, clip: PackedVector2Array, bounds: Rect2, phase: float, layers: Array) -> void:
var w := bounds.size.x * 0.34
var k := bounds.size.y * 0.75 # Schräge
var x0 := bounds.position.x - w + (bounds.size.x + w + k) * phase
for layer in layers:
var lw: float = w * layer[0]
var lx := x0 + (w - lw) / 2
var band := PackedVector2Array([Vector2(lx, bounds.position.y), Vector2(lx + lw, bounds.position.y), Vector2(lx + lw - k, bounds.end.y), Vector2(lx - k, bounds.end.y)])
for poly in Geometry2D.intersect_polygons(band, clip):
if poly.size() >= 3 and _area(poly) > 2.0: # Splitter am Rand lassen sich nicht zeichnen
ci.draw_colored_polygon(poly, layer[1])
static func _area(poly: PackedVector2Array) -> float:
var a := 0.0
for i in poly.size():
var p := poly[i]
var q := poly[(i + 1) % poly.size()]
a += p.x * q.y - q.x * p.y
return absf(a) / 2.0
## Funkeln bei Ultimate Rare: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen.
static func rarity_sparkle(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> void:
if rar != "Ultimate Rare":
return
var t := Time.get_ticks_msec() / 1000.0
for i in 9:
var h := absi(hash(Vector2i(seed, i)))
var p := rect.position + Vector2(8 + (h % 1000) / 1000.0 * (rect.size.x - 16), 8 + ((h / 1000) % 1000) / 1000.0 * (rect.size.y - 16))
var tw := 0.5 + 0.5 * sin(t * 2.5 + i * 1.9 + seed % 7) if animations else 0.35 + 0.65 * ((i * 7 + seed) % 5) / 4.0
if tw < 0.15:
continue
var s := 2.0 + 4.0 * tw
var col := Color(1, 1, 1, 0.3 + 0.65 * tw)
ci.draw_line(p - Vector2(s, 0), p + Vector2(s, 0), col, 1.4)
ci.draw_line(p - Vector2(0, s), p + Vector2(0, s), col, 1.4)
ci.draw_circle(p, 1.3, col)
## Weicher Schein um eine Karte höherer Stufe; vor dem Hintergrund zeichnen.
static func glow(ci: CanvasItem, rect: Rect2, color: Color, radius: int = 8, spread: int = 8) -> void:
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0, 0, 0, 0)
sb.set_corner_radius_all(radius)
sb.shadow_color = Color(color, 0.6)
sb.shadow_size = spread
ci.draw_style_box(sb, rect)
## Hintergrund in den Farben der Elemente: ein Element füllt die Karte, verschmolzene Wesen
## tragen je Element einen senkrechten Streifen.
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
ci.draw_style_box(box(Cards.ELEMENT_COLORS[elements[0]], radius), rect)
if elements.size() < 2:
return
var clip := rounded_poly(rect, radius)
var w := rect.size.x / elements.size()
for i in range(1, elements.size()):
var strip := Rect2(rect.position.x + w * i, rect.position.y, w, rect.size.y)
for poly in Geometry2D.intersect_polygons(rect_poly(strip), clip):
if poly.size() >= 3:
ci.draw_colored_polygon(poly, Cards.ELEMENT_COLORS[elements[i]])
ci.draw_line(strip.position + Vector2(0, 2), Vector2(strip.position.x, strip.end.y - 2), Color(0.05, 0.05, 0.08, 0.5), 1.5)
# --- Kartenteile ------------------------------------------------------
## Bild der Karte: dunkler Kreis mit Ring in der Elementfarbe, darin das Symbol der Bildkarte
## (bei Verschmelzungen die des stärkeren Elternteils). Ab Super Rare glänzt der Kreis (Holo),
## bei Ultimate Rare golden und als Relief.
static func picture(ci: CanvasItem, card, c: Vector2, r: float, rar: String, seed: int) -> void:
var src = card.art_card()
var ring := lighter(Cards.ELEMENT_COLORS[src.elements[0]], 0.3)
ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.8))
ci.draw_circle(c, r, Color(0.09, 0.09, 0.13, 0.9))
glyph(ci, src, c, r * 1.3, 0.95, rarity_relief(rar))
var lvl := Cards.RARITIES.find(rar)
if lvl >= 2:
sheen(ci, circle_poly(c, r), Rect2(c - Vector2(r, r), Vector2(r * 2, r * 2)), seed, lvl >= 4)
ci.draw_arc(c, r + 1.5, 0, TAU, 48, Color(1.0, 0.86, 0.45) if lvl >= 4 else ring, 3.0, true)
## Kostenkristall: sechseckig, blau, Zahl in der Mitte; shiny: goldene, glänzende Zahl.
static func cost_crystal(ci: CanvasItem, c: Vector2, r: float, cost: int, font: int, shiny: bool = false) -> void:
var pts := PackedVector2Array()
var outer := PackedVector2Array()
for i in 6:
var a := -PI / 2 + i * PI / 3
pts.append(c + Vector2(cos(a), sin(a)) * r)
outer.append(c + Vector2(cos(a), sin(a)) * (r + 2))
ci.draw_colored_polygon(outer, Color(1.0, 0.86, 0.45) if shiny else Color("14141c"))
ci.draw_colored_polygon(pts, MANA)
var shine := PackedVector2Array([pts[5], pts[0], c + Vector2(0, -r * 0.1), c + Vector2(-r * 0.55, r * 0.15)])
ci.draw_colored_polygon(shine, Color(1, 1, 1, 0.22))
_stat_text(ci, str(cost), font, Color.WHITE, c, shiny)
## Wertkreis (Angriff gelb, Leben blau) mit Zahl; shiny: goldener Ring, glänzende Zahl.
static func stat_circle(ci: CanvasItem, c: Vector2, r: float, value: String, color: Color, font: int, text_col: Color = Color.WHITE, shiny: bool = false) -> void:
ci.draw_circle(c, r + 2, Color(1.0, 0.86, 0.45) if shiny else Color("14141c"))
ci.draw_circle(c, r, color)
ci.draw_circle(c + Vector2(-r * 0.25, -r * 0.3), r * 0.45, Color(1, 1, 1, 0.16))
_stat_text(ci, value, font, text_col, c, shiny)
static func _stat_text(ci: CanvasItem, s: String, font: int, col: Color, c: Vector2, shiny: bool) -> void:
if shiny and col == Color.WHITE:
var size := fs(font)
var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
ci.draw_string_outline(FONT_BOLD, c - dim / 2 + Vector2(0, FONT_BOLD.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, 3, Color(0.05, 0.05, 0.08, 0.95))
shiny_text(ci, s, font, Color(1.0, 0.86, 0.45), c, "center", int(c.x + c.y))
else:
text_outlined(ci, s, font, col, c, "center", 3)
## Eigenschaft als Symbol auf dunklem Kreis: Spott Ausrufezeichen, Flink Doppelpfeil,
## Regeneration Kreuz, Doppelschlag gekreuzte Klingen.
static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: Color = Color("f2f2f6")) -> void:
ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.7))
var w := maxf(1.5, r * 0.22)
match kw:
"Spott":
ci.draw_line(c + Vector2(0, -r * 0.7), c + Vector2(0, r * 0.2), col, w * 1.6)
ci.draw_circle(c + Vector2(0, r * 0.6), w * 0.95, col)
"Regeneration":
ci.draw_line(c + Vector2(0, -r * 0.7), c + Vector2(0, r * 0.7), col, w * 1.5)
ci.draw_line(c + Vector2(-r * 0.7, 0), c + Vector2(r * 0.7, 0), col, w * 1.5)
"Flink":
for i in 2:
var x := c.x - r * 0.55 + i * r * 0.55
ci.draw_polyline(PackedVector2Array([Vector2(x, c.y - r * 0.6), Vector2(x + r * 0.5, c.y), Vector2(x, c.y + r * 0.6)]), col, w)
"Doppelschlag":
ci.draw_line(c + Vector2(-r * 0.65, r * 0.65), c + Vector2(r * 0.6, -r * 0.6), col, w)
ci.draw_line(c + Vector2(r * 0.65, r * 0.65), c + Vector2(-r * 0.6, -r * 0.6), col, w)
ci.draw_line(c + Vector2(-r * 0.7, r * 0.35), c + Vector2(-r * 0.35, r * 0.7), col, w)
ci.draw_line(c + Vector2(r * 0.7, r * 0.35), c + Vector2(r * 0.35, r * 0.7), col, w)
# --- Kartensymbole (prozedural, je Element) --------------------------
## relief: Schatten- und Lichtkante unter dem Symbol (Ultimate Rare).
static func glyph(ci: CanvasItem, card, center: Vector2, size: float, alpha: float = 0.95, relief: bool = false) -> void:
if relief:
ci.draw_set_transform(Vector2(2, 2))
_glyph_plain(ci, card, center, size, Color(0, 0, 0, alpha * 0.6))
ci.draw_set_transform(Vector2(-1, -1))
_glyph_plain(ci, card, center, size, Color(1, 1, 1, alpha * 0.5))
ci.draw_set_transform(Vector2.ZERO)
_glyph_plain(ci, card, center, size, Color(0, 0, 0, 0), alpha)
## Symbol in einer festen Farbe (Relief) oder, bei durchsichtigem tint, in Elementfarben.
static func _glyph_plain(ci: CanvasItem, card, center: Vector2, size: float, tint: Color, alpha: float = 0.95) -> void:
var variant := absi(hash(card.id)) % 3
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
col.a = 0.95
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))
col.a = alpha
if tint.a > 0:
col = tint
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:
if elems.size() == 1:
_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)
return
# Verschmolzen: die ersten beiden Elemente nebeneinander, ab dem dritten ein Ring
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
col2.a = alpha
if tint.a > 0:
col2 = tint
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
if elems.size() >= 3:
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
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)
## Elementsymbol in einer Farbe (Filterknöpfe, Listen), Grundvariante.
static func element_icon(ci: CanvasItem, element: String, c: Vector2, size: float, col: Color) -> void:
_element_glyph(ci, element, c, size, 0, col)
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
@@ -338,6 +667,11 @@ static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: fl
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))
_: # Neutral: ein schlichter Schild
var w := h * 0.8
var pts := PackedVector2Array([c + Vector2(-w, -h * 0.8), c + Vector2(w, -h * 0.8), c + Vector2(w, h * 0.1), c + Vector2(0, h), c + Vector2(-w, h * 0.1)])
ci.draw_polyline(pts + PackedVector2Array([pts[0]]), col, maxf(2, size * 0.09))
ci.draw_line(c + Vector2(0, -h * 0.5), c + Vector2(0, h * 0.6), col, maxf(2, size * 0.07))
static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
@@ -348,7 +682,7 @@ static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
ci.draw_colored_polygon(pts, col)
var inner := PackedVector2Array([
c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)])
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75))
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75 * col.a))
## Dezenter Hintergrund: Farbverlauf und schräge Linien.
-151
View File
@@ -1,151 +0,0 @@
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
-1
View File
@@ -1 +0,0 @@
uid://cf42h6bsh8lvg
+9
View File
@@ -0,0 +1,9 @@
@echo off
rem Startet Dinger. Beim ersten Start nach dem Klonen importiert Godot zuerst die Ressourcen.
cd /d "%~dp0"
if "%GODOT%"=="" set GODOT=godot
if not exist ".godot\global_script_class_cache.cfg" (
echo Erster Start: Ressourcen werden importiert ...
"%GODOT%" --headless --path . --import >nul 2>&1
)
"%GODOT%" --path . %*
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Startet Dinger. Beim ersten Start nach dem Klonen importiert Godot zuerst die Ressourcen
# (Schriften, Übersetzungen, Klassenliste), sonst lässt sich das Projekt nicht laden.
cd "$(dirname "$0")" || exit 1
GODOT="${GODOT:-godot}"
if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Erster Start: Ressourcen werden importiert ..."
"$GODOT" --headless --path . --import > /dev/null 2>&1
fi
exec "$GODOT" --path . "$@"
+49
View File
@@ -0,0 +1,49 @@
extends Node
## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen.
var main: Node
var max_dt := 0.0
var frames_n := 0
var measuring := false
func _ready() -> void:
Game.test_seed = 4242
main = load("res://scenes/main.tscn").instantiate()
main.save_path = "user://_probe.json"
add_child(main)
await get_tree().process_frame
await get_tree().process_frame
var scr: BattleScreen = BattleScreen.new()
main.show_screen(scr)
await get_tree().process_frame
await get_tree().process_frame
var h: Game.Player = scr.game.human
h.energy = 10
h.hand.pop_back()
if scr.game.current() == h:
scr.game.draw(h)
var t0 := Time.get_ticks_usec()
for i in 30:
scr.refresh()
print("refresh() mit 5 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
scr.ai_delay = 0.2
measuring = true
var start := Time.get_ticks_msec()
var actions := 0
var last_turn := scr.game.turn
while Time.get_ticks_msec() - start < 12000 and scr.game.winner == "":
if scr.game.current().is_human and scr.game.current() == h:
scr.game.end_turn()
scr.after_action()
await get_tree().process_frame
measuring = false
print("längste Bildzeit in 12 s KI-Spiel: %.0f ms (%d Bilder, Zug %d)" % [max_dt * 1000.0, frames_n, scr.game.turn])
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://_probe.json"))
get_tree().quit()
func _process(delta: float) -> void:
if measuring:
frames_n += 1
max_dt = maxf(max_dt, delta)
+1
View File
@@ -0,0 +1 @@
uid://botawgwpha3ai
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/frame_probe.gd" id="1"]
[node name="FrameProbe" type="Node"]
script = ExtResource("1")
+29
View File
@@ -0,0 +1,29 @@
extends SceneTree
## Statische Prüfung: lädt jedes Skript; Parse- oder Kompilierfehler lassen den Lauf scheitern.
## godot --headless --path . -s tests/lint.gd
func scan(dir: String, out: Array) -> void:
var d := DirAccess.open(dir)
if d == null:
return
for sub in d.get_directories():
if not sub.begins_with("."):
scan(dir.path_join(sub), out)
for f in d.get_files():
if f.ends_with(".gd"):
out.append(dir.path_join(f))
func _initialize() -> void:
var files := []
for dir in ["res://scripts", "res://tests"]:
scan(dir, files)
var bad := 0
for path in files:
var script = load(path)
if script == null or not (script is GDScript):
printerr("FEHLER: %s lädt nicht" % path)
bad += 1
print("%d Skripte geprüft, %d fehlerhaft" % [files.size(), bad])
quit(1 if bad > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://c65qyffqln67m
-35
View File
@@ -1,35 +0,0 @@
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()
-1
View File
@@ -1 +0,0 @@
uid://bu5hjcfuinhy8
-65
View File
@@ -1,65 +0,0 @@
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"})
-1
View File
@@ -1 +0,0 @@
uid://dcbqt8ybdkhrb
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/net_bot.gd" id="1"]
[node name="NetBot" type="Node"]
script = ExtResource("1")
+42
View File
@@ -0,0 +1,42 @@
extends SceneTree
## Laufzeitmessung: Start, KI-Entscheidungen, Deckerzeugung, Vorschau, Musik.
func ms(f: Callable, n: int = 1) -> float:
var t0 := Time.get_ticks_usec()
for i in n:
f.call()
return (Time.get_ticks_usec() - t0) / 1000.0 / n
func _initialize() -> void:
var t := ms(func(): Cards.all())
print("Kartenpool laden: %.1f ms" % t)
var r := RandomNumberGenerator.new()
r.seed = 3
print("Stapel würfeln: %.2f ms" % ms(func(): DeckGen.random_deck(r), 20))
print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20))
for n in [1]:
var g := Game.new_battle(r)
# ein volles Brett für alle, damit die Entscheidung teuer wird
var uid := 5000
for p in g.players:
for k in 4:
var c := Game.Creature.new(Cards.by_id("f_3_2"), p, uid)
uid += 1
c.ready = true
p.board.append(c)
var h: Game.Player = g.players[0]
h.energy = 10
h.max_energy = 10
for k in 6:
h.hand.append(Cards.by_id("f_4_3"))
AI.time_budget_ms = 100000
var t_full := ms(func(): AI.choose_action(g, h))
AI.time_budget_ms = 150
var t_budget := ms(func(): AI.choose_action(g, h))
var cands := AI.candidate_actions(g, h).size()
print("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget])
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(r).clone(), 20))
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
quit()
+1
View File
@@ -0,0 +1 @@
uid://xrm3o6vloxh0
+3 -1
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env bash
# Führt Import und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
# Führt Import, statische Prüfung und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
set -euo pipefail
GODOT="${1:-godot}"
cd "$(dirname "$0")/.."
"$GODOT" --headless --path . --import > /dev/null 2>&1 || true
"$GODOT" --headless --path . -s tests/lint.gd 2>&1 | grep -E "FEHLER|Skripte" || true
"$GODOT" --headless --path . -s tests/lint.gd > /dev/null 2>&1
"$GODOT" --headless --path . -s tests/test_runner.gd 2>&1 | tee /tmp/dinger_tests.log | grep -E "FEHLER|Prüfungen|SCRIPT ERROR" || true
grep -q " 0 fehlgeschlagen" /tmp/dinger_tests.log
+25 -18
View File
@@ -1,6 +1,6 @@
extends SceneTree
## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln
## ist etwa 1/(Gegner+1) Siege zu erwarten:
## sind etwa 50 % Siege zu erwarten:
## godot --headless --path . -s tests/simulate.gd
@@ -8,31 +8,38 @@ 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("Beide gierig (Erwartung etwa 50 %):")
_run(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)
_run(games / 2, r, false, true)
print("Beide mit Vorausschau:")
_run(games / 2, r, false, false)
print("Kampagne (du mit Vorausschau), je Stufe:")
for stage in [0, 4, 9, 19, 29, 39, 49]:
_campaign(stage, games / 4, r)
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:
func _campaign(stage: int, games: int, r: RandomNumberGenerator) -> void:
var wins := 0
for i in games:
var g := Game.new_battle_with(Campaign.opponent(stage), r, false, {"deck": DeckGen.element_deck(Cards.NEUTRAL)}) # du mit der Startsammlung
while g.winner == "":
AI.take_turn(g, g.current(), false)
assert(g.turn < 300, "Spiel endet nicht")
if g.winner == "human":
wins += 1
var st := Campaign.stage(stage)
print("Stufe %2d %-12s %2d Leben, %d Merkmale: Siege %3d%%" % [stage + 1, st.name, int(st.life), st.perks.size(), wins * 100 / games])
func _run(games: int, r: RandomNumberGenerator, human_greedy: bool, opp_greedy: bool) -> void:
var wins := 0
var turns := 0
for i in games:
var g := Game.new_battle(deck_fn.call(r), n_opp, r)
var g := Game.new_battle(r)
while g.winner == "":
var p := g.current()
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy)
@@ -40,4 +47,4 @@ func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNum
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])
print("Siege %3d%% Ø Züge %.1f" % [wins * 100 / games, float(turns) / games])
+820 -903
View File
File diff suppressed because it is too large. Load diff
+727 -323
View File
File diff suppressed because it is too large. Load diff
+208 -24
View File
@@ -1,59 +1,243 @@
keys,en
Dinger,Dinger
Ein Kartenspiel: jeder gegen jeden,A card game: free-for-all
Ein Kartenspiel: Duell um das Feld,A card game: duel for the field
Einzelspieler,Single player
Kampagne,Campaign
Lokal zu mehreren,Local multiplayer
Netzwerkspiel,Network game
Deck,Deck
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)
%d neu · %d aufgewertet · +%d Staub,%d new · %d upgraded · +%d dust
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
Rückgängig,Undo
Kapitel 1,Chapter 1
Kapitel 2,Chapter 2
Kapitel 3,Chapter 3
Kapitel 4,Chapter 4
Kapitel 5,Chapter 5
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
Wähle ein Angriffsziel,Choose an attack target
Abgebrochen,Cancelled
%s kann nicht angreifen,%s cannot attack
Nicht dein Zug,Not your turn
Nicht auf der Hand,Not in your hand
Dein Feld ist voll,Your board is full
Kein gültiges Ziel,No valid target
Ungültiges Ziel,Invalid target
Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first
Sieg!,Victory!
Niederlage,Defeat
+%d Münzen,+%d coins
Schwierigkeit,Difficulty
Leicht,Easy
Normal,Normal
Schwer,Hard
Profil zurücksetzen,Reset profile
Große Schrift,Large text
%d Münzen,%d coins
Abbrechen,Cancel
"Ja, zurücksetzen","Yes, reset"
Flink,Swift
Doppelschlag,Double Strike
Common,Common
Rare,Rare
Super Rare,Super Rare
Ultra Rare,Ultra Rare
Ultimate Rare,Ultimate Rare
Feuer,Fire
Wasser,Water
Erde,Earth
Luft,Air
%s zerlegt (+%d Staub),%s dismantled (+%d dust)
Kapitel %d,Chapter %d
Zuschauen,Watch
Zuschauer,Spectator
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden"
Starten,Start
Sammlung,Collection
Verschmelzen,Fuse
Nur eigene Wesen,Only your own creatures
Nur eigene Karten,Only your own cards
Sammlung: %d von %d Karten · Kampagne: %d/%d · Level %d,Collection: %d of %d cards · Campaign: %d/%d · Level %d
Skilltree,Skill tree
Level %d,Level %d
%d / %d EP bis Level %d,%d / %d XP to level %d
Skillpunkte: %d,Skill points: %d
Lernen,Learn
Stufe %d / %d,Rank %d / %d
Sammeln,Collecting
Wirtschaft,Economy
Kampf,Battle
Packglück,Pack luck
Staubsammler,Dust collector
Münzsegen,Coin blessing
Händler,Merchant
Freies Ziehen,Free draw
Starker Start,Strong start
Seltenere Karten in Boostern,Rarer cards in boosters
Mehr Staub aus Doppelten und Zerlegen,More dust from duplicates and dismantling
Mehr Münzen nach jedem Kampf,More coins after every battle
Booster werden billiger,Boosters get cheaper
Das zweite Ziehen je Zug ist auch gratis,The second draw each turn is free as well
Mehr Mana im ersten Zug,More mana in the first turn
Chancen %d/%d/%d/%d/%d %%,Odds %d/%d/%d/%d/%d %%
+%d %% Staub,+%d %% dust
+%d %% Münzen,+%d %% coins
"Neutral %d, Element %d Münzen","Neutral %d, element %d coins"
zwei Karten je Zug gratis,two cards per turn for free
eine Karte je Zug gratis,one card per turn for free
erster Zug: %d Mana,first turn: %d mana
Jetzt: %s,Now: %s
Nächste Stufe: %s,Next rank: %s
Höchste Stufe erreicht,Highest rank reached
Cheats,Cheats
Währung,Currency
Erfahrung,Experience
+1.000 Münzen,"+1,000 coins"
+10.000 Münzen,"+10,000 coins"
+1.000 Staub,"+1,000 dust"
+10.000 Staub,"+10,000 dust"
+1 Level,+1 level
+5 Level,+5 levels
Skills zurücksetzen,Reset skills
Erfahrung auf 0,Experience to 0
Kapitel 2 frei,Unlock chapter 2
Kapitel 3 frei,Unlock chapter 3
Kapitel 4 frei,Unlock chapter 4
Kapitel 5 frei,Unlock chapter 5
Kampagne zurücksetzen,Reset campaign
Alle Karten (Common),All cards (Common)
Alle Karten Ultimate Rare,All cards Ultimate Rare
Eigene Karten +1 Stufe,Own cards +1 rank
Sammlung zurücksetzen,Reset collection
Gespeichert,Saved
"Münzen %d · Staub %d · EP %d (Level %d, %d Skillpunkte frei) · Kampagne %d/%d · Sammlung %d Karten, Stapel %d","Coins %d · Dust %d · XP %d (level %d, %d skill points free) · Campaign %d/%d · Collection %d cards, deck %d"
Schon auf der höchsten Stufe,Already at the highest rank
Kein Skillpunkt frei,No skill point available
Erfahrung sind deine verdienten Münzen. Jedes Level bringt einen Skillpunkt.,Experience is the coins you earn. Every level grants one skill point.
%s ist jetzt %s,%s is now %s
Alle Kapitel abgeschlossen,All chapters completed
Elemente,Elements
Aufwerten,Upgrade
Aufwerten zu %s (%d Staub),Upgrade to %s (%d dust)
Höchste Stufe,Top tier
Keine Karte gewählt,No card selected
Zerlegen (+%d Staub),Dismantle (+%d dust)
Nicht genug Staub (%d nötig),Not enough dust (%d needed)
Schon auf der höchsten Stufe,Already at the top tier
Common-Karten lassen sich nicht zerlegen,Common cards cannot be dismantled
Aufgewertet,Upgraded
+%d Staub,+%d dust
Dein Zug,Your turn
Nicht genug Mana,Not enough mana
Profil wirklich zurücksetzen?,Really reset your profile?
"Sammlung, Münzen und Fortschritt gehen verloren.","Collection, coins and progress are lost."
%d Leben,%d life
Gegnerkarten: %s,Opponent cards: %s
Vorausschau-KI,lookahead AI
einfache KI,simple AI
ein Merkmal,one perk
Münzen %s,coins %s
",","."
%d Züge,%d turns
%d Karten,%d cards
%d schnelle Züge,%d fast turns
Sieg ×2,Victory ×2
Kampagne +%d,Campaign +%d
Start gegen die KI,Start against the AI
Ziehen,Draw
Verdeckt legen ◆ %d,Set face-down ◆ %d
Aufdecken,Reveal
verdeckt,face-down
Aufgedeckt!,Revealed!
Merkmal +%d,Perk +%d
Erst die Wesen,Creatures first
%s liegt verdeckt,%s is face-down
%s liegt schon offen,%s is already face-up
Unbekannte Eigenschaft,Unknown trait
%s hat schon %s,%s already has %s
Wesen kommen mit +1 Angriff,Creatures enter with +1 attack
Hat im ersten Zug 2 Mana mehr,Has 2 extra mana on the first turn
Wesen haben keinen Elementnachteil,Creatures have no element weakness
Beginnt mit 5 Leben mehr,Starts with 5 extra life
Greift zweimal je Zug,Attacks twice per turn
Verdeckt,Face-down
Inferno,Inferno
Tiefsee,Deep Sea
Berg,Mountain
Orkan,Hurricane
Dampf,Steam
Lava,Lava
Blitz,Lightning
Schlamm,Mud
Sturm,Storm
Sand,Sand
Feuerwesen haben +1 Angriff,Fire creatures have +1 attack
Wesen kommen mit +1 Leben,Creatures enter with +1 health
Bekommt jeden Zug 1 Mana mehr,Gets 1 extra mana each turn
Ziehen kostet kein Mana,Drawing costs no mana
Spott,Taunt
Regeneration,Regeneration
Muss zuerst angegriffen werden,Must be attacked first
Greift sofort an,Attacks right away
Heilt sich am Zugende ganz,Heals fully at the end of the turn
Wesen greifen sofort an,Creatures attack right away
Verschmelzen mit …,Fuse with …
Ziel wählen,Pick the partner
Erst die Wesen mit Spott,Taunting creatures first
%s kann erst im nächsten Zug aufgedeckt werden,%s can be revealed next turn
%s greift erst im nächsten Zug an,%s attacks next turn
greift erst im nächsten Zug an,attacks next turn
%s durch das Element,%s from its element
Zwei verschiedene Karten wählen,Pick two different cards
stirbt,dies
Abwerfen +%d ◆,Discard +%d ◆
Überspringen,Skip
Zug %d,Turn %d
Stapel: %d Karten (%d bis %d),Deck: %d cards (%d to %d)
Stärke %d,Strength %d
Herstellen (%d Staub),Craft (%d dust)
Nicht in der Sammlung,Not in the collection
%s hergestellt,%s crafted
Neu!,New!
Auffüllen,Fill up
Leeren,Clear
Dein Stapel ist leer,Your deck is empty
Klick auf eine Karte: rein oder raus.,Click a card: in or out.
Manakurve,Mana curve
Optionen,Options
Hauptmenü,Main menu
Neutral,Neutral
Nie im Vorteil oder Nachteil,Never at an advantage or disadvantage
Veteran,Veteran
Feuerritter,Fire Knight
Wasserritter,Water Knight
Erdritter,Earth Knight
Luftritter,Air Knight
1 keys en
2 Dinger Dinger
3 Ein Kartenspiel: jeder gegen jeden Ein Kartenspiel: Duell um das Feld A card game: free-for-all A card game: duel for the field
4 Einzelspieler Single player
5 Kampagne Campaign
6 Lokal zu mehreren Deck Local multiplayer Deck
Netzwerkspiel Network game
7 Booster öffnen Open boosters
Deck bauen Build deck
Werkstatt Workshop
Erfolge Achievements
Regeln Rules
8 Einstellungen Settings
Anleitung Tutorial
9 Beenden Quit
10 Zurück Back
Weiter Continue
11 Start Start
Speichern Save
Leeren Clear
12 Suche Search
Fehlende Missing
13 Alle All
Aktiv setzen Set active
Auto-Ausfüllen Auto-fill
Umbenennen Rename
14 Zug beenden End turn
15 Aufgeben Surrender
16 Weiterspielen Keep playing
17 Ja, aufgeben Yes, surrender
18 Wirklich aufgeben? Really surrender?
Der Kampf endet ohne Münzen. The battle ends without coins.
19 Zurück zum Menü Back to menu
20 Lupe Zoom
21 Schließen Close
22 Held als Ziel Target hero
23 Verlauf Log
Karte überfahren für Details Hover a card for details
24 Lautstärke Volume
25 Musik Music
26 KI-Tempo AI speed
27 Animationen Animations
28 Sprache Language
29 Booster Boosters
30 Booster kaufen (100 Münzen) %d neu · %d aufgewertet · +%d Staub Buy booster (100 coins) %d new · %d upgraded · +%d dust
31 Alle aufdecken Reveal all
32 Zerlegen Dismantle
Herstellen Craft
33 Kämpfen Fight
34 Wiederholen Replay
35 Gesperrt Locked
36 Spiel eröffnen Host game
37 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
38 Du bist dran Your turn
39 Rückgängig Undo
40 Kapitel 1 Chapter 1
41 Kapitel 2 Chapter 2
42 Kapitel 3 Chapter 3
43 Kapitel 4 Chapter 4
44 Kapitel 5 Chapter 5
45 Münzen: %d · Staub: %d Coins: %d · Dust: %d
46 Wähle ein Angriffsziel Choose an attack target
47 Abgebrochen Cancelled
48 %s kann nicht angreifen %s cannot attack
49 Nicht dein Zug Not your turn
50 Nicht auf der Hand Not in your hand
51 Dein Feld ist voll Your board is full
52 Kein gültiges Ziel No valid target
53 Ungültiges Ziel Invalid target
54 Ein Wächter muss zuerst angegriffen werden A guardian must be attacked first
55 Sieg! Victory!
56 Niederlage Defeat
57 +%d Münzen +%d coins
58 Schwierigkeit Difficulty
59 Leicht Easy
60 Normal Normal
61 Schwer Hard
62 Profil zurücksetzen Reset profile
63 Große Schrift Large text
64 %d Münzen %d coins
65 Abbrechen Cancel
66 Ja, zurücksetzen Yes, reset
67 Flink Swift
68 Doppelschlag Double Strike
69 Common Common
70 Rare Rare
71 Super Rare Super Rare
72 Ultra Rare Ultra Rare
73 Ultimate Rare Ultimate Rare
74 Feuer Fire
75 Wasser Water
76 Erde Earth
77 Luft Air
78 %s zerlegt (+%d Staub) %s dismantled (+%d dust)
79 Kapitel %d Chapter %d
80 Zuschauen Watch
81 Zuschauer Spectator
82 Zuschauer: Hände sind verdeckt Spectator: hands are hidden
83 Starten Start
84 Sammlung Collection
85 Verschmelzen Fuse
86 Nur eigene Wesen Only your own creatures
87 Nur eigene Karten Only your own cards
88 Sammlung: %d von %d Karten · Kampagne: %d/%d · Level %d Collection: %d of %d cards · Campaign: %d/%d · Level %d
89 Skilltree Skill tree
90 Level %d Level %d
91 %d / %d EP bis Level %d %d / %d XP to level %d
92 Skillpunkte: %d Skill points: %d
93 Lernen Learn
94 Stufe %d / %d Rank %d / %d
95 Sammeln Collecting
96 Wirtschaft Economy
97 Kampf Battle
98 Packglück Pack luck
99 Staubsammler Dust collector
100 Münzsegen Coin blessing
101 Händler Merchant
102 Freies Ziehen Free draw
103 Starker Start Strong start
104 Seltenere Karten in Boostern Rarer cards in boosters
105 Mehr Staub aus Doppelten und Zerlegen More dust from duplicates and dismantling
106 Mehr Münzen nach jedem Kampf More coins after every battle
107 Booster werden billiger Boosters get cheaper
108 Das zweite Ziehen je Zug ist auch gratis The second draw each turn is free as well
109 Mehr Mana im ersten Zug More mana in the first turn
110 Chancen %d/%d/%d/%d/%d %% Odds %d/%d/%d/%d/%d %%
111 +%d %% Staub +%d %% dust
112 +%d %% Münzen +%d %% coins
113 Neutral %d, Element %d Münzen Neutral %d, element %d coins
114 zwei Karten je Zug gratis two cards per turn for free
115 eine Karte je Zug gratis one card per turn for free
116 erster Zug: %d Mana first turn: %d mana
117 Jetzt: %s Now: %s
118 Nächste Stufe: %s Next rank: %s
119 Höchste Stufe erreicht Highest rank reached
120 Cheats Cheats
121 Währung Currency
122 Erfahrung Experience
123 +1.000 Münzen +1,000 coins
124 +10.000 Münzen +10,000 coins
125 +1.000 Staub +1,000 dust
126 +10.000 Staub +10,000 dust
127 +1 Level +1 level
128 +5 Level +5 levels
129 Skills zurücksetzen Reset skills
130 Erfahrung auf 0 Experience to 0
131 Kapitel 2 frei Unlock chapter 2
132 Kapitel 3 frei Unlock chapter 3
133 Kapitel 4 frei Unlock chapter 4
134 Kapitel 5 frei Unlock chapter 5
135 Kampagne zurücksetzen Reset campaign
136 Alle Karten (Common) All cards (Common)
137 Alle Karten Ultimate Rare All cards Ultimate Rare
138 Eigene Karten +1 Stufe Own cards +1 rank
139 Sammlung zurücksetzen Reset collection
140 Gespeichert Saved
141 Münzen %d · Staub %d · EP %d (Level %d, %d Skillpunkte frei) · Kampagne %d/%d · Sammlung %d Karten, Stapel %d Coins %d · Dust %d · XP %d (level %d, %d skill points free) · Campaign %d/%d · Collection %d cards, deck %d
142 Schon auf der höchsten Stufe Already at the highest rank
143 Kein Skillpunkt frei No skill point available
144 Erfahrung sind deine verdienten Münzen. Jedes Level bringt einen Skillpunkt. Experience is the coins you earn. Every level grants one skill point.
145 %s ist jetzt %s %s is now %s
146 Alle Kapitel abgeschlossen All chapters completed
147 Elemente Elements
148 Aufwerten Upgrade
149 Aufwerten zu %s (%d Staub) Upgrade to %s (%d dust)
150 Höchste Stufe Top tier
151 Keine Karte gewählt No card selected
152 Zerlegen (+%d Staub) Dismantle (+%d dust)
153 Nicht genug Staub (%d nötig) Not enough dust (%d needed)
154 Schon auf der höchsten Stufe Already at the top tier
155 Common-Karten lassen sich nicht zerlegen Common cards cannot be dismantled
156 Aufgewertet Upgraded
157 +%d Staub +%d dust
158 Dein Zug Your turn
159 Nicht genug Mana Not enough mana
160 Profil wirklich zurücksetzen? Really reset your profile?
161 Sammlung, Münzen und Fortschritt gehen verloren. Collection, coins and progress are lost.
162 %d Leben %d life
163 Gegnerkarten: %s Opponent cards: %s
164 Vorausschau-KI lookahead AI
165 einfache KI simple AI
166 ein Merkmal one perk
167 Münzen %s coins %s
168 , .
169 %d Züge %d turns
170 %d Karten %d cards
171 %d schnelle Züge %d fast turns
172 Sieg ×2 Victory ×2
173 Kampagne +%d Campaign +%d
174 Start gegen die KI Start against the AI
175 Ziehen Draw
176 Verdeckt legen ◆ %d Set face-down ◆ %d
177 Aufdecken Reveal
178 verdeckt face-down
179 Aufgedeckt! Revealed!
180 Merkmal +%d Perk +%d
181 Erst die Wesen Creatures first
182 %s liegt verdeckt %s is face-down
183 %s liegt schon offen %s is already face-up
184 Unbekannte Eigenschaft Unknown trait
185 %s hat schon %s %s already has %s
186 Wesen kommen mit +1 Angriff Creatures enter with +1 attack
187 Hat im ersten Zug 2 Mana mehr Has 2 extra mana on the first turn
188 Wesen haben keinen Elementnachteil Creatures have no element weakness
189 Beginnt mit 5 Leben mehr Starts with 5 extra life
190 Greift zweimal je Zug Attacks twice per turn
191 Verdeckt Face-down
192 Inferno Inferno
193 Tiefsee Deep Sea
194 Berg Mountain
195 Orkan Hurricane
196 Dampf Steam
197 Lava Lava
198 Blitz Lightning
199 Schlamm Mud
200 Sturm Storm
201 Sand Sand
202 Feuerwesen haben +1 Angriff Fire creatures have +1 attack
203 Wesen kommen mit +1 Leben Creatures enter with +1 health
204 Bekommt jeden Zug 1 Mana mehr Gets 1 extra mana each turn
205 Ziehen kostet kein Mana Drawing costs no mana
206 Spott Taunt
207 Regeneration Regeneration
208 Muss zuerst angegriffen werden Must be attacked first
209 Greift sofort an Attacks right away
210 Heilt sich am Zugende ganz Heals fully at the end of the turn
211 Wesen greifen sofort an Creatures attack right away
212 Verschmelzen mit … Fuse with …
213 Ziel wählen Pick the partner
214 Erst die Wesen mit Spott Taunting creatures first
215 %s kann erst im nächsten Zug aufgedeckt werden %s can be revealed next turn
216 %s greift erst im nächsten Zug an %s attacks next turn
217 greift erst im nächsten Zug an attacks next turn
218 %s durch das Element %s from its element
219 Zwei verschiedene Karten wählen Pick two different cards
220 stirbt dies
221 Abwerfen +%d ◆ Discard +%d ◆
222 Überspringen Skip
223 Zug %d Turn %d
224 Stapel: %d Karten (%d bis %d) Deck: %d cards (%d to %d)
225 Stärke %d Strength %d
226 Herstellen (%d Staub) Craft (%d dust)
227 Nicht in der Sammlung Not in the collection
228 %s hergestellt %s crafted
229 Neu! New!
230 Auffüllen Fill up
231 Leeren Clear
232 Dein Stapel ist leer Your deck is empty
233 Klick auf eine Karte: rein oder raus. Click a card: in or out.
234 Manakurve Mana curve
235 Optionen Options
236 Hauptmenü Main menu
237 Neutral Neutral
238 Nie im Vorteil oder Nachteil Never at an advantage or disadvantage
239 Veteran Veteran
240 Feuerritter Fire Knight
241 Wasserritter Water Knight
242 Erdritter Earth Knight
243 Luftritter Air Knight
Binary file not shown.