Compare commits

..
16 Commits
Author SHA1 Message Date
team3 f2c609a08b update
Tests / headless (push) Waiting to run
Tests / fenster-schnell (push) Waiting to run
Tests / fenster-voll (push) Waiting to run
Tests / balance (push) Waiting to run
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
74 changed files with 5937 additions and 8953 deletions

No files matched your search

+3 -8
View File
@@ -54,7 +54,7 @@ jobs:
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1 sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
- name: Import - name: Import
run: ./godot --headless --path . --import || true run: ./godot --headless --path . --import || true
- name: Fenster-Test (voll, mit Netzwerk und Hotseat) - 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 run: xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
- name: Ergebnis - name: Ergebnis
run: grep -q " 0 fehlgeschlagen" fenster.log run: grep -q " 0 fehlgeschlagen" fenster.log
@@ -72,10 +72,5 @@ jobs:
chmod +x godot chmod +x godot
- name: Import - name: Import
run: ./godot --headless --path . --import || true run: ./godot --headless --path . --import || true
- name: Archetypen messen - name: Balance messen
run: ./godot --headless --path . -s tests/strength_probe.gd 2>&1 | grep -E "%|geschrieben" run: ./godot --headless --path . -s tests/simulate.gd 2>&1 | grep -E "Siege|Dauer"
- name: Ergebnis sichern
uses: actions/upload-artifact@v4
with:
name: archetypen
path: data/archetypen.json
+38 -56
View File
@@ -1,75 +1,59 @@
# Dinger # 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` Der Spielstand liegt unter `user://spielstand.json`
(Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`). (Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`).
## Spielsysteme ## Spielsysteme
- 100 Karten in `data/karten.json`: 4 Elemente, 10 Zweier-Kombos (auch gleiche Elemente), 4 mythische Dreifach-Kombos, 4 Fallen - 150 Karten in `data/karten.json`: je Element und neutral alle Werte von 0/1 bis 5/5, die ID verrät sie (`f_3_2` = Feuer 3/2, `n_…` neutral), Namen aus Eigenschaft und Wesen; Kombos sind die Namen verschmolzener Wesen. Neutrale Karten sind nie im Vorteil oder Nachteil, bilden kein Paar, haben aber ihren eigenen Zähler (+1/+1 je drei); verschmolzen mit einem Element (Feuerritter …) bleibt dessen Vorteil, die Schwäche fällt weg
- Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus - 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)
- Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht) - Mana: 1 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen und Legen
- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern, Erfolge, Tagesaufgabe - Jeder spielt mit seinem eigenen Stapel aus 40 bis 60 Karten (Menü „Deck“: eigene Karten rein oder raus, „Auffüllen“ nimmt die wertvollsten des gewählten Filters, bei gleichem Wert die teuersten; „Stärke“ ist der mittlere Kartenwert: ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100, also 100 für Grundkarten bis 500 für Ultimate Rare). Zu Beginn die obersten fünf Karten des gemischten Stapels. Ziehen bringt eine Karte, die erste im Zug ist frei, dann 1, 2, 3 … Mana; ist der Stapel leer, kostet jeder Zug 1, 2, 3 … Leben (Erschöpfung); eine Handkarte abwerfen bringt 1 Mana (Rechtsklick)
- Lokal zu mehreren: bis zu 4 Menschen am selben Rechner plus KI - 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
- Booster, fünf Deck-Slots, Werkstatt (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen) - Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar und bekommt den Elementbonus erst offen; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf
- Beim ersten Start wählt man eines von vier Startdecks (40 Karten: 25 eines Elements bis Angriff 4, je fünf billige der anderen), der Rest kommt aus Boostern oder der Werkstatt. Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare) und ist je Stufe +1/+1 stärker, bei gleichen Kosten; die Stufe zeigt sich auch in der Optik (Glanz, Holo, Relief, Funkeln). Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern
- Booster (100 Münzen): fünf Karten mit gewürfelter Stufe (45/25/15/10/5 %, mindestens eine Rare); neue kommen in Sammlung und Stapel, eine höhere Stufe ersetzt die alte Version (die wird zu Staub), der Rest wird zu Staub (50/100/200/500/1000). Werkstatt in der Sammlung: Aufwerten kostet 100/200/500/1000 Staub, Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Elementboni, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); je drei ausgespielte Karten eines Elements geben den eigenen Wesen dieses Elements +1/+1 (verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen). Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Boni. Die Zähler stehen links neben dem jeweiligen Brett und zeigen nur Elemente, bei denen etwas passiert ist: zwei Symbole, gefüllt je offenem eigenen Wesen, und die Zahl der ausgespielten Karten
- Elementvorteil beim Kampf (Wasser > Feuer > Luft > Erde > Wasser): wer im Vorteil ist, schlägt durch den Stapelbonus des Ziels (so viel Schaden mehr, wie das Ziel an +x/+x trägt); wer im Nachteil ist, schlägt ohne seinen eigenen Stapelbonus zu; beim Angriff wie beim Konter, gegen Helden nichts; ohne Stapelbonus bewirkt das Element nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht. Ist ein Angreifer gewählt, zeigen die Gegnerkarten ihren Konter gegen ihn (grün mehr, rot weniger); über einem Ziel zeigt der Angreifer seinen Angriff mit Vorteil, und wer stirbt, trägt die Marke „stirbt“
- Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar, per Doppelklick auf das eigene Wesen direkt. Nichts schlägt durch. Den Zug beendet die Leertaste oder der Rechtsklick ins Leere; Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden)
- Verschmelzen: eine Handkarte oder ein eigenes Wesen auf eine andere ziehen und loslassen, auch gemischt; das Ergebnis erscheint über dem Ziel, darunter „Verschmelzen“ oder „Abbrechen“ (Gamepad: Y, „Verschmelzen mit …“). Es kostet nichts; Wesen verlassen das Brett, die neue Karte kommt unverletzt auf die Hand und wird wie jede andere ausgespielt. Je Wert die aufgerundete Hälfte der Grundwertsumme plus eins, Kosten daraus wie bei jeder Karte, dann die höhere Stufe der Eltern mit ihrem +1/+1; Elemente und Eigenschaften beider; drei oder mehr Elemente ergeben eine Chimäre
- Duell mit ausgeloster Zugreihenfolge, beide beginnen mit 1 Mana; die Hand fasst 7 Karten, das Feld 5 Wesen
- Schwierigkeit Leicht/Normal/Schwer: Gegner mit 25/30/35 Leben, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach
- KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread
- Kampagne: drei Kapitel mit je zehn Magiern, Bosse mit ein bis drei Merkmalen und eigenen Stapeln (Kapitel 1 bis Kosten 3, Kapitel 2 bis 5 als Rare, Kapitel 3 alles als Super Rare), eigenes Merkmal nach Kapitel 1
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`) - Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
- Anleitung (geführte erste Partie), Mulligan im ersten Zug, Tastatur (Leertaste, 1 bis 9, Esc, Enter) - Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`)
- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten - Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen; Ton und Musik sind standardmäßig aus
- 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
- Geführte Anleitung mit Hervorhebungen, Rückgängig für Fehlklicks, Überspringen von Gegnerzügen
- Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1
- Wöchentliche Herausforderung mit Regelvarianten, Bestenliste (Serie, schnellster Sieg), Statistik-Export als CSV
- Zehn Fallenkarten, Netzwerk mit Wiederverbinden (Platz bleibt 60 s frei)
- Deckstärke aus Seltenheit, Schlüsselwörtern und Effekten; Gegnerdecks werden auf die eigene Stärke gebaut
- Schwierigkeit Leicht/Normal/Schwer: Zielstärke 0,9/1,0/1,2-fach, schwache oder starke Archetypen, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach
- Gegnervorschau vor dem Kampf (Element und Stärke, neu würfelbar), Vorschlag zur Schwierigkeit nach drei Siegen oder Niederlagen
- Deckstärke mit Kurvenabzug und Gewichtung durch die eigene Kartenstatistik; Booster schlagen passende Karten fürs Deck vor
- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben, Gischtläufer für Sturm
- Anleitung Teil 2 (Mulligan, Fallen, Feldkombo Dampf), Gamepad-Steuerung, große Schrift, Profil zurücksetzen
- Gegnervorschau mit höchstens drei Neuwürfen, Schwierigkeitstipp nach Spielverlauf (deutliche Siege oder drei Niederlagen)
- KI mit Zeitbudget je Entscheidung (150 ms), Warnung bei ungespeichertem Deck
- Lava trifft nur noch das stärkste gegnerische Wesen; Archetypen-Werte kommen aus `data/archetypen.json` (Messlauf schreibt sie)
- Kartentext passt sich der Schriftgröße an, Gamepad auch in Menüs (Fokusrahmen), Anleitung 2 erzwingt Maras Angriff in die Falle
- KI-Vorausschau in zwei Stufen (erst alle Züge mit den besten Zielen, dann die acht besten vertieft): bei 5 Gegnern 0,1 s statt 2 s, und sie rechnet im Hintergrund-Thread, Musik ebenso
- KI-Bewertung mit Bedrohung (gegnerische Angriffskraft ohne eigenen Wächter, tödliche besonders) und Führenden-Bremse; im Duell 62:38 gegen die alte Bewertung
- Unterbrochene Einzelspieler-Kämpfe (auch Kampagne und Herausforderung) werden laufend gespeichert und lassen sich im Menü fortsetzen
- Gamepad im Deckbau, in der Werkstatt und beim Booster: Karten, Deckzeilen und Kartenrücken sind fokussierbar, A wählt, B geht zurück
- Englisch auch für Kartennamen und Kartentexte (`name_en` in `data/karten.json`, Textbausteine über `tr()`)
- Deck-Codes zum Teilen (`DINGER1:id*anzahl,…`) mit Zwischenablage im Deckbau
- Kapitel 3 der Kampagne (drei Merkmale, volle Tische), acht neue Karten (je Elementpaar eine, zwei Fallen), Elementbooster für 120 Münzen
- Netzwerk: fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung (er bekommt laufend den vollen Spielstand), die anderen verbinden neu; Zuschauer sehen die Partie ohne Hände
- Balance in drei Messrunden (je 200 Partien pro Archetyp): Sand gibt +1/+1 statt Schild, Wasser und Feuer zäher, Lava-Karten leicht schwächer; Spanne jetzt 40–64 % statt 36–78 % (Feuer+Erde bleibt mit 64 % die Spitze)
- Große Schrift: Kartennamen neben dem Kostenkreis, Kartentext läuft nicht mehr über, breitere Knöpfe in Booster, Deckbau und Werkstatt
- Übersetzungsprüfung: jeder tr()-Text und jeder Anleitungsschritt braucht einen englischen Eintrag
## Tests ## Tests
`tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`: `tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`:
Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test und Archetypen-Messung nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft). Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft).
`DINGER_FAST=1` lässt den Fenster-Test ohne Hotseat, Netzwerk und lange Kämpfe laufen (etwa ein Drittel der Zeit). 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.
Statistiken aus Spieltests lassen sich unter Erfolge als CSV exportieren.
``` ```
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/test_runner.gd # Regeln, KI, Booster, Spielstand
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar
godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH)
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
DINGER_ONLY=test_host_wechsel,test_zuschauer godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests DINGER_ONLY=test_sammlung godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
N=200 godot --headless --path . -s tests/strength_probe.gd # Messlauf mit 200 statt 60 Partien je Archetyp godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Stapel, Musik
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Deckerzeugung, Musik godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern
``` ```
## Export ## Export
@@ -84,14 +68,12 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
## Aufbau ## Aufbau
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json` - `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Hotseat - `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementboni, Kampf, Verschmelzen
- `scripts/deckgen.gd` – Deckstärke, Gegnerdecks entlang der Energiekurve, Archetypen - `scripts/deckgen.gd` – Stapel: zufällige für Gegner, Standardstapel aus der Sammlung
- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI) - `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen - `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Stapel, Booster, Werkstatt, Einstellungen
- `scripts/campaign.gd` – Kampagnenstufen - `scripts/campaign.gd` – Kampagnenstufen
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte, Anleitung, Mulligan, Gamepad), Erfolge, Einstellungen, Regeln, Ton
- `scenes/main.tscn` – Startszene - `scenes/main.tscn` – Startszene
- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden - `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `element_view.gd` den Elementzähler, `battle_layout.gd` die Aufteilung des Kampfes
- `scripts/challenge.gd` – wöchentliche Herausforderungen
- `translations/ui.csv` – englische Oberflächentexte - `translations/ui.csv` – englische Oberflächentexte
- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs
+24 -18
View File
@@ -1,26 +1,32 @@
# Dinger # Cards
##
## Grundidee ## Grundidee
- Kartenspiel wie zum Beispiel Magic - Kartenspiel wie zum Beispiel Magic
- PC-Spiel - PC-Spiel
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln - Duell: Spieler gegen einen KI-Gegner, beide mit gleichen Regeln
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl - Einzelspieler-Knopf führt zur Wahl der Schwierigkeit
- Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt - Kampagne gegen benannte Magier als Fortschritt
- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich, auch als Zuschauer
- Ein unterbrochener Kampf lässt sich später fortsetzen
- Geführte erste Partie als Einstieg
- Wöchentliche Herausforderungen und Bestenliste als Langzeitziel
## Sammeln ## Sammeln
- Booster öffnen - Man startet mit einem von vier Startdecks (40 Karten: 25 eines Elements bis Angriff 4, je fünf billige der anderen); alle Karten sammeln ist das lange Ziel
- Karten haben Seltenheiten - 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
- Aus der Sammlung werden Decks gebaut (mehrere Slots) und als Code geteilt - 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)
- Überschüssige Karten lassen sich zerlegen und neue herstellen - Werkstatt: Staub wertet gezielt auf (100/200/500/1000), Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
- Jeder hat sein Deck: 40 bis 60 eigene Karten, im Menü „Deck“ zusammengestellt (Auffüllen folgt dem Filter, wertvollste zuerst; Stärke = mittlerer Kartenwert ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100: 100 bis 500); zu Beginn die obersten fünf Karten daraus; Ziehen bringt eine Karte, die erste im Zug frei, dann 1, 2, 3 … Mana; leerer Stapel: jeder Zug kostet 1, 2, 3 … Leben
- 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 ## Elemente
- Karten gehören zu Elementen - Jede Karte gehört zu genau einem Element oder ist neutral; je Element und neutral gibt es alle Werte von 0/1 bis 5/5 (150 Karten). Neutral: nie im Vorteil oder Nachteil, kein Paar, aber ein eigener Zähler (+1/+1 je drei); verschmolzen nimmt das Wesen im schlechten Matchup die neutrale Seite
- Elemente sind kombinierbar, daraus entstehen viele Varianten - Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan)
- Gleiche Elemente sind auch eine Kombo (Feuer + Feuer) - Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag)
- Jedes Element und jede Kombo hat eine eigene Spielweise - Je drei ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements, jeder Spieler zählt für sich; verdeckt gelegte zählen mit, bekommen den Bonus aber erst offen; die Karte zeigt vorher, was sie bekäme; verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte und Boni
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus - Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (wer im Vorteil ist, schlägt durch den Stapelbonus des Ziels: so viel Schaden mehr, wie das Ziel an +x/+x trägt; wer im Nachteil ist, schlägt ohne seinen eigenen Stapelbonus zu; beim Angriff wie beim Konter; ohne Stapelbonus bewirkt das Element nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht)
- Verdeckte Fallen bringen Bluff und Reaktion im gegnerischen Zug - 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
-12
View File
@@ -1,12 +0,0 @@
{
"Erde": 50,
"Erde+Luft": 56,
"Feuer": 40,
"Feuer+Erde": 64,
"Feuer+Luft": 42,
"Feuer+Wasser": 57,
"Luft": 45,
"Wasser": 43,
"Wasser+Erde": 50,
"Wasser+Luft": 41
}
+1347 -2225
View File
File diff suppressed because it is too large. Load diff
+144
View File
@@ -0,0 +1,144 @@
# 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), rechts der
Elementbonus.
- 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.
- **Elementzähler** je Spieler als Liste links neben seinem Brett, nur Elemente mit Wesen oder gespielten Karten.
Je Element zwei Symbole in seiner Form, gefüllt je offenem eigenen Wesen (beide voll: Sondereffekt),
daneben das Mana der ausgespielten Karten; je 10 gibt es +1 Angriff.
- **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.
- Handkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen
steht der Elementkonter (der ignorierte Stapelbonus) am Ziel, bei Verdeckten nicht.
## 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, Einstellungen,
Beenden; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und
Staub oben rechts.
- Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter.
- Kampagne als Pfad mit zehn Stationen je Kapitel, die nächste pulsiert; darunter die gewählte
Stufe mit Gegner, Leben, Belohnung und Merkmalen. Die Bosse werden über Leben und Merkmale
stärker, nicht über Begleiter.
- Sammlung: sechs Spalten, eigene Karten zuerst (höchste Stufen vorn), fehlende abgedunkelt, rechts
die große Vorschau mit Aufwerten, Zerlegen oder Herstellen. Deck: gleiches Raster, nur eigene Karten,
Stapelkarten hell, die anderen abgedunkelt; Klick wechselt. Rechts die Kartenzahl und die Stärke
(mittlerer Kartenwert, 100 für Grundkarten bis 500 für Ultimate Rare).
## 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. ; Engine configuration file.
; It's best edited using the editor UI and not directly, ; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious. ; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5 config_version=5
@@ -15,7 +19,6 @@ config/features=PackedStringArray("4.7", "GL Compatibility")
window/size/viewport_width=1280 window/size/viewport_width=1280
window/size/viewport_height=800 window/size/viewport_height=800
window/stretch/mode="canvas_items" window/stretch/mode="canvas_items"
window/stretch/aspect="keep"
[internationalization] [internationalization]
+150 -186
View File
@@ -1,25 +1,19 @@
class_name AI class_name AI
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels ## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels ausgeführt und die
## ausgeführt und die Stellung danach bewertet; die besten Erstschritte werden um ## Stellung danach bewertet; die besten Erstschritte werden um einen Folgeschritt vertieft.
## einen Folgeschritt vertieft. Die gierige Variante (choose_action_greedy) dient ## Verdeckte gegnerische Wesen kennt die KI nicht (Game.masked_clone). Die gierige Variante
## als Vergleich und für die Schwierigkeit Leicht. Alles hier ist zustandslos und ## (choose_action_greedy) dient als Vergleich und für die Schwierigkeit Leicht. Alles hier ist
## darf in einem Hintergrund-Thread auf einer Spielkopie laufen. ## zustandslos und darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
# --- Bewertung ------------------------------------------------------------------- # --- Bewertung -------------------------------------------------------------------
static func creature_value(c: Game.Creature) -> float: static func creature_value(c: Game.Creature) -> float:
var v := float(c.power() + c.health) 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: if c.attacks_per_turn() == 2:
v += c.power() v += c.power() * 0.5
if c.frozen: if c.has("Regeneration"):
v -= c.power() # setzt den nächsten Angriff aus: der Angriffswert zählt nicht v += 1.0
if not c.ready:
v -= c.power() * 0.25
return v return v
@@ -30,16 +24,22 @@ static func board_value(p: Game.Player) -> float:
return v return v
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, die der Gegner ## Karten auf der Hand zählen nach ihren Werten: ein Drache ist mehr wert als ein Kobold.
## negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden bremsen). static func hand_value(p: Game.Player) -> float:
## Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders. 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: static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive(): if not p.alive():
return -10000.0 return -10000.0
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5 var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.01
var alive_enemies := 0 var alive_enemies := 0
var threat := 0.0 var threat := 0.0
var leader := 0.0
for e in g.players: for e in g.players:
if e == p: if e == p:
continue continue
@@ -47,54 +47,33 @@ static func evaluate(g: Game, p: Game.Player) -> float:
score += 40.0 score += 40.0
continue continue
alive_enemies += 1 alive_enemies += 1
var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2 score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
score -= ev
leader = maxf(leader, ev)
if e.life <= 6: if e.life <= 6:
score += 3.0 # fast besiegt: dranbleiben score += 3.0 # fast besiegt: dranbleiben
for c in e.board: for c in e.board:
if not c.frozen: if not c.hidden:
threat += c.power() * c.attacks_per_turn() threat += c.power() * c.attacks_per_turn()
if alive_enemies == 0: if alive_enemies == 0:
return 10000.0 return 10000.0
score -= leader * 0.25 if p.board.is_empty():
var guarded := false if threat >= p.life:
for c in p.board:
if c.taunt():
guarded = true
break
if guarded:
score -= threat * 0.1
elif threat >= p.life:
score -= 25.0 score -= 25.0
else: else:
score -= threat * 0.3 score -= threat * 0.3
else:
score -= threat * 0.12 # auch hinter Wesen tut gegnerische Angriffskraft weh
return score return score
# --- Vorausschau --------------------------------------------------------------- # --- Vorausschau ---------------------------------------------------------------
const MAX_TARGETS := 4 # je Karte bzw. Angreifer nur die aussichtsreichsten Ziele prüfen const MAX_TARGETS := 4 # je Angreifer nur die aussichtsreichsten Ziele prüfen
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
## Grobe Rangfolge eines Ziels für einen Karteneffekt (nur zum Vorsortieren). ## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann der Rest.
static func _target_rank(kind: String, amount: int, t) -> float:
if t is Game.Player:
return 50.0 - t.life if kind == "damage" else 0.0
match kind:
"damage":
return 100.0 + _value(t) if not t.shield and t.health <= amount else float(_value(t))
"freeze", "bounce":
return t.card.cost * 10.0 + t.power()
"buff", "shield":
return (100.0 if t.can_attack() else 0.0) + t.power()
return float(_value(t))
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann Helden, dann der Rest.
static func _attack_rank(cr: Game.Creature, t) -> float: static func _attack_rank(cr: Game.Creature, t) -> float:
if t is Game.Player: if t is Game.Player:
return 50.0 - t.life return 50.0 - t.life
@@ -113,45 +92,26 @@ static func _top(list: Array, rank: Callable, n: int) -> Array:
return ranked.slice(0, n).map(func(pair): return pair[1]) return ranked.slice(0, n).map(func(pair): return pair[1])
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Karte und Angreifer. ## 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: static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
var out := [] var out := []
var valid_cache := {}
for card in p.hand: for card in p.hand:
if not game.can_play(p, card)[0]: if game.can_play(p, card)[0]:
continue out.append({"type": "play", "card": card, "face_down": false})
var tt: String = card.target_type() for cr in p.board:
if tt == "": if cr.hidden and game.can_reveal(p, cr)[0]:
out.append({"type": "play", "card": card, "target": null}) out.append({"type": "reveal", "creature": cr})
continue for pr in fusion_pairs(game, p, prune):
if not valid_cache.has(tt): out.append({"type": "fuse", "creature": pr[0], "other": pr[1]})
valid_cache[tt] = game.valid_targets(p, tt)
var valid: Array = valid_cache[tt]
if valid.is_empty():
out.append({"type": "play", "card": card, "target": null})
continue
if prune:
var fx: Cards.Effect = null
for f in card.effects:
if f.target == tt:
fx = f
break
var kind: String = fx.kind if fx != null else ""
var amount: int = fx.amount if fx != null else 0
var key := "%s/%s/%d" % [tt, kind, amount]
if not valid_cache.has(key):
valid_cache[key] = _top(valid, func(t): return _target_rank(kind, amount, t), MAX_TARGETS)
valid = valid_cache[key]
for t in valid:
out.append({"type": "play", "card": card, "target": t})
for cr in p.board: for cr in p.board:
if not cr.can_attack(): if not cr.can_attack():
continue continue
var targets := [] var targets := []
for e in game.enemies_of(p): for e in game.enemies_of(p):
for t in e.board + [e]: targets.append_array(e.board)
if game.can_attack(cr, t)[0]: if e.board.is_empty():
targets.append(t) targets.append(e)
targets = targets.filter(func(t): return game.can_attack(cr, t)[0])
if prune: if prune:
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS) targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
for t in targets: for t in targets:
@@ -159,16 +119,51 @@ static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -
return out 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. ## Führt eine Aktion des Originals auf der Kopie aus.
static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void: static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
var p2: Game.Player = g2.players[game.current_idx] var p2: Game.Player = g2.players[game.current_idx]
var target = null match action.type:
if action.get("target") != null: "play":
target = map_target(g2, game, action.target) g2.play_card(p2, action.card, bool(action.get("face_down", false)))
if action.type == "play": "reveal":
g2.play_card(p2, action.card, target) g2.reveal(p2, g2.find_creature(action.creature.uid))
else: "fuse":
g2.attack(g2.find_creature(action.creature.uid), target) 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): static func map_target(g2: Game, game: Game, t):
@@ -181,34 +176,30 @@ static func map_target(g2: Game, game: Game, t):
static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary: static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary:
var out := action.duplicate() var out := action.duplicate()
if action.has("creature"): if action.has("creature"):
out["creature"] = game.find_creature(action.creature.uid) out["creature"] = _part(game, action.creature)
if action.has("other"):
out["other"] = _part(game, action.other)
if action.get("target") != null: if action.get("target") != null:
out["target"] = map_target(game, copy, action.target) out["target"] = map_target(game, copy, action.target)
return out return out
## Mulligan der KI: teure Karten im ersten Zug zurücklegen. ## Beste Aktion nach einer Vorausschau von zwei Schritten auf einer Kopie, die verdeckte
static func mulligan_cards(p: Game.Player) -> Array: ## gegnerische Wesen nicht kennt. Bei Zeitmangel gilt der beste bisherige Zug.
return p.hand.filter(func(c): return c.cost >= 5)
## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer
## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug.
static func choose_action(game: Game, p: Game.Player) -> Dictionary: static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty(): if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"} return {"type": "end"}
if not p.mulligan_done and p.turns == 1 and p == game.current(): var view := game.masked_clone(p)
return {"type": "mulligan", "cards": mulligan_cards(p)} var me: Game.Player = view.players[game.players.find(p)]
var t0 := Time.get_ticks_msec() var t0 := Time.get_ticks_msec()
var base := evaluate(game, p) + 0.01 var base := evaluate(view, me) + 0.01
var firsts := [] # {action, game, score} var firsts := [] # {action, game, score}
for action in candidate_actions(game, p, true): for action in candidate_actions(view, me, true):
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms: if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
break break
var g2 := game.clone_for(p) var g2 := view.clone()
apply_on_clone(g2, game, action) apply_on_clone(g2, view, action)
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])}) 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) firsts.sort_custom(func(a, b): return a.score > b.score)
var best := {"type": "end"} var best := {"type": "end"}
var best_score := base var best_score := base
@@ -217,15 +208,15 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
var g2: Game = f.game var g2: Game = f.game
var score: float = f.score var score: float = f.score
if g2.winner == "": if g2.winner == "":
var p2: Game.Player = g2.players[game.current_idx] var p2: Game.Player = g2.players[view.current_idx]
var count := 0 var count := 0
for nxt in candidate_actions(g2, p2, true): for nxt in candidate_actions(g2, p2, true):
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms: if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
break break
count += 1 count += 1
var g3 := g2.clone_for(p2) var g3 := g2.clone()
apply_on_clone(g3, g2, nxt) apply_on_clone(g3, g2, nxt)
score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001) score = maxf(score, evaluate(g3, g3.players[view.current_idx]) - 0.001)
if score > best_score: if score > best_score:
best_score = score best_score = score
best = f.action best = f.action
@@ -234,50 +225,49 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if firsts[i].score > best_score: if firsts[i].score > best_score:
best_score = firsts[i].score best_score = firsts[i].score
best = firsts[i].action 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 best
return map_action(game, view, best)
# --- Gierige Vergleichs-KI ------------------------------------------------------- # --- Einfache Heuristiken ----------------------------------------------------------
static func _kills(attacker, defender) -> bool: 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: 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 = null
var best_v := 0 var best_v := -INF
for x in arr: for t in list:
var v: int = key.call(x) var v := float(key.call(t))
if best == null or v > best_v: if v > best_v:
best = x
best_v = v best_v = v
return best best = t
static func _threat_score(h) -> int:
var board_attack := 0
for c in h.board:
board_attack += c.power()
return h.life - board_attack
static func _weakest(heroes: Array):
var best = null
for h in heroes:
if best == null or _threat_score(h) < _threat_score(best):
best = h
return best return best
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary: static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty(): if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"} return {"type": "end"}
if not p.mulligan_done and p.turns == 1 and p == game.current(): # Gierig: ein Wesen mit höchstens halbem Leben heilen (mit der billigsten Handkarte
return {"type": "mulligan", "cards": mulligan_cards(p)} # 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]) var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
if not playable.is_empty(): if not playable.is_empty():
Game.shuffle(playable, game.rng) Game.shuffle(playable, game.rng)
@@ -285,71 +275,40 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
for c in playable: for c in playable:
if c.cost > card.cost: if c.cost > card.cost:
card = c 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: for cr in p.board:
if cr.can_attack(): if cr.can_attack():
var t = pick_attack_target(game, p, cr) var t = pick_attack_target(game, p, cr)
if t != null: if t != null:
return {"type": "attack", "creature": cr, "target": t} return {"type": "attack", "creature": cr, "target": t}
if game.can_draw(p)[0]:
return {"type": "draw"}
return {"type": "end"} return {"type": "end"}
static func pick_target(game: Game, p: Game.Player, card: Cards.Card): ## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
var tt := card.target_type() ## der Held nur, wenn kein Wesen mehr steht.
if tt == "":
return null
var valid := game.valid_targets(p, tt)
if valid.is_empty():
return null
var fx: Cards.Effect = null
for f in card.effects:
if f.target == tt:
fx = f
break
var creatures := valid.filter(func(t): return t is Game.Creature)
var heroes := valid.filter(func(t): return t is Game.Player)
match fx.kind:
"damage":
var killable := creatures.filter(func(c): return not c.shield and c.health <= fx.amount)
if not killable.is_empty():
return _best(killable, Callable(AI, "_value"))
if not heroes.is_empty():
return _weakest(heroes)
return _best(creatures, Callable(AI, "_value"))
"freeze", "bounce":
return _best(creatures, func(c): return c.card.cost * 100 + c.power())
"buff", "shield":
return _best(creatures, func(c): return (100 if c.can_attack() else 0) + c.power())
return valid[0]
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature): static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
var enemies := game.enemies_of(p) for e in game.enemies_of(p):
var total := 0 if e.board.is_empty():
for c in p.board: return e if game.can_attack(cr, e)[0] else null
if c.can_attack(): var creatures: Array = e.board.filter(func(c): return game.can_attack(cr, c)[0])
total += c.power() * c.attacks_left if creatures.is_empty():
var options := [] continue
for e in enemies: var good: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and not _kills(c, cr))
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(): if not good.is_empty():
return _best(good, Callable(AI, "_value")) return _best(good, Callable(AI, "_value"))
var trade := creatures.filter(func(c): return _kills(cr, c) and _value(c) > _value(cr)) 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(): if not trade.is_empty():
return _best(trade, Callable(AI, "_value")) return _best(trade, Callable(AI, "_value"))
if not heroes.is_empty():
return _weakest(heroes)
if not creatures.is_empty():
return _best(creatures, func(c): return 1000 - c.health) return _best(creatures, func(c): return 1000 - c.health)
return null return null
@@ -359,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: static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
match action.type: match action.type:
"play": "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": "attack":
game.attack(action.creature, action.target) game.attack(action.creature, action.target)
"mulligan": "fuse":
game.mulligan(p, action.cards) game.fuse(p, action.creature, action.other)
_: _:
game.end_turn() game.end_turn()
@@ -375,3 +338,4 @@ static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: i
apply_action(game, p, action) apply_action(game, p, action)
if action.type == "end" or game.winner != "": if action.type == "end" or game.winner != "":
return return
game.end_turn()
+45 -55
View File
@@ -1,63 +1,52 @@
class_name Campaign class_name Campaign
## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Gegnern am Tisch. ## Kampagne: zehn benannte Magier in drei Kapiteln. Die Bosse haben weniger Leben als du,
## dafür Merkmale; je Kapitel kommt eins dazu.
const STAGES := [ const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"}, {"name": "Fenn", "title": "Kobold-Zähmer", "life": 22, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "reward": 100, "perk": "leben_35"}, {"name": "Mara", "title": "Gezeitenhexe", "life": 22, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "reward": 150, "perk": "waechter_leben"}, {"name": "Borin", "title": "Steinfaust", "life": 24, "reward": 150, "perk": "wesen_angriff"},
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "reward": 150, "perk": "flink_angriff"}, {"name": "Liska", "title": "Windläuferin", "life": 24, "reward": 150, "perk": "ziehen_gratis"},
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "zauber_schaden"}, {"name": "Vulkar", "title": "Herr der Lava", "life": 24, "reward": 200, "perk": "wesen_leben"},
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_karten"}, {"name": "Nyx", "title": "Sturmruferin", "life": 26, "reward": 200, "perk": "extra_mana"},
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "reward": 250, "perk": "resonanz_doppelt"}, {"name": "Ignis", "title": "Inferno-Priester", "life": 26, "reward": 250, "perk": "element_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "reward": 250, "perk": "schild_wesen"}, {"name": "Thalassa", "title": "Tiefseekönigin", "life": 26, "reward": 250, "perk": "alle_flink"},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 300, "perk": "alle_flink"}, {"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 300, "perk": "alle_flink"},
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 500, "perk": "start_energie"}, {"name": "Archimagier", "title": "Meister der Elemente", "life": 28, "reward": 500, "perk": "start_energie"},
] ]
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück. ## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Leben zurück.
const STAGES2 := [ const STAGES2 := [
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, {"name": "Fenn", "title": "Flammenzähmer", "life": 22, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "reward": 300, "perks": ["leben_35", "extra_karten"]}, {"name": "Mara", "title": "Herrin der Gezeiten", "life": 22, "reward": 300, "perks": ["leben_35", "extra_mana"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]}, {"name": "Borin", "title": "Fels der Ewigkeit", "life": 24, "reward": 350, "perks": ["wesen_angriff", "alle_flink"]},
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]}, {"name": "Liska", "title": "Sturmläuferin", "life": 24, "reward": 350, "perks": ["ziehen_gratis", "wesen_angriff"]},
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]}, {"name": "Vulkar", "title": "Aschekönig", "life": 24, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_karten", "flink_angriff"]}, {"name": "Nyx", "title": "Gewitterruferin", "life": 26, "reward": 400, "perks": ["extra_mana", "ziehen_gratis"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]}, {"name": "Ignis", "title": "Inferno-Erzpriester", "life": 26, "reward": 450, "perks": ["element_doppelt", "wesen_leben"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, {"name": "Thalassa", "title": "Kaiserin der Tiefe", "life": 26, "reward": 450, "perks": ["alle_flink", "leben_35"]},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 500, "perks": ["alle_flink", "start_energie"]}, {"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 500, "perks": ["alle_flink", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]}, {"name": "Archimagier", "title": "Herr aller Elemente", "life": 28, "reward": 800, "perks": ["start_energie", "element_doppelt"]},
] ]
## Kapitel 3: drei Merkmale, volle Tische, die stärksten Decks. ## Kapitel 3: drei Merkmale und volle Tische.
const STAGES3 := [ const STAGES3 := [
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "zauber_schaden"]}, {"name": "Fenn", "title": "Aschefürst", "life": 20, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_karten", "schild_wesen"]}, {"name": "Mara", "title": "Sturmflut", "life": 20, "reward": 600, "perks": ["leben_35", "extra_mana", "alle_flink"]},
{"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "extra": 3, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]}, {"name": "Borin", "title": "Berg selbst", "life": 22, "reward": 700, "perks": ["wesen_angriff", "alle_flink", "leben_35"]},
{"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "extra": 3, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_karten"]}, {"name": "Liska", "title": "Windbraut", "life": 22, "reward": 700, "perks": ["ziehen_gratis", "wesen_angriff", "extra_mana"]},
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["zauber_schaden", "feuer_angriff", "waechter_leben"]}, {"name": "Vulkar", "title": "Magmakaiser", "life": 22, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "wesen_angriff"]},
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_karten", "flink_angriff", "resonanz_doppelt"]}, {"name": "Nyx", "title": "Orkanmutter", "life": 24, "reward": 800, "perks": ["extra_mana", "ziehen_gratis", "element_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 5, "reward": 900, "perks": ["resonanz_doppelt", "zauber_schaden", "start_energie"]}, {"name": "Ignis", "title": "Sonnenpriester", "life": 24, "reward": 900, "perks": ["element_doppelt", "wesen_leben", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 5, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]}, {"name": "Thalassa", "title": "Herz der Tiefe", "life": 24, "reward": 900, "perks": ["alle_flink", "leben_35", "element_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 1000, "perks": ["alle_flink", "start_energie", "zauber_schaden"]}, {"name": "Zephyra", "title": "Gewitterkaiserin", "life": 26, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]},
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 5, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_karten", "leben_35"]}, {"name": "Archimagier", "title": "Ewiger Meister", "life": 26, "reward": 1200, "perks": ["start_energie", "element_doppelt", "leben_35"]},
] ]
const CHAPTERS := [STAGES, STAGES2, STAGES3] const CHAPTERS := [STAGES, STAGES2, STAGES3]
# Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks.
const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6]
const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4]
const MIN_RATING3 := [3.2, 3.3, 3.5, 3.6, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4]
const MIN_RATINGS := [MIN_RATING, MIN_RATING2, MIN_RATING3]
static func target_rating(stage_index: int, player_rating: float) -> float:
var base: float = MIN_RATINGS[chapter(stage_index) - 1][stage_index % STAGES.size()]
return maxf(base, player_rating)
static func total() -> int: static func total() -> int:
return STAGES.size() * CHAPTERS.size() return STAGES.size() * CHAPTERS.size()
@@ -75,15 +64,16 @@ static func stage(i: int) -> Dictionary:
return CHAPTERS[chapter(i) - 1][i % STAGES.size()] return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist). ## Boss einer Stufe mit dem Leben, den Merkmalen und einem Stapel der Stufe; ab Kapitel 2 sind
static func opponents(stage_index: int, r: RandomNumberGenerator, player_rating: float = 0.0) -> Array: ## seine Karten eine Seltenheitsstufe höher (+1/+1), ab Kapitel 3 zwei.
static func opponent(stage_index: int, r: RandomNumberGenerator = null) -> Game.Player:
var st := stage(stage_index) var st := stage(stage_index)
var target := target_rating(stage_index, player_rating) var boss := Game.Player.new(st.name, false, int(st.life), st.title)
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_rated(r, st.elements, target), false, life, st.title)
boss.perks = Array(st.perks) boss.perks = Array(st.perks)
var out := [boss] if r == null:
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) r = Game.make_rng()
for k in extra: var ch := chapter(stage_index)
out.append(Game.random_opponent(r, k + 1, Game.START_LIFE, target)) boss.deck = DeckGen.opponent_deck(r, ch)
return out for id in boss.deck:
boss.looks[id] = ch - 1
return boss
+175 -191
View File
@@ -1,15 +1,25 @@
class_name Cards class_name Cards
## Elemente, Kombos und der Kartenpool. ## Elemente, Kombonamen, Schlüsselwörter und der Kartenpool.
const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"] 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 := { const ELEMENT_COLORS := {
"Feuer": Color("be3c23"), "Feuer": Color("be3c23"),
"Wasser": Color("2d64be"), "Wasser": Color("2d64be"),
"Erde": Color("73552d"), "Erde": Color("73552d"),
"Luft": Color("78aac8"), "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 := { const COMBOS := {
"Neutral+Neutral": "Veteran",
"Feuer+Neutral": "Feuerritter",
"Wasser+Neutral": "Wasserritter",
"Erde+Neutral": "Erdritter",
"Luft+Neutral": "Luftritter",
"Feuer+Feuer": "Inferno", "Feuer+Feuer": "Inferno",
"Wasser+Wasser": "Tiefsee", "Wasser+Wasser": "Tiefsee",
"Erde+Erde": "Berg", "Erde+Erde": "Berg",
@@ -20,166 +30,139 @@ const COMBOS := {
"Wasser+Erde": "Schlamm", "Wasser+Erde": "Schlamm",
"Wasser+Luft": "Sturm", "Wasser+Luft": "Sturm",
"Erde+Luft": "Sand", "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. # Elementkreis: Wasser löscht Feuer, Feuer verbrennt Luft, Luft trägt Erde ab, Erde saugt Wasser auf.
const FIELD_COMBO_TEXT := { const BEATS := {"Wasser": "Feuer", "Feuer": "Luft", "Luft": "Erde", "Erde": "Wasser"}
"Dampf": "Friere das stärkste gegnerische Wesen ein", # Seltenheit ist reine Optik: jede Karte gibt es in jeder Stufe, die Werte bleiben gleich.
"Lava": "1 Schaden an das stärkste gegnerische Wesen", # Stufen wie bei Yu-Gi-Oh: Common grau, Rare silber, Super Rare blau, Ultra Rare gold,
"Blitz": "2 Schaden an den schwächsten Gegner", # Ultimate Rare schillert durch alle Farben.
"Schlamm": "Heile dich um 3", const RARITIES: Array[String] = ["Common", "Rare", "Super Rare", "Ultra Rare", "Ultimate Rare"]
"Sturm": "Ziehe 1 Karte",
"Sand": "Das neue Wesen erhält +1/+1",
}
const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern."
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
const RARITY_COLORS := { const RARITY_COLORS := {
"gewöhnlich": Color("aaaaaa"), "Common": Color("aaaaaa"),
"selten": Color("4682e6"), "Rare": Color("c8d0dc"),
"episch": Color("aa46dc"), "Super Rare": Color("4682e6"),
"legendär": Color("f0aa28"), "Ultra Rare": Color("f0aa28"),
"mythisch": Color("ff5a8c"), "Ultimate Rare": Color("ff5a8c"),
} }
const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"] # Eigenschaften haben Karten nicht von Haus aus; sie kommen allein vom Elementpaar der eigenen
const KEYWORD_HELP := { # Seite (ELEMENT_KEYWORD, siehe Game.Creature.has).
"Wächter": "Muss zuerst angegriffen werden.", const KEYWORDS: Array[String] = ["Spott", "Flink", "Regeneration", "Doppelschlag"]
"Flink": "Kann sofort angreifen.", const KEYWORD_TEXT := {
"Schild": "Blockt den nächsten Schaden.", "Spott": "Muss zuerst angegriffen werden",
"Doppelschlag": "Greift zweimal pro Zug an.", "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"}
# Näherung der Kartenstärke: Seltenheit plus Zuschläge für Schlüsselwörter und Effekte.
const RARITY_POINTS := {"gewöhnlich": 1.0, "selten": 2.0, "episch": 3.0, "legendär": 4.0, "mythisch": 5.0}
const DATA_PATH := "res://data/karten.json" const DATA_PATH := "res://data/karten.json"
## 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: static func combo_key(elements: Array) -> String:
var sorted := elements.duplicate() 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) return "+".join(sorted)
## Name der Elementkombination eines verschmolzenen Wesens; drei oder vier Elemente sind eine Chimäre.
static func combo_name(elements: Array) -> String: static func combo_name(elements: Array) -> String:
if elements.size() == 1: if elements.size() == 1:
return elements[0] return elements[0]
return COMBOS[combo_key(elements)] return COMBOS.get(combo_key(elements), "Chimäre")
static func field_combo_effect(name: String) -> Effect: ## Elementvorteil aus Sicht von own: +1 im Vorteil, -1 im Nachteil, sonst 0.
match name: ## Hat ein Wesen mehrere Elemente, kämpft es mit dem, das gegen die Gegenseite am besten steht.
"Dampf": static func matchup(own: Array, other: Array) -> int:
return Effect.new("freeze", "strongest_enemy_creature") var mine := best_element(own, other)
"Lava": var theirs := best_element(other, own)
return Effect.new("damage", "strongest_enemy_creature", 1) if mine == "" or theirs == "":
"Blitz": return 0
return Effect.new("damage", "weakest_enemy_hero", 2) return duel(mine, theirs)
"Schlamm":
return Effect.new("heal", "self_hero", 3)
"Sturm":
return Effect.new("draw", "none", 1)
"Sand":
return Effect.new("buff", "self_creature", 1, 1)
return null
const TRIGGER_TEXT := { ## +1, wenn a b schlägt, -1, wenn b a schlägt, sonst 0.
"attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift", static func duel(a: String, b: String) -> int:
"creature_played": "Wenn ein Gegner ein Wesen ausspielt", if BEATS.get(a, "") == b:
} return 1
if BEATS.get(b, "") == a:
return -1
return 0
class Effect extends RefCounted: ## Das Element aus own, das gegen die Elemente in other am besten steht.
var kind: String # damage, heal, draw, freeze, bounce, buff, shield, summon, cancel static func best_element(own: Array, other: Array) -> String:
var target: String # siehe CHOSEN_TARGETS und Game.resolve() var best := ""
var amount: int var best_score := -9
var amount2: int for e in own:
var card_id: String # für summon 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: ## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts).
var d := {"kind": kind, "target": target} static func cost_of(attack: int, health: int) -> int:
if amount != 0: return mini(10, (2 * attack + health) / 3)
d["amount"] = amount
if amount2 != 0:
d["amount2"] = amount2
if card_id != "":
d["card_id"] = card_id
return d
static func from_dict(d: Dictionary) -> Effect:
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl). ## Wert eines verschmolzenen Wesens aus den Grundwerten der Eltern: die aufgerundete Hälfte der
func text(trigger: String = "") -> String: ## Summe plus eins. Ein starker und ein schwacher Partner ergeben also weniger als der starke.
var n := str(amount) static func fusion_value(a: int, b: int) -> int:
if target == "trigger_source": return ceili((a + b) / 2.0) + 1
var played := trigger == "creature_played"
match kind:
"damage": ## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt).
if played: static func fusion_name(elements: Array) -> String:
return tr("%s Schaden an dieses Wesen") % n return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]])
return tr("%s Schaden an den Angreifer") % n
"freeze":
if played: ## Verschmelzung zweier Karten: Elemente (sortiert) und Eigenschaften beider, Angriff und Leben
return tr("Es wird eingefroren") ## nach fusion_value aus den Grundwerten, Kosten daraus wie bei jeder Karte; dann die Stufe level
return tr("Der Angreifer wird eingefroren") ## mit ihrem +1/+1 (ohne Angabe die höhere der Eltern). Das Bild trägt der stärkere Elternteil
"bounce": ## (Angriff plus Leben).
return tr("Es geht zurück auf die Hand") static func fused(a: Card, b: Card, level: int = -1) -> Card:
match kind: var attack := fusion_value(a.base_attack(), b.base_attack())
"cancel": var health := fusion_value(a.base_health(), b.base_health())
return tr("Der Angriff verpufft") var els := []
"summon": for e in a.elements + b.elements:
return tr("Beschwöre einen %s") % Cards.by_id(card_id).title() if not (e in els):
"damage": els.append(e)
match target: els.sort_custom(func(x, y): return element_order(x) < element_order(y))
"enemy_creature": var c := Card.new()
return tr("%s Schaden an ein gegnerisches Wesen") % n c.id = "fus_%s_%s" % [a.id, b.id]
"enemy_any": c.name = fusion_name(els)
return tr("%s Schaden an ein Ziel") % n c.elements = els
"all_enemy_creatures": c.attack = attack
return tr("%s Schaden an alle gegnerischen Wesen") % n c.health = health
"all_enemy_heroes": c.cost = cost_of(attack, health)
return tr("%s Schaden an alle Gegner") % n for k in KEYWORDS:
"all_enemies": if k in a.keywords or k in b.keywords:
return tr("%s Schaden an alle Gegner und ihre Wesen") % n c.keywords.append(k)
"heal": var strong := a if a.attack + a.health >= b.attack + b.health else b
return tr("Heile dich um %s") % n c.art = strong.art_card().id
"draw": return c.leveled(maxi(level, maxi(maxi(a.look, b.look), 0)))
if amount == 1:
return tr("Ziehe 1 Karte")
return tr("Ziehe %s Karten") % n ## Platzhalter für ein verdecktes gegnerisches Wesen: unbekanntes Element, geschätzte Werte.
"freeze": static func unknown() -> Card:
if target == "enemy_creature": var c := Card.new()
return tr("Friere ein gegnerisches Wesen ein") c.id = "?"
if target == "strongest_enemy_creature": c.name = "Verdeckt"
return tr("Friere das stärkste gegnerische Wesen ein") c.name_en = "Face-down"
return tr("Friere alle gegnerischen Wesen ein") c.elements = []
"bounce": c.attack = 2
if target == "enemy_creature": c.health = 3
return tr("Ein gegnerisches Wesen zurück auf die Hand") c.cost = cost_of(2, 3)
return tr("Alle gegnerischen Wesen zurück auf die Hand") return c
"buff":
var b := "+%d/+%d" % [amount, amount2]
if target == "friendly_creature":
return tr("Ein befreundetes Wesen erhält %s") % b
return tr("Alle befreundeten Wesen erhalten %s") % b
"shield":
if target == "friendly_creature":
return tr("Ein befreundetes Wesen erhält Schild")
if target == "self_creature":
return tr("Erhält Schild")
return tr("Alle befreundeten Wesen erhalten Schild")
return ""
class Card extends RefCounted: class Card extends RefCounted:
@@ -187,49 +170,70 @@ class Card extends RefCounted:
var name: String # deutscher Name (auch im Verlauf und in Tests) var name: String # deutscher Name (auch im Verlauf und in Tests)
var name_en := "" # englischer Name var name_en := "" # englischer Name
var elements: Array var elements: Array
var cost: int
var rarity: String
var kind: String # "Wesen", "Zauber" oder "Falle"
var attack: int = 0 var attack: int = 0
var health: int = 0 var health: int = 0
var keywords: Array = [] var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet, höchstens 10 (aus den Grundwerten)
var effects: Array = [] var keywords: Array = [] # feste Eigenschaften der Karte (Elementpaare geben ihre zur Laufzeit)
var trigger: String = "" # nur Fallen: attacked, creature_played 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 is_creature() -> bool:
return kind == "Wesen"
func is_trap() -> bool:
return kind == "Falle"
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind} var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health}
if is_creature():
d["attack"] = attack
d["health"] = health
if not keywords.is_empty(): if not keywords.is_empty():
d["keywords"] = keywords d["keywords"] = keywords
if not effects.is_empty(): if look >= 0:
d["effects"] = effects.map(func(e): return e.to_dict()) d["look"] = look
if trigger != "": if art != "":
d["trigger"] = trigger d["art"] = art
if cost != Cards.cost_of(attack, health):
d["cost"] = cost
return d 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: static func from_dict(d: Dictionary) -> Card:
var c := Card.new() var c := Card.new()
c.id = d.id c.id = d.id
c.name = d.name c.name = d.name
c.name_en = str(d.get("name_en", "")) c.name_en = str(d.get("name_en", ""))
c.elements = Array(d.elements) 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.attack = int(d.get("attack", 0))
c.health = int(d.get("health", 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", [])) c.keywords = Array(d.get("keywords", []))
for e in d.get("effects", []): c.look = int(d.get("look", -1))
c.effects.append(Effect.from_dict(e)) c.art = str(d.get("art", ""))
c.trigger = str(d.get("trigger", "")) 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 return c
## Anzeigename in der aktuellen Sprache. ## Anzeigename in der aktuellen Sprache.
@@ -244,32 +248,20 @@ class Card extends RefCounted:
func combo() -> String: func combo() -> String:
return Cards.combo_name(elements) return Cards.combo_name(elements)
func target_type() -> String: func is_neutral() -> bool:
for fx in effects: return Cards.NEUTRAL in elements
if fx.target in Cards.CHOSEN_TARGETS:
return fx.target
return ""
## Kartentext: Neutral und die festen Eigenschaften.
func text() -> String: func text() -> String:
var parts: Array[String] = []
if not keywords.is_empty():
var kws: Array[String] = [] var kws: Array[String] = []
if is_neutral():
kws.append(tr(Cards.NEUTRAL))
for k in Cards.KEYWORDS: for k in Cards.KEYWORDS:
if k in keywords: if k in keywords:
kws.append(tr(k)) kws.append(tr(k))
parts.append(", ".join(kws) + ".") if kws.is_empty():
if not effects.is_empty(): return ""
var fx: Array[String] = [] return ", ".join(kws) + "."
for f in effects:
fx.append(f.text(trigger))
var s := ", ".join(fx) + "."
if is_trap():
parts.append(tr("Falle. %s: %s") % [tr(Cards.TRIGGER_TEXT[trigger]), s])
elif is_creature():
parts.append(tr("Beim Ausspielen: %s") % s)
else:
parts.append(s)
return " ".join(parts)
static var _all: Array = [] static var _all: Array = []
@@ -294,14 +286,6 @@ static func has_id(id) -> bool:
return _by_id.has(id) return _by_id.has(id)
static func card_value(c: Card) -> float:
return RARITY_POINTS[c.rarity] + 0.3 * c.keywords.size() + 0.3 * c.effects.size()
static func of_rarity(r: String) -> Array:
return all().filter(func(c): return c.rarity == r)
## Lädt den Kartenpool aus data/karten.json. ## Lädt den Kartenpool aus data/karten.json.
static func _build() -> void: static func _build() -> void:
var json := JSON.new() 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
+56 -127
View File
@@ -1,142 +1,71 @@
class_name DeckGen class_name DeckGen
## Deckstärke, Gegnerdecks und Archetypen (ausgelagert aus den Spielregeln). ## Stapel: die vier Startdecks, Auffüllen aus der Sammlung, zufällige Stapel für Gegner.
const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3} const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens
# Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner).
const RESONANCE_STEPS := [0, 1, 2, 3, 3]
# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, 60 Partien je Archetyp, tests/strength_probe.gd).
const ARCHETYPE_STRENGTH := {"Erde": 50, "Erde+Luft": 56, "Feuer": 40, "Feuer+Erde": 64, "Feuer+Luft": 42, "Feuer+Wasser": 57, "Luft": 45, "Wasser": 43, "Wasser+Erde": 50, "Wasser+Luft": 41}
## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang.
const ARCHETYPE_PATH := "res://data/archetypen.json" static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array:
static var _strength: Dictionary = {} 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)
## Gemessene Werte aus data/archetypen.json (vom Messlauf geschrieben), sonst die eingebaute Tabelle. Game.shuffle(rest, r)
static func strength() -> Dictionary: var out := []
if _strength.is_empty(): for c in cheap.slice(0, CHEAP_IN_RANDOM) + rest + cheap.slice(CHEAP_IN_RANDOM):
_strength = ARCHETYPE_STRENGTH.duplicate() if out.size() >= Game.DECK_MIN:
if FileAccess.file_exists(ARCHETYPE_PATH):
var json := JSON.new()
if json.parse(FileAccess.get_file_as_string(ARCHETYPE_PATH)) == OK and json.data is Dictionary:
for k in json.data:
if _strength.has(k):
_strength[k] = int(json.data[k])
return _strength
## Elementkombinationen einer Stufe: "leicht" die schwächere Hälfte, "schwer" die stärkere, sonst alle.
static func archetypes(tier: String = "") -> Array:
var table := strength()
var keys: Array = table.keys()
keys.sort_custom(func(a, b): return table[a] < table[b])
var half: int = keys.size() / 2
match tier:
"leicht":
return keys.slice(0, half)
"schwer":
return keys.slice(half)
return keys
# Energiekurve der Gegnerdecks: Anzahl Karten je Kosten (6 = sechs und mehr).
const CURVE := {1: 3, 2: 5, 3: 4, 4: 3, 5: 2, 6: 3}
## Gegnerdeck aus den Elementen entlang der Energiekurve. bias > 1 bevorzugt seltene Karten, bias < 1 gewöhnliche.
static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array:
var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements))
var deck := []
var counts := {}
for bucket in CURVE:
for i in CURVE[bucket]:
var c = _pick_for_cost(r, pool, counts, bucket, bias)
if c != null:
counts[c.id] = counts.get(c.id, 0) + 1
deck.append(c)
while deck.size() < Game.DECK_SIZE:
var c = _pick_for_cost(r, pool, counts, r.randi_range(1, 6), bias)
if c == null:
break break
counts[c.id] = counts.get(c.id, 0) + 1 out.append(c.id)
deck.append(c) return out
return deck
## Karte nahe den gewünschten Kosten, gewichtet nach Seltenheit (bias) und Kostenabstand. ## Stapel eines Kampagnengegners: Kapitel 1 bis Kosten 3, Kapitel 2 bis 5, Kapitel 3 alles.
## Bei extremer Seltenheitsvorgabe darf die Kurve nachgeben, sonst wäre das Ziel unerreichbar. static func opponent_deck(r: RandomNumberGenerator, chapter: int) -> Array:
static func _pick_for_cost(r: RandomNumberGenerator, pool: Array, counts: Dictionary, cost: int, bias: float): return random_deck(r, [3, 5, 99][clampi(chapter - 1, 0, 2)])
var candidates := []
var weights := PackedFloat32Array()
for c in pool: ## Startdeck für ein Element: die 25 Karten des Elements bis Angriff 4, dazu je fünf billige der
if counts.get(c.id, 0) >= Game.MAX_COPIES: ## anderen Elemente (DECK_MIN Karten).
static func start_deck(element: String) -> Array:
var out := []
for c in Cards.all():
if c.elements[0] == element and c.attack <= 4:
out.append(c.id)
for e in Cards.ELEMENTS:
if e == element:
continue continue
var spread: int = absi(mini(c.cost, 6) - cost) var cheap := Cards.all().filter(func(c): return c.elements[0] == e and c.cost <= Game.START_MANA)
var w: float = OPPONENT_RARITY_WEIGHT[c.rarity] cheap.sort_custom(func(a, b): return a.cost < b.cost if a.cost != b.cost else a.attack > b.attack)
w = w / bias if c.rarity == "gewöhnlich" else w * bias for c in cheap.slice(0, 5):
w *= pow(0.3, spread) out.append(c.id)
candidates.append(c) return out
weights.append(w)
if candidates.is_empty():
return null
return candidates[r.rand_weighted(weights)]
## Gegnerdeck auf eine Zielstärke: die Gewichtung wird angepasst, bis das Deck nahe am Ziel liegt. ## Füllt deck aus pool (Karten-IDs) bis limit auf, die wertvollsten zuerst (Card.value in der
static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array: ## Stufe aus levels: ID -> Stufe), bei gleichem Wert die teuersten; nichts doppelt.
var bias := 1.0 static func fill(deck: Array, pool: Array, limit: int, levels: Dictionary = {}) -> Array:
var best := [] var out := deck.duplicate()
var best_diff := 99.0 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))))
for attempt in 24: cards.sort_custom(func(a, b):
var deck := build_opponent_deck(r, elements, bias) if a.value() != b.value():
var diff := deck_rating(deck) - target return a.value() > b.value()
if absf(diff) < best_diff: return a.cost > b.cost if a.cost != b.cost else a.id < b.id)
best = deck for c in cards:
best_diff = absf(diff) if out.size() >= limit:
if absf(diff) <= tolerance:
break break
bias = clampf(bias * (1.35 if diff < 0 else 1.0 / 1.35), 0.05, 20.0) out.append(c.id)
return best return out
## Zufälliger Gegner. target > 0: Deck auf diese Stärke gebaut. tier: Archetypen der Schwierigkeit. ## Stärke eines Stapels: der mittlere Kartenwert (Card.value) in den Stufen aus levels,
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = Game.START_LIFE, target: float = 0.0, tier: String = "") -> Game.Player: ## 100 für lauter Grundkarten bis 500 für lauter Ultimate Rare; leer: 0.
var options := archetypes(tier) static func strength(deck: Array, levels: Dictionary) -> int:
var elements: Array = options[r.randi_range(0, options.size() - 1)].split("+") if deck.is_empty():
var deck := build_opponent_deck_rated(r, elements, target) if target > 0.0 else build_opponent_deck(r, elements) return 0
return Game.Player.new("Gegner %d" % index, deck, false, life, Cards.combo_name(elements) + "-Magier") 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())
## Kartenwert, bei genug Partien mit der Siegquote der Karte gewichtet (Statistik aus Spieltests). static func random_opponent(life: int = Game.START_LIFE) -> Game.Player:
static func card_value(c: Cards.Card, stats: Dictionary = {}) -> float: return Game.Player.new("Gegner", false, life)
var v := Cards.card_value(c)
if stats.has(c.id):
var st: Dictionary = stats[c.id]
if int(st.games) >= 10:
v *= 0.5 + float(st.wins) / float(st.games)
return v
## Abzug für schlechte Energiekurven: zu wenig günstige oder zu viele teure Karten.
static func curve_penalty(cards: Array) -> float:
var cheap := 0
var expensive := 0
for c in cards:
if c.cost <= 2:
cheap += 1
if c.cost >= 5:
expensive += 1
return minf(0.05 * maxi(0, 6 - cheap) + 0.05 * maxi(0, expensive - 5), 0.4)
## Deckstärke: Durchschnitt der Kartenwerte minus Kurvenabzug (1 = nur gewöhnliche Karten, bis etwa 3,5).
static func deck_rating(cards: Array, stats: Dictionary = {}) -> float:
if cards.is_empty():
return 0.0
var sum := 0.0
for c in cards:
sum += card_value(c, stats)
return sum / cards.size() - curve_penalty(cards)
+391 -456
View File
File diff suppressed because it is too large. Load diff
-428
View File
@@ -1,428 +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)
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
signal status_changed
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
var rejoin_seconds := 60.0
var spectator := false # eigene Rolle: nur zuschauen
var spectators := {} # Host: peer_id -> Name
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
var i_am_successor := false
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
var reconnecting := false
func _ready() -> void:
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():
if not reconnecting:
_fail("Verbindung fehlgeschlagen"))
multiplayer.server_disconnected.connect(_on_server_lost)
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}
spectators = {}
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 = ""
spectator = false
spectators = {}
successor_ip = ""
i_am_successor = false
backup = {}
backup_names = {}
last_winner = ""
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, spectator)
# --- Host-Wechsel ---------------------------------------------------------------
# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen
# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der
# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück.
func _successor_id() -> int:
var ids: Array = seats.keys().filter(func(id): return id != 1)
ids.sort()
return ids[0] if not ids.is_empty() else -1
func _update_successor() -> void:
if not is_host or game == null:
return
var succ := _successor_id()
var ip := ""
if succ > 0 and peer != null and peer.get_peer(succ) != null:
ip = peer.get_peer(succ).get_remote_address()
for id in seats:
if id != 1:
set_successor.rpc_id(id, ip, id == succ)
## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel.
func _seat_names() -> Dictionary:
var out := {}
for id in seats:
out[seats[id]] = names.get(id, "")
for pname in waiting:
out[waiting[pname].seat] = pname
return out
@rpc("authority", "reliable")
func set_successor(ip: String, me: bool) -> void:
successor_ip = ip
i_am_successor = me
@rpc("authority", "reliable")
func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void:
backup = snap
backup_names = seat_names
func _on_server_lost() -> void:
if is_host:
return
if last_winner != "":
_fail("Host hat das Spiel beendet")
elif i_am_successor and not backup.is_empty():
_become_host()
elif successor_ip != "":
status = "Host weg, verbinde mit dem Nachfolger …"
status_changed.emit()
_reconnect(successor_ip)
else:
_fail("Host hat das Spiel beendet")
func _become_host() -> void:
var snap := backup
var seat_names := backup_names
var seat := my_seat
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
var err := FAILED
for attempt in 5:
err = host()
if err == OK:
break
await get_tree().create_timer(1.0).timeout
if err != OK:
_fail("Host weg, Übernahme fehlgeschlagen")
return
game = Game.from_snapshot(snap)
seats = {1: seat}
my_seat = seat
waiting = {}
for s in seat_names:
if int(s) != seat:
waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()}
game.say("%s übernimmt als Host." % my_name)
status = "Du bist jetzt Host, die anderen verbinden neu …"
successor_ip = ""
i_am_successor = false
backup = {}
migrated.emit()
func _reconnect(ip: String) -> void:
reconnecting = true
await get_tree().create_timer(2.0).timeout
for attempt in 5:
if join(ip) == OK:
var t := 0.0
while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING:
await get_tree().process_frame
t += get_process_delta_time()
if connected:
reconnecting = false
return
await get_tree().create_timer(1.0).timeout
reconnecting = false
_fail("Nachfolge-Host nicht erreichbar")
func _on_peer_connected(_id: int) -> void:
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)
spectators.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)
_update_successor()
action_applied.emit()
lobby_update.rpc(names, spectators)
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, as_spectator: bool = false) -> void:
if not is_host:
return
var id := multiplayer.get_remote_sender_id()
if as_spectator:
spectators[id] = name
lobby_update.rpc(names, spectators)
lobby_changed.emit()
if game != null:
start_game.rpc_id(id, -1)
receive_snapshot.rpc_id(id, game.snapshot(-2))
return
if game != null:
# 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]))
_update_successor()
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, spectators)
lobby_changed.emit()
@rpc("authority", "reliable")
func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
names = n
spectators = sp
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])
for id in spectators:
start_game.rpc_id(id, -1)
_update_successor()
started.emit(my_seat)
@rpc("authority", "reliable")
func start_game(seat: int) -> void:
my_seat = seat
started.emit(seat)
## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes,
## der Nachfolger zusätzlich den vollen Spielstand).
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]))
for id in spectators:
receive_snapshot.rpc_id(id, game.snapshot(-2))
var succ := _successor_id()
if succ > 0:
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
@rpc("authority", "reliable")
func receive_snapshot(snap: Dictionary) -> void:
last_winner = str(snap.get("winner", ""))
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
-105
View File
@@ -1,105 +0,0 @@
class_name NetState
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln).
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl.
## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer).
static func snapshot(g: Game, viewer: int) -> Dictionary:
var ps := []
for i in g.players.size():
var p: Game.Player = g.players[i]
var mine := i == viewer or viewer == -1
var board := []
for c in p.board:
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
"ready": c.ready, "attacks_left": c.attacks_left})
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
"deck": p.deck.map(func(c): return c.id) if viewer == -1 else p.deck.size(),
"traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
"board": board})
var evs := []
for ev in g.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = e.card.id
evs.append(e)
return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx,
"winner": g.winner, "winner_player": g.players.find(g.winner_player) if g.winner_player != null else -1,
"next_uid": g.next_uid, "log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs}
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
static func from_snapshot(d: Dictionary) -> Game:
var placeholder: Cards.Card = Cards.all()[0]
var list := []
for pd in d.players:
var p := Game.Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title)
p.max_life = int(pd.max_life)
p.energy = int(pd.energy)
p.max_energy = int(pd.max_energy)
p.fatigue = int(pd.fatigue)
p.start_bonus = int(pd.start_bonus)
p.order = int(pd.order)
p.perks = Array(pd.perks)
p.turns = int(pd.turns)
p.mulligan_done = bool(pd.mulligan_done)
if pd.hand is Array:
for cid in pd.hand:
p.hand.append(Cards.by_id(cid))
else:
for i in int(pd.hand):
p.hand.append(placeholder)
if pd.deck is Array:
for cid in pd.deck:
p.deck.append(Cards.by_id(cid))
else:
for i in int(pd.deck):
p.deck.append(placeholder)
if pd.traps is Array:
for cid in pd.traps:
p.traps.append(Cards.by_id(cid))
else:
for i in int(pd.traps):
p.traps.append(placeholder)
for cd in pd.board:
var c := Game.Creature.new(Cards.by_id(cd.card), p, int(cd.uid))
c.attack = int(cd.attack)
c.resonance = int(cd.resonance)
c.health = int(cd.health)
c.max_health = int(cd.max_health)
c.shield = bool(cd.shield)
c.frozen = bool(cd.frozen)
c.ready = bool(cd.ready)
c.attacks_left = int(cd.attacks_left)
p.board.append(c)
list.append(p)
var hi := int(d.human)
var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new())
g.players = list
g.turn = int(d.turn)
g.current_idx = int(d.current_idx)
g.winner = d.winner
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
g.next_uid = int(d.next_uid)
for line in d.log:
g.log.append(str(line))
for ev in d.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
e["card"] = Cards.by_id(e.card)
for key in ["player", "uid", "count", "amount"]:
if e.has(key):
e[key] = int(e[key])
for key in ["target", "attacker", "source"]:
if e.has(key):
var r: Dictionary = e[key]
if r.has("uid"):
r["uid"] = int(r.uid)
if r.has("index"):
r["index"] = int(r.index)
g.events.append(e)
return g
-1
View File
@@ -1 +0,0 @@
uid://c3ao1mpgbrter
+147 -537
View File
@@ -1,114 +1,59 @@
class_name Profile class_name Profile
extends RefCounted extends RefCounted
## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Statistik, Erfolge, Tagesaufgabe, Einstellungen. ## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen.
## Man startet mit einem von vier Startdecks (je ein Element im Mittelpunkt); Booster und die
## Werkstatt bringen den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe
## gibt (Cards.Card.leveled); kommt eine seltenere Version, ersetzt sie die alte, die zu Staub wird.
const SAVE_VERSION := 5 const SAVE_VERSION := 7
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
const SAVE_PATH := "user://spielstand.json" const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100 const BOOSTER_COST := 100
const ELEMENT_BOOSTER_COST := 120 # nur Karten eines Elements (auch seine Kombos)
const CODE_PREFIX := "DINGER1:" # Deck-Code zum Teilen
const BOOSTER_SIZE := 5 const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5} const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0}
const START_COINS := 150 const START_COINS := 150
const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300} const REWARD_WIN := 100
const REWARD_LOSS := 50 const REWARD_LOSS := 50
const DECK_SLOTS := 5 # Schwierigkeit: Leben der Gegner, KI und Münzen.
# Schwierigkeit: Faktor auf die Zielstärke der Gegnerdecks, Belohnung und KI-Tiefe.
const DIFFICULTIES := { const DIFFICULTIES := {
"leicht": {"name": "Leicht", "factor": 0.9, "reward": 0.75, "smart": false, "tier": "leicht"}, "leicht": {"name": "Leicht", "life": 25, "reward": 0.75, "smart": false},
"normal": {"name": "Normal", "factor": 1.0, "reward": 1.0, "smart": true, "tier": ""}, "normal": {"name": "Normal", "life": 30, "reward": 1.0, "smart": true},
"schwer": {"name": "Schwer", "factor": 1.2, "reward": 1.5, "smart": true, "tier": "schwer"}, "schwer": {"name": "Schwer", "life": 35, "reward": 1.5, "smart": true},
} }
# Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub. # Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub.
const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600} const DUST_DISMANTLE := {"Common": 50, "Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200} const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
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 50 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": "archimagier", "name": "Archimagier", "text": "Schließe Kapitel 3 der Kampagne ab.", "reward": 800},
{"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150},
{"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100},
]
const DAILY_TASKS := [
{"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80},
{"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60},
{"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40},
{"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50},
{"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50},
{"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60},
{"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50},
{"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120},
{"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90},
{"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90},
{"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90},
{"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90},
]
static var today_override := "" # für Tests
var coins := 0 var coins := 0
var dust := 0 var dust := 0
var collection := {} # id -> Anzahl var collection := {} # eigene Karten: id -> Seltenheitsstufe (Index in Cards.RARITIES)
var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids} var deck: Array = [] # Karten-IDs des eigenen Stapels (Game.DECK_MIN bis DECK_MAX)
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 campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
var daily := {"date": "", "task": "", "progress": 0, "done": false} var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false} var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
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 perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
var tutorial2_done := false
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0, "margins": []}
var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung
## 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": []})
## Neues Profil: noch ohne Karten, das Startdeck wählt der Spieler (choose_start).
static func starter() -> Profile: static func starter() -> Profile:
var p := Profile.new() var p := Profile.new()
p.coins = START_COINS 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
# Zwei seltene Karten zum Start, damit es etwas zum Aufbauen gibt
p.collection["f_drache"] = 1
p.collection["e_baum"] = 1
var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche",
"e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"]
for cid in starter_ids:
p.deck.append(cid)
p.deck.append(cid)
p.deck[p.deck.find("f_asche")] = "f_drache"
p.deck[p.deck.find("e_troll")] = "e_baum"
return p return p
## Hat der Spieler schon ein Startdeck gewählt?
func started() -> bool:
return not collection.is_empty()
## Das Startdeck eines Elements wird Sammlung und Stapel.
func choose_start(element: String) -> void:
collection = {}
deck = DeckGen.start_deck(element)
for id in deck:
collection[id] = 0
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil. ## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
static func load_or_new(path: String = SAVE_PATH) -> Profile: static func load_or_new(path: String = SAVE_PATH) -> Profile:
for candidate in [path, path + ".bak"]: for candidate in [path, path + ".bak"]:
@@ -123,76 +68,39 @@ static func load_or_new(path: String = SAVE_PATH) -> Profile:
return p 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: static func from_dict(d: Dictionary) -> Profile:
var p := Profile.new() var p := Profile.new()
var version := int(d.get("version", 1))
p.coins = int(d.get("coins", 0)) p.coins = int(d.get("coins", 0))
p.dust = int(d.get("dust", 0)) p.dust = int(d.get("dust", 0))
p.campaign_stage = int(d.get("campaign_stage", 0))
if int(d.get("version", 1)) >= SAVE_VERSION:
var coll = d.get("collection", {}) var coll = d.get("collection", {})
for cid in coll: for cid in coll:
if Cards.has_id(cid): if Cards.has_id(cid):
p.collection[cid] = int(coll[cid]) p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
if version >= 3: var ids := []
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", []): for cid in d.get("deck", []):
if Cards.has_id(cid): if p.collection.has(cid) and not (cid in ids):
p.decks[0].cards.append(cid) ids.append(cid)
if version >= 2: p.deck = ids.slice(0, Game.DECK_MAX)
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", {}) var se = d.get("settings", {})
p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)), p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)),
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)), "ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")), "language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
"big_font": bool(se.get("big_font", false))} "big_font": bool(se.get("big_font", false))}
if not DIFFICULTIES.has(p.settings.difficulty): if not DIFFICULTIES.has(p.settings.difficulty):
p.settings.difficulty = "normal" p.settings.difficulty = "normal"
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", "")) p.perk = str(d.get("perk", ""))
if not Game.PERKS.has(p.perk): if not Game.PERKS.has(p.perk):
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)), "loss_streak": int(rc.get("loss_streak", 0)),
"margins": Array(rc.get("margins", [])).map(func(m): return int(m))}
p.challenge_week = int(d.get("challenge_week", -1))
p.tutorial2_done = bool(d.get("tutorial2_done", false))
return p return p
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, "deck": deck,
"decks": decks, "active_deck": active_deck, "campaign_stage": campaign_stage, "settings": settings, "perk": perk}
"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, "tutorial2_done": tutorial2_done}
## Speichert und sichert den vorherigen Stand als .bak. ## Speichert und sichert den vorherigen Stand als .bak.
@@ -211,451 +119,153 @@ func take_notices() -> Array[String]:
return out return out
# --- Decks und Sammlung ----------------------------------------------------------- # --- Sammlung und Stapel -----------------------------------------------------------
func deck_cards(slot: int = -1) -> Array: func owns(cid: String) -> bool:
var ids: Array = decks[slot if slot >= 0 else active_deck].cards return collection.has(cid)
var out := []
for cid in ids:
out.append(Cards.by_id(cid)) func level(cid: String) -> int:
return int(collection.get(cid, 0))
func rarity(cid: String) -> String:
return Cards.RARITIES[level(cid)]
## 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 return out
func deck_valid(slot: int = -1) -> Array: ## Der Stapel für den Kampf: der gewählte, mit weiteren eigenen Karten bis DECK_MIN aufgefüllt,
return Game.validate_deck(deck_cards(slot), collection) ## 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)
func deck_name(slot: int = -1) -> String: ## Karte in den Stapel oder heraus. Gibt "" zurück oder eine Fehlermeldung.
return decks[slot if slot >= 0 else active_deck].name func toggle_deck(cid: String) -> String:
if cid in deck:
deck.erase(cid)
func rename_deck(slot: int, name: String) -> String: return ""
var n := name.strip_edges() if not owns(cid):
if n.is_empty() or n.length() > 20: return "Karte nicht in der Sammlung"
return "Name muss 1 bis 20 Zeichen haben" if deck.size() >= Game.DECK_MAX:
decks[slot].name = n return "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX
deck.append(cid)
return "" return ""
## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve. ## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt. func upgrade(cid: String) -> String:
func autofill_deck(slot: int) -> String: if not owns(cid):
var cards: Array = decks[slot].cards return "Karte nicht in der Sammlung"
var elems := Game.deck_elements(deck_cards(slot)) var lvl := level(cid)
if elems.is_empty(): if lvl >= Cards.RARITIES.size() - 1:
var count := {} return "Schon auf der höchsten Stufe"
for c in owned_cards(): var cost: int = DUST_UPGRADE[Cards.RARITIES[lvl + 1]]
for e in c.elements:
count[e] = count.get(e, 0) + 1
var ranked: Array = count.keys()
ranked.sort_custom(func(a, b): return count[a] > count[b])
elems = ranked.slice(0, Game.MAX_DECK_ELEMENTS)
var pool := owned_cards().filter(func(c): return c.elements.all(func(e): return e in elems))
pool.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
var counts := {}
for cid in cards:
counts[cid] = counts.get(cid, 0) + 1
var progressed := true
while cards.size() < Game.DECK_SIZE and progressed:
progressed = false
for cost in range(1, 11):
if cards.size() >= Game.DECK_SIZE:
break
for c in pool:
if c.cost != cost:
continue
var have: int = counts.get(c.id, 0)
if have < mini(collection.get(c.id, 0), Game.MAX_COPIES):
cards.append(c.id)
counts[c.id] = have + 1
progressed = true
break
var ok := Game.validate_deck(deck_cards(slot), collection)
return "" if ok[0] else ok[1]
## Bestenliste: Karten mit den meisten Siegen (mindestens eine Partie).
func top_cards(n: int) -> Array:
var rows := []
for cid in card_stats:
var st: Dictionary = card_stats[cid]
if st.games > 0:
rows.append({"card": Cards.by_id(cid), "games": st.games, "wins": st.wins, "played": st.played,
"rate": float(st.wins) / st.games})
rows.sort_custom(func(a, b): return a.wins > b.wins or (a.wins == b.wins and a.rate > b.rate))
return rows.slice(0, n)
func owned_cards() -> Array:
return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0)
func distinct_owned() -> int:
var n := 0
for cid in collection:
if collection[cid] > 0:
n += 1
return n
# --- Werkstatt --------------------------------------------------------------------
## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung.
func dismantle(cid: String, n: int = 1) -> String:
var owned: int = collection.get(cid, 0)
if owned < n:
return "Nicht genug Kopien"
var needed := 0
for d in decks:
needed = maxi(needed, d.cards.count(cid))
if owned - n < needed:
return "Wird in einem Deck gebraucht"
collection[cid] = owned - n
dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n
return ""
## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung.
func craft(cid: String) -> String:
var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity]
if dust < cost: if dust < cost:
return "Nicht genug Staub (%d nötig)" % cost return "Nicht genug Staub (%d nötig)" % cost
dust -= cost dust -= cost
collection[cid] = collection.get(cid, 0) + 1 collection[cid] = lvl + 1
daily_event("craft")
check_achievements()
return "" return ""
## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück. ## Entzaubern: eine Stufe abgeben bringt Staub; eine Common-Karte verlässt die Sammlung.
func dismantle_extras() -> int: ## Gibt "" zurück oder eine Fehlermeldung.
var before := dust func dismantle(cid: String) -> String:
for cid in collection.keys(): if not owns(cid):
var extra: int = collection[cid] - Game.MAX_COPIES return "Karte nicht in der Sammlung"
if extra > 0: var lvl := level(cid)
dismantle(cid, extra) dust += DUST_DISMANTLE[Cards.RARITIES[lvl]]
return dust - before if lvl == 0:
collection.erase(cid)
deck.erase(cid)
else:
collection[cid] = lvl - 1
return ""
## Herstellen: eine fehlende Karte für Staub als Common. Gibt "" zurück oder eine Fehlermeldung.
func craft(cid: String) -> String:
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 ---------------------------------------------------------------------- # --- Booster ----------------------------------------------------------------------
## Kartenpool einer Seltenheit, bei Elementboostern nur Karten mit diesem Element. ## Fünf Karten mit gewürfelter Seltenheit.
static func booster_pool(rarity: String, element: String) -> Array: static func roll_booster(r: RandomNumberGenerator) -> Array:
var pool := Cards.of_rarity(rarity)
if element != "":
pool = pool.filter(func(c): return element in c.elements)
return pool
static func roll_booster(r: RandomNumberGenerator, element: String = "") -> Array:
var rarities := RARITY_WEIGHTS.keys() var rarities := RARITY_WEIGHTS.keys()
var weights := PackedFloat32Array(RARITY_WEIGHTS.values()) var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
var cards := [] var pool := Cards.all()
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 := booster_pool(rarity, element)
cards.append(pool[r.randi_range(0, pool.size() - 1)])
return cards
## Deck-Code zum Teilen: "DINGER1:id*anzahl,…" (sortiert, daher vergleichbar).
static func deck_code(card_ids: Array) -> String:
var counts := {}
for cid in card_ids:
counts[cid] = counts.get(cid, 0) + 1
var keys := counts.keys()
keys.sort()
return CODE_PREFIX + ",".join(keys.map(func(k): return "%s*%d" % [k, counts[k]]))
## Karten-IDs aus einem Deck-Code; leer bei ungültigem Code.
static func parse_deck_code(code: String) -> Array:
var s := code.strip_edges()
if not s.begins_with(CODE_PREFIX):
return []
var out := [] var out := []
for part in s.substr(CODE_PREFIX.length()).split(",", false): for i in BOOSTER_SIZE:
var kv: PackedStringArray = part.strip_edges().split("*") var rar: String = rarities[r.rand_weighted(weights)]
if kv.size() != 2 or not Cards.has_id(kv[0]) or not kv[1].is_valid_int(): if i == BOOSTER_SIZE - 1 and out.all(func(x): return x.rarity == "Common"):
return [] rar = "Rare" # mindestens eine Rare pro Booster
for i in clampi(int(kv[1]), 0, Game.MAX_COPIES): out.append({"card": pool[r.randi_range(0, pool.size() - 1)], "rarity": rar})
out.append(kv[0])
return out return out
## Öffnet einen Booster (element: Elementbooster). Gibt die Karten zurück, leer wenn die Münzen nicht reichen. ## Öffnet einen Booster. Neue Karten kommen in Sammlung und Stapel (solange Platz ist). Von jeder
func open_booster(r: RandomNumberGenerator = null, element: String = "") -> Array: ## Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine gleiche
var cost := ELEMENT_BOOSTER_COST if element != "" else BOOSTER_COST ## oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn die Münzen nicht reichen.
if coins < cost: func open_booster(r: RandomNumberGenerator = null) -> Array:
if coins < BOOSTER_COST:
return [] return []
if r == null: if r == null:
r = Game.make_rng() r = Game.make_rng()
coins -= cost coins -= BOOSTER_COST
var cards := roll_booster(r, element) var out := roll_booster(r)
booster_since_mythic += 1 for res in out:
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch") var lvl: int = Cards.RARITIES.find(res.rarity)
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic: res["new"] = not owns(res.card.id)
var pool := booster_pool("mythisch", element) res["upgraded"] = false
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)] res["dust"] = 0
has_mythic = true if res.new:
if has_mythic: collection[res.card.id] = lvl
booster_since_mythic = 0 if deck.size() < Game.DECK_MAX:
for c in cards: deck.append(res.card.id)
collection[c.id] = collection.get(c.id, 0) + 1 elif lvl > level(res.card.id):
stats.boosters += 1 res["upgraded"] = true
daily_event("booster") res["dust"] = DUST_DISMANTLE[rarity(res.card.id)]
check_achievements() collection[res.card.id] = lvl
return cards else:
res["dust"] = DUST_DISMANTLE[res.rarity]
dust += res.dust
return out
# --- Kampfergebnis, Erfolge, Tagesaufgabe ------------------------------------------- # --- Kampf und Kampagne -----------------------------------------------------------
## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück. ## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück.
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei. ## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche. func record_battle(won: bool, stage: int = -1, reward_factor: float = 1.0) -> int:
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
turns: int = 0, challenge: Dictionary = {}, reward_factor: float = 1.0, remaining_life: int = -1) -> int:
stats.games += 1
records.margins.append(maxi(remaining_life, 0) if won else 0)
while records.margins.size() > 5:
records.margins.pop_front()
if won:
records.streak += 1
records.loss_streak = 0
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
records.loss_streak += 1
var tip := suggest_difficulty()
if tip != "" and stage < 0 and challenge.is_empty():
notices.append("Tipp: %s. Schwierigkeit %s?" % [
"%d Siege in Folge" % records.streak if won else "%d Niederlagen in Folge" % records.loss_streak, DIFFICULTIES[tip].name])
for cid in played_ids:
_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 var reward := REWARD_LOSS
if won: if won:
stats.wins += 1 reward = int(round(REWARD_WIN * reward_factor))
var key := str(n_opp)
stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1
reward = int(round(REWARD_WIN[n_opp] * reward_factor))
if stage >= 0 and stage == campaign_stage: if stage >= 0 and stage == campaign_stage:
campaign_stage += 1 campaign_stage += 1
reward = Campaign.stage(stage).reward reward = Campaign.stage(stage).reward
notices.append("Kampagne: %s besiegt!" % Campaign.stage(stage).name) 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 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 return reward
func _stat(cid: String) -> Dictionary:
if not card_stats.has(cid):
card_stats[cid] = {"played": 0, "games": 0, "wins": 0}
return card_stats[cid]
func check_achievements(ctx: Dictionary = {}) -> Array:
var unlocked := []
for a in ACHIEVEMENTS:
if a.id in achievements:
continue
if _achieved(a.id, ctx):
achievements.append(a.id)
coins += a.reward
unlocked.append(a)
notices.append("Erfolg: %s (+%d Münzen)" % [a.name, a.reward])
return unlocked
func _achieved(id: String, ctx: Dictionary) -> bool:
var won: bool = ctx.get("won", false)
var n: int = ctx.get("n_opp", 0)
var deck_used: Array = ctx.get("deck", [])
match id:
"erster_sieg":
return stats.wins >= 1
"duellant":
return won and n == 1
"tischherr":
return won and n >= 3
"chaosmeister":
return won and n == 5
"sammler":
return distinct_owned() >= 50
"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 >= 2 * Campaign.STAGES.size()
"archimagier":
return campaign_stage >= Campaign.total()
"serie":
return records.best_streak >= 5
"wochenheld":
return challenge_week >= 0
return false
func difficulty() -> Dictionary: func difficulty() -> Dictionary:
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal) return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
## Vorschlag: rauf nach drei deutlichen Siegen in Folge (im Schnitt mindestens 12 Leben übrig) oder fünf
## Siegen egal wie knapp; runter nach drei Niederlagen in Folge. "" = kein Vorschlag.
func suggest_difficulty() -> String:
var order := ["leicht", "normal", "schwer"]
var i := order.find(str(settings.get("difficulty", "normal")))
if i < order.size() - 1 and records.streak >= 3:
var last: Array = records.margins.slice(maxi(0, records.margins.size() - 3))
var avg := 0.0
for m in last:
avg += float(m) / last.size()
if records.streak >= 5 or (records.margins.size() >= 3 and avg >= 12.0):
return order[i + 1]
if records.loss_streak >= 3 and i > 0:
return order[i - 1]
return ""
## Eigene Deckstärke, mit Kartenstatistik gewichtet.
func deck_rating(cards: Array = []) -> float:
var deck_used: Array = cards if not cards.is_empty() else deck_cards()
return Game.deck_rating(deck_used, card_stats)
## Zielstärke der Gegnerdecks: eigene Deckstärke mal Schwierigkeitsfaktor.
func target_rating(cards: Array = []) -> float:
return deck_rating(cards) * float(difficulty().factor)
## Tauscht die schwächste Karte des aktiven Decks gegen cid, wenn das Deck dadurch stärker und gültig bleibt.
## dry_run: nur prüfen. Gibt "" zurück oder den Grund, warum nicht.
func improve_deck_with(cid: String, dry_run: bool = false) -> String:
if collection.get(cid, 0) <= 0:
return "Karte nicht in der Sammlung"
var card := Cards.by_id(cid)
if deck.count(cid) >= mini(collection[cid], Game.MAX_COPIES):
return "Schon so oft im Deck wie möglich"
var elems := Game.deck_elements(deck_cards())
for e in card.elements:
if not (e in elems):
elems.append(e)
if elems.size() > Game.MAX_DECK_ELEMENTS:
return "Passt nicht zu den Elementen des Decks"
var weakest := ""
var weakest_value := Cards.card_value(card)
for other in deck:
if other == cid:
continue
var v := Cards.card_value(Cards.by_id(other))
if v < weakest_value:
weakest_value = v
weakest = other
if weakest == "":
return "Keine schwächere Karte im Deck"
if dry_run:
return ""
var trial: Array = deck.duplicate()
trial[trial.find(weakest)] = cid
var ok := Game.validate_deck(trial.map(func(id): return Cards.by_id(id)), collection)
if not ok[0]:
return ok[1]
deck = trial
return ""
## Karten aus new_ids, die das aktive Deck verbessern würden.
func deck_suggestions(new_ids: Array) -> Array:
var out := []
for cid in new_ids:
if not (cid in out) and improve_deck_with(cid, true) == "":
out.append(cid)
return out
func perk_unlocked() -> bool: func perk_unlocked() -> bool:
return campaign_stage >= Campaign.STAGES.size() return campaign_stage >= Campaign.STAGES.size()
## 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
-56
View File
@@ -1,56 +0,0 @@
class_name AchievementsScreen
extends Control
var app
var export_label: Label
func _ready() -> void:
var p: Profile = app.profile
var col := Style.column(self, 900, 12, 4)
Style.clabel(col, "Erfolge", 32, 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.total()], 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.clabel(col, "Serie: %d (beste %d) · Schnellster Sieg: %s" % [p.records.streak, p.records.best_streak,
("%d Züge" % p.records.fastest_win) if p.records.fastest_win > 0 else "–"], 15, Style.MUTED)
Style.spacer(col, 4)
for a in Profile.ACHIEVEMENTS:
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, 36)
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)
export_label = Style.clabel(col, "", 13, Style.GREEN)
Style.spacer(col, 2)
var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Statistik exportieren", Vector2(220, 40), export_stats)
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
func export_stats() -> void:
var path := "user://statistik.csv"
var err: String = app.profile.export_stats(path)
export_label.text = ("Gespeichert: " + ProjectSettings.globalize_path(path)) if err == "" else err
func _input(event: InputEvent) -> void:
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 screen # BattleScreen
var enabled := true var enabled := true
var banners: Array = [] # wartende Ansagen [Text, Farbe]
var banner_running := false
func _init(s) -> void: 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) var atk: CreatureView = screen.tiles.get(ev.attacker.uid)
lunge(atk, center_of(ev.target, centers)) lunge(atk, center_of(ev.target, centers))
flash_target(ev.target) flash_target(ev.target)
shake()
delay = 0.12 delay = 0.12
"damage": "damage":
sfx("damage") sfx("damage")
popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay) popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay)
"heal": "heal":
sfx("heal") sfx("heal")
popup("+%d" % ev.amount, Style.GREEN, center_of(ev.target, centers), delay) popup(tr("Regeneration"), Style.GREEN, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
"buff":
popup(ev.text, Style.GREEN, center_of(ev.target, centers), delay)
"blocked":
popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay)
"freeze":
popup("Eis", Color("aaddff"), center_of(ev.target, centers), delay)
"combo":
sfx("combo")
var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid})
if where.x < 0:
where = screen.target_center({"kind": "player", "index": ev.player})
popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1)
"play": "play":
sfx("play") sfx("click" if ev.get("hidden", false) else "play")
"spell": "reveal":
sfx("play")
var caster: Game.Player = g.players[ev.player]
popup(ev.card.name, Style.GOLD, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
if caster != screen.perspective:
screen.show_preview(ev.card, true)
screen.preview_timer = 1.6
"trap_set":
sfx("click")
popup("Falle gelegt", Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
"trap":
sfx("combo") sfx("combo")
popup("Falle: %s!" % ev.card.name, Style.ORANGE, screen.target_center({"kind": "player", "index": ev.player}) + Vector2(0, -20), 0.0) popup(tr("Aufgedeckt!"), Style.GOLD, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
screen.show_preview(ev.card, true) "fuse":
screen.preview_timer = 1.8 sfx("combo")
"mulligan": popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
if ev.count > 0: "draw":
popup(("tauscht 1 Karte" if int(ev.count) == 1 else "tauscht %d Karten" % ev.count), Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0) sfx("click")
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2: func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
@@ -73,9 +54,24 @@ func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
return c 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: func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
if not enabled or at.x < 0: if not enabled or at.x < 0:
return return
at = _free_spot(at)
var l := Label.new() var l := Label.new()
l.text = text l.text = text
l.add_theme_font_size_override("font_size", 22) 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_color_override("font_outline_color", Color(0, 0, 0, 0.9))
l.add_theme_constant_override("outline_size", 6) l.add_theme_constant_override("outline_size", 6)
l.mouse_filter = Control.MOUSE_FILTER_IGNORE l.mouse_filter = Control.MOUSE_FILTER_IGNORE
l.set_meta("spot", at)
screen.fx_layer.add_child(l) screen.fx_layer.add_child(l)
l.reset_size() l.reset_size()
l.position = at - l.size / 2 l.position = at - l.size / 2
@@ -142,14 +139,31 @@ func pop_in(tile: CreatureView) -> void:
func fade_out(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: if not enabled:
tile.queue_free() view.queue_free()
return return
var tw := tile.create_tween().set_parallel(true) var tw := view.create_tween().set_parallel(true)
tw.tween_property(tile, "modulate:a", 0.0, 0.3) tw.tween_property(view, "modulate:a", 0.0, 0.3)
tw.tween_property(tile, "scale", Vector2(0.6, 0.6), 0.3) tw.tween_property(view, "scale", Vector2(0.6, 0.6), 0.3)
tw.chain().tween_callback(tile.queue_free) 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: 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) screen.create_tween().tween_property(tile, "position", pos, 0.2)
else: else:
tile.position = pos 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())
+54 -30
View File
@@ -1,11 +1,13 @@
class_name BattleGamepad class_name BattleGamepad
extends RefCounted extends RefCounted
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Hand, eigene Wesen und Ziele, ## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Stapel, Hand, eigene Wesen
## A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, X macht rückgängig. ## 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 screen # BattleScreen
var active := false var active := false
var zone := "hand" var zone := "deck"
var index := 0 var index := 0
var targets: Array = [] var targets: Array = []
@@ -26,10 +28,20 @@ func button(button: int) -> void:
if screen.confirm.visible: if screen.confirm.visible:
screen.confirm.visible = false screen.confirm.visible = false
screen.cancel_all() screen.cancel_all()
zone = "hand" zone = "deck"
JOY_BUTTON_Y: JOY_BUTTON_Y:
if screen.b_skip.visible: if screen.b_skip.visible:
screen.skip_ai = true 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: JOY_BUTTON_X:
if screen.b_undo.visible: if screen.b_undo.visible:
screen.undo() screen.undo()
@@ -41,17 +53,34 @@ func button(button: int) -> void:
move(1) move(1)
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN: JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
if zone != "target": if zone != "target":
zone = "own" if zone == "hand" else "hand" _next_zone(1 if button == JOY_BUTTON_DPAD_DOWN else -1)
index = 0
screen.refresh() 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: func items() -> Array:
match zone: match zone:
"deck":
return [screen.deck] if screen.deck.visible else []
"hand": "hand":
return screen.hand_views return screen.hand_views
"own": "own":
return screen.own_views.filter(func(v): return v.creature.can_attack()) return screen.own_views
"target": "target":
return targets return targets
return [] return []
@@ -64,12 +93,6 @@ func move(dir: int) -> void:
func confirm() -> void: func confirm() -> void:
if screen.handover.visible:
screen.take_over()
return
if screen.mulligan.visible:
screen.do_mulligan([])
return
if screen.overlay.visible: if screen.overlay.visible:
screen.leave() screen.leave()
return return
@@ -81,22 +104,10 @@ func confirm() -> void:
index = clampi(index, 0, items.size() - 1) index = clampi(index, 0, items.size() - 1)
var item = items[index] var item = items[index]
match zone: match zone:
"deck":
screen.on_click("deck", null)
"hand": "hand":
screen.on_click("hand", item.card) screen.on_click("hand", item.card)
if screen.pending != null:
targets = []
var valid: Array = screen.game.valid_targets(screen.perspective, screen.pending.target_type())
for v in screen.enemy_views:
if v.creature in valid:
targets.append(v)
for v in screen.own_views:
if v.creature in valid:
targets.append(v)
for o in screen.others():
if o in valid:
targets.append(screen.hero_panels[o])
zone = "target"
index = 0
"own": "own":
screen.on_click("own", item.creature) screen.on_click("own", item.creature)
if screen.selected != null: if screen.selected != null:
@@ -116,8 +127,21 @@ func confirm() -> void:
for o in screen.hero_panels: for o in screen.hero_panels:
if screen.hero_panels[o] == item: if screen.hero_panels[o] == item:
screen.on_click("hero", o) screen.on_click("hero", o)
zone = "hand" zone = "own"
index = 0 _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: func draw_cursor() -> void:
@@ -130,7 +154,7 @@ func draw_cursor() -> void:
index = clampi(index, 0, items.size() - 1) index = clampi(index, 0, items.size() - 1)
var item = items[index] var item = items[index]
var r: Rect2 = Rect2(item.slot, item.size) if item is CreatureView else Rect2(item.position, item.size) var r: Rect2 = Rect2(item.slot, item.size) if item is CreatureView else Rect2(item.position, item.size)
var hl := BattleScreen.Highlight.new() var hl := Highlight.new()
hl.position = r.position - Vector2(6, 6) hl.position = r.position - Vector2(6, 6)
hl.size = r.size + Vector2(12, 12) hl.size = r.size + Vector2(12, 12)
hl.mouse_filter = Control.MOUSE_FILTER_IGNORE hl.mouse_filter = Control.MOUSE_FILTER_IGNORE
+61 -28
View File
@@ -1,40 +1,73 @@
class_name BattleLayout 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 B_UNDO := Rect2(20, 10, 210, 32) # links oben: Rückgängig, darunter Überspringen (Gegnerzug)
const HAND_W := 130 const B_SKIP := Rect2(20, 48, 210, 32)
const HAND_H := 185 const OPP_DECK := Rect2(1090, 10, 130, 182) # Stapel des Gegners rechts oben
const HAND_GAP := 8 const OPP_BOX := Rect2(1050, 200, 210, 70) # Kasten des Gegners darunter
const BAR := Rect2(335, 415, 610, 68) const OPP_HAND_Y := 22 # Bogen hebt die äußeren Rückseiten um bis zu 12 Pixel
const OWN_X := (1280 - 5 * 110 - 4 * 10) / 2 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
static func compact(n_others: int) -> bool: const MSG := Rect2(20, 650, 210, 80) # Meldungen
return n_others >= 4 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)
static func hero_box_rect(n_others: int, i: int) -> Rect2: const OWN_X := 295
var col_w := 1280 / n_others const HAND_W := 130 # Handkarten so groß wie die Wesen auf dem Feld
var margin := 6 if compact(n_others) else 10 const HAND_H := 165
return Rect2(i * col_w + margin, 10, col_w - 2 * margin, 64) 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: 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: ## Brett des Gegners über seiner Elementleiste.
var col_w := 1280 / n_others static func opp_slot(j: int) -> Rect2:
var sz: Array = SIZES[n_others] return Rect2(OWN_X + j * 140, 205, HAND_W, HAND_H)
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])
## 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: static func hand_rect(count: int, i: int) -> Rect2:
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP var step: float = HAND_W + HAND_GAP
var x := (1280 - total) / 2 if count > 1:
return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H) 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: ## Hand des Gegners oben, gleich groß, nur die Rückseiten.
return Rect2(400 + i * 78, 490, 70, 98) 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 (Elementbonus, Eigenschaften) links unter den Elementzählern.
static func info_rect(_preview: Rect2) -> Rect2:
return INFO
-63
View File
@@ -1,63 +0,0 @@
class_name BattleMulligan
extends Control
## Mulligan-Maske: Karten anklicken, behalten oder tauschen.
signal decided(indexes: Array)
var layer: Control
var b_keep: Button
var b_swap: Button
var selected: Array = []
func _init() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(dark)
Style.panel(self, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
Style.label(self, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
Style.label(self, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
layer = Control.new()
layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(layer)
b_keep = Style.button(self, "Behalten", Rect2(430, 515, 200, 44), func(): finish(false), Color("285a3c"))
b_swap = Style.button(self, "Tauschen", Rect2(650, 515, 200, 44), func(): finish(true))
func open(hand: Array) -> void:
selected = []
visible = true
Style.clear(layer)
for i in hand.size():
var r := BattleLayout.hand_rect(hand.size(), i)
var v := CardView.new(hand[i], r.size)
v.position = Vector2(r.position.x, 322)
var idx := i
v.clicked.connect(func(_view): toggle(idx))
layer.add_child(v)
_borders()
func toggle(idx: int) -> void:
if idx in selected:
selected.erase(idx)
else:
selected.append(idx)
_borders()
func _borders() -> void:
var views := layer.get_children()
for i in views.size():
views[i].border = Style.YELLOW if i in selected else null
views[i].queue_redraw()
b_swap.text = ("Tauschen (%d)" % selected.size()) if not selected.is_empty() else "Tauschen"
func finish(swap: bool) -> void:
visible = false
decided.emit(selected.duplicate() if swap else [])
-1
View File
@@ -1 +0,0 @@
uid://gwdm38e83cbj
File diff suppressed because it is too large. Load diff
-149
View File
@@ -1,149 +0,0 @@
class_name BattleTutorial
extends RefCounted
## Geführte Partien: Schritte, Bedingungen und Hervorhebungen (ausgelagert aus dem Kampfbildschirm).
const STEPS1 := [
["Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",
"played_creature"],
["Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.", "turn_passed"],
["Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.", "attacked"],
["Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.", "resonance"],
["Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an das stärkste gegnerische Wesen.", "combo"],
["Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.", "spell"],
["Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!", ""],
]
const STEPS2 := [
["Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst.", "mulligan"],
["Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast.", "trap_set"],
["Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein.", "trap_fired"],
["Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander.", "combo_dampf"],
["Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara!", ""],
]
var screen # BattleScreen
var part := 1
var step := 0
func _init(s, p: int = 1) -> void:
screen = s
part = p
func steps() -> Array:
return STEPS1 if part == 1 else STEPS2
func stage_index() -> int:
return 0 if part == 1 else 1
## Teil 1: ein Wesen für 1 Energie auf der Hand, damit der erste Schritt immer möglich ist.
## Teil 2: Lehrkarten auf die eigene Hand; Mara bekommt Wesen, damit sie sicher in die Falle läuft.
func inject_cards(g: Game) -> void:
if part == 1 and not g.human.hand.any(func(c): return c.is_creature() and c.cost == 1):
g.human.hand.append(Cards.by_id("f_kobold"))
if part == 2:
g.human.hand.append(Cards.by_id("w_falle"))
g.human.hand.append(Cards.by_id("w_reiter"))
for i in 2:
g.opponents[0].hand.append(Cards.by_id("w_reiter"))
func condition(kind: String) -> bool:
var g: Game = screen.game
var h: Game.Player = g.human
match kind:
"played_creature":
return g.log.any(func(l): return l.begins_with("Du spielt")) and h.board.size() > 0
"turn_passed":
return h.turns >= 2
"attacked":
return g.log.any(func(l): return l.begins_with(h.board[0].name + " greift") if not h.board.is_empty() else false) \
or g.log.any(func(l): return l.ends_with("greift Fenn an."))
"resonance":
return h.board.any(func(c): return c.resonance > 0)
"combo":
return g.log.any(func(l): return l.begins_with("Feldkombo"))
"spell":
return g.log.any(func(l): return l == "Du spielt Funke." or l == "Du spielt Feuerball." or l == "Du spielt Verhärten.")
"mulligan":
return h.mulligan_done
"trap_set":
return g.log.any(func(l): return l == "Du legt eine Falle.")
"trap_fired":
return g.log.any(func(l): return l.begins_with("Falle von Du"))
"combo_dampf":
return g.log.any(func(l): return l.begins_with("Feldkombo Dampf"))
return false
func advance() -> void:
while step < steps().size() - 1 and condition(steps()[step][1]):
step += 1
func next() -> bool:
if step < steps().size() - 1:
step += 1
return true
return false
func text() -> String:
return "%d/%d %s" % [step + 1, steps().size(), screen.tr(steps()[step][0])]
func finished() -> bool:
return step >= steps().size() - 1
## Rechtecke, die der aktuelle Schritt hervorhebt.
func highlight_rects(mine: bool) -> Array[Rect2]:
var rects: Array[Rect2] = []
if not mine or screen.any_overlay():
return rects
var g: Game = screen.game
var h: Game.Player = screen.perspective
match steps()[step][1]:
"played_creature":
for v in screen.hand_views:
if v.card.is_creature() and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"turn_passed", "trap_fired":
rects.append(screen.b_end.get_rect())
"attacked":
for v in screen.own_views:
if v.creature.can_attack():
rects.append(Rect2(v.slot, v.size))
if screen.own_views.any(func(v): return v.creature.can_attack()):
for o in screen.others():
if o.alive() and screen.hero_panels.has(o):
rects.append(screen.hero_panels[o].get_rect())
"resonance":
for v in screen.hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return e in c.elements)):
rects.append(Rect2(v.position, v.size))
"combo":
for v in screen.hand_views:
var c: Cards.Card = v.card
if c.is_creature() and g.can_play(h, c)[0] and h.board.any(func(b): return b.card.elements.any(func(e): return not (e in c.elements))):
rects.append(Rect2(v.position, v.size))
"spell":
for v in screen.hand_views:
if v.card.kind == "Zauber" and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"trap_set":
for v in screen.hand_views:
if v.card.is_trap() and g.can_play(h, v.card)[0]:
rects.append(Rect2(v.position, v.size))
"combo_dampf":
var has_fire: bool = h.board.any(func(b): return "Feuer" in b.card.elements)
for v in screen.hand_views:
var c: Cards.Card = v.card
if not c.is_creature() or not g.can_play(h, c)[0]:
continue
if (has_fire and "Wasser" in c.elements) or (not has_fire and "Feuer" in c.elements):
rects.append(Rect2(v.position, v.size))
return rects
-1
View File
@@ -1 +0,0 @@
uid://cg78afyod2873
+57 -66
View File
@@ -5,17 +5,15 @@ const CARD_W := 200
const CARD_H := 280 const CARD_H := 280
var app var app
var cards: Array = [] var cards: Array = [] # {"card", "rarity", "upgraded", "dust"} je Karte
var revealed := {} var revealed := {}
var new_ids := {}
var coins_label: Label var coins_label: Label
var msg_label: Label var msg_label: Label
var b_buy: Button var b_buy: Button
var b_all: Button var b_all: Button
var b_improve: Button
var suggestion := ""
var slots: Control var slots: Control
var element_buttons: Array = [] var fx_layer: Control
var fresh := {} # gerade aufgedeckt: wird umgedreht
class CardBack extends Control: class CardBack extends Control:
@@ -46,29 +44,23 @@ class CardBack extends Control:
func slot_rect(i: int) -> Rect2: func slot_rect(i: int) -> Rect2:
var gap := 20 var gap := 20
var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2 var x0 := (1280 - Profile.BOOSTER_SIZE * CARD_W - (Profile.BOOSTER_SIZE - 1) * gap) / 2
return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H) return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H)
func _ready() -> void: func _ready() -> void:
Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true) Style.header(self, "Booster", func(): app.show_screen(MenuScreen.new()))
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center") coins_label = Style.chip(self, "", Rect2(860, 26, 240, 38))
Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.", fx_layer = Control.new()
16, Style.MUTED, Rect2(0, 115, 1280, 24), "center") fx_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
Style.label(self, "Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos" % Profile.ELEMENT_BOOSTER_COST, add_child(fx_layer)
14, Style.MUTED, Rect2(0, 586, 1280, 22), "center")
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
element_buttons.append(Style.button(self, e, Rect2(322 + i * 162, 612, 150, 38), buy.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)))
slots = Control.new() slots = Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(slots) add_child(slots)
msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 700, 1280, 22), "center") msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 640, 1280, 22), "center")
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(290, 720, 330, 48), buy, Color("5a4628")) b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(370, 690, 330, 52), buy, Color("5a4628"))
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all) b_buy.add_theme_font_size_override("font_size", 20)
b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c")) b_all = Style.button(self, "Alle aufdecken", Rect2(720, 690, 200, 52), reveal_all)
b_improve.visible = false
Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new()))
refresh() refresh()
@@ -77,10 +69,9 @@ func _input(event: InputEvent) -> void:
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())
func buy(element: String = "") -> void: func buy() -> void:
var p: Profile = app.profile var p: Profile = app.profile
var before: Dictionary = p.collection.duplicate() var got: Array = p.open_booster()
var got: Array = p.open_booster(null, element)
if got.is_empty(): if got.is_empty():
msg_label.text = "Nicht genug Münzen" msg_label.text = "Nicht genug Münzen"
msg_label.add_theme_color_override("font_color", Style.RED) msg_label.add_theme_color_override("font_color", Style.RED)
@@ -89,78 +80,78 @@ func buy(element: String = "") -> void:
app.sfx.play("play") app.sfx.play("play")
cards = got cards = got
revealed = {} revealed = {}
new_ids = {} msg_label.text = ""
for c in cards:
if before.get(c.id, 0) == 0:
new_ids[c.id] = true
var notes: Array[String] = p.take_notices()
msg_label.text = " · ".join(notes)
msg_label.add_theme_color_override("font_color", Style.GREEN)
refresh() refresh()
func reveal(i: int) -> void: func reveal(i: int) -> void:
revealed[i] = true revealed[i] = true
app.sfx.play("click") fresh = {i: 0.0}
app.sfx.play("combo" if cards[i].rarity != "Common" else "click")
refresh() refresh()
func reveal_all() -> void: func reveal_all() -> void:
for i in 5: fresh = {}
var k := 0
for i in Profile.BOOSTER_SIZE:
if not revealed.has(i):
fresh[i] = k * 0.12
k += 1
revealed[i] = true revealed[i] = true
app.sfx.play("play")
refresh() refresh()
## Nimmt die vorgeschlagene Karte ins aktive Deck (ersetzt die schwächste). ## Umdrehen: die Karte wächst aus der Mitte; seltene Stufen blitzen in ihrer Farbe auf.
func improve() -> void: func flip(v: CardView, delay: float, rarity: String) -> void:
if suggestion == "": if not Style.animations:
return return
var err: String = app.profile.improve_deck_with(suggestion) v.scale.x = 0.0
if err == "": var tw := create_tween()
app.save() tw.tween_interval(delay)
msg_label.text = tr("%s ist jetzt im Deck %s") % [Cards.by_id(suggestion).title(), app.profile.deck_name()] tw.tween_property(v, "scale:x", 1.0, 0.18).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
msg_label.add_theme_color_override("font_color", Style.GREEN) if rarity == "Common":
suggestion = "" return
else: var burst := Style.panel(fx_layer, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12)
msg_label.text = err burst.pivot_offset = burst.size / 2
msg_label.add_theme_color_override("font_color", Style.RED) burst.modulate.a = 0.0
refresh() var bt := create_tween()
bt.tween_interval(delay + 0.1)
bt.tween_property(burst, "modulate:a", 1.0, 0.05)
bt.parallel().tween_property(burst, "scale", Vector2(1.25, 1.25), 0.45).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
bt.tween_property(burst, "modulate:a", 0.0, 0.3)
bt.tween_callback(burst.queue_free)
func refresh() -> void: func refresh() -> void:
var p: Profile = app.profile var p: Profile = app.profile
coins_label.text = "Münzen: %d" % p.coins coins_label.text = "Münzen: %d" % p.coins
b_buy.disabled = p.coins < Profile.BOOSTER_COST b_buy.disabled = p.coins < Profile.BOOSTER_COST
for b in element_buttons: b_all.disabled = cards.is_empty() or revealed.size() >= Profile.BOOSTER_SIZE
b.disabled = p.coins < Profile.ELEMENT_BOOSTER_COST
b_all.disabled = cards.is_empty() or revealed.size() >= 5
# Deck-Vorschlag, sobald alle Karten offen liegen
suggestion = ""
if not cards.is_empty() and revealed.size() >= 5:
var ids: Array = cards.map(func(c): return c.id)
var better: Array = p.deck_suggestions(ids)
if not better.is_empty():
suggestion = better[0]
b_improve.visible = suggestion != ""
if suggestion != "":
b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).title(), p.deck_name()]
Style.clear(slots) Style.clear(slots)
for i in 5: for i in Profile.BOOSTER_SIZE:
var r := slot_rect(i) var r := slot_rect(i)
if cards.is_empty(): if cards.is_empty():
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2) Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
elif revealed.has(i): elif revealed.has(i):
var c: Cards.Card = cards[i] var res: Dictionary = cards[i]
var v := CardView.new(c, r.size) 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 v.position = r.position
slots.add_child(v) slots.add_child(v)
Style.label(slots, tr(c.rarity), 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true) if fresh.has(i):
if new_ids.has(c.id): flip(v, fresh[i], res.rarity)
Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true) Style.label(slots, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if res.get("new", false):
Style.label(slots, "Neu!", 16, Style.GOLD, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
elif res.upgraded:
Style.label(slots, "Aufgewertet", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
else: 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, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
else: else:
var back := CardBack.new(r.size) var back := CardBack.new(r.size)
back.position = r.position back.position = r.position
back.clicked.connect(reveal.bind(i)) back.clicked.connect(reveal.bind(i))
slots.add_child(back) slots.add_child(back)
fresh = {}
+74 -49
View File
@@ -1,32 +1,37 @@
class_name CampaignScreen class_name CampaignScreen
extends Control extends Control
## Kampagne als Pfad: zehn Stationen je Kapitel, darunter die gewählte Stufe im Detail.
var app var app
var chapter := 1 var chapter := 1
var selected := 0 # gewählte Stufe (Index über alle Kapitel)
var perk_box: OptionButton var perk_box: OptionButton
var node_buttons: Array = []
func _ready() -> void: func _ready() -> void:
var p: Profile = app.profile var p: Profile = app.profile
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size()) chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size())
selected = mini(p.campaign_stage, Campaign.total() - 1)
build() build()
func build() -> void: func build() -> void:
Style.clear(self) Style.clear(self)
node_buttons = []
var p: Profile = app.profile var p: Profile = app.profile
var col := Style.column(self, 1000, 16, 5) Style.header(self, "Kampagne", func(): app.show_screen(MenuScreen.new()))
Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true) var n := Campaign.STAGES.size()
var tabs := Style.row(col, 10) var x := 40
tabs.alignment = BoxContainer.ALIGNMENT_CENTER
for c in Campaign.CHAPTERS.size(): for c in Campaign.CHAPTERS.size():
var b := Style.cbutton(tabs, "Kapitel %d" % (c + 1), Vector2(140, 36), set_chapter.bind(c + 1), Color("285a3c") if chapter == c + 1 else null) var b := Style.button(self, "Kapitel %d" % (c + 1), Rect2(x, 88, 150, 38), set_chapter.bind(c + 1), Style.PRIMARY if chapter == c + 1 else null)
b.disabled = p.campaign_stage < c * Campaign.STAGES.size() b.disabled = p.campaign_stage < c * n
x += 160
if p.perk_unlocked(): if p.perk_unlocked():
var pr := Style.row(tabs, 8) Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(760, 96, 130, 24), "right")
Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110)
perk_box = OptionButton.new() perk_box = OptionButton.new()
perk_box.custom_minimum_size = Vector2(300, 36) perk_box.position = Vector2(900, 88)
perk_box.size = Vector2(340, 38)
perk_box.add_item("keins") perk_box.add_item("keins")
var keys: Array = Game.PERKS.keys() var keys: Array = Game.PERKS.keys()
for k in keys: for k in keys:
@@ -35,62 +40,82 @@ func build() -> void:
perk_box.item_selected.connect(func(i: int): perk_box.item_selected.connect(func(i: int):
p.perk = "" if i == 0 else keys[i - 1] p.perk = "" if i == 0 else keys[i - 1]
app.save()) app.save())
pr.add_child(perk_box) add_child(perk_box)
var intro := ["Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★).", # Pfad mit Stationen
"Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.", var offset := (chapter - 1) * n
"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks."] var y := 190.0
Style.clabel(col, intro[chapter - 1], 14, Style.MUTED) var step := (1280 - 200) / float(n - 1)
var offset := (chapter - 1) * Campaign.STAGES.size() var centers: Array[Vector2] = []
for k in Campaign.STAGES.size(): 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 i := offset + k
var st := Campaign.stage(i)
var done := i < p.campaign_stage var done := i < p.campaign_stage
var next := i == p.campaign_stage var next := i == p.campaign_stage
var panel := PanelContainer.new() var b := Button.new()
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6, b.text = str(i + 1)
Style.GOLD if next else null, 2 if next else 0)) b.position = centers[k] - Vector2(24, 24)
col.add_child(panel) b.size = Vector2(48, 48)
var r := Style.row(panel, 10) var base: Color = Style.PRIMARY if done else (Color("5a4628") if next else Style.PANEL)
r.custom_minimum_size = Vector2(0, 50) var edge: Color = Style.GOLD if i == selected else (Style.GREEN if done else Style.MUTED)
Style.clabel(r, str(i + 1), 18, Style.GOLD if next else Style.MUTED, "center", true, 36) b.add_theme_stylebox_override("normal", Style.box(base, 24, edge, 3 if i == selected else 2))
Style.clabel(r, "%s · %s" % [st.name, st.title], 16, Style.TEXT if not done else Style.MUTED, "left", true, 290) b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 24, edge, 3))
var dots := Control.new() b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 24, edge, 3))
dots.custom_minimum_size = Vector2(50, 0) b.add_theme_stylebox_override("disabled", Style.box(Style.darker(Style.PANEL, 0.8), 24, Style.PANEL_LIGHT, 2))
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE b.add_theme_stylebox_override("focus", Style.box(Color(0, 0, 0, 0), 24, Style.GOLD, 3))
var elems: Array = st.elements b.disabled = i > p.campaign_stage
dots.draw.connect(func(): b.pressed.connect(func():
for m in elems.size(): selected = i
dots.draw_circle(Vector2(10 + m * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[m]])) build())
r.add_child(dots) Style.juice(b)
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) add_child(b)
var table: String = "Duell" if extra == 0 else "%d am Tisch" % (extra + 2) node_buttons.append(b)
var perks_text: String = ", ".join(st.perks.map(func(pk): return Game.PERKS[pk])) if next and Style.animations:
var target := Campaign.target_rating(i, p.target_rating()) b.pivot_offset = b.size / 2
Style.clabel(r, "%s · Stärke %.1f · %d Münzen\n★ %s" % [table, target, st.reward, perks_text], 12, Style.MUTED, "left", false, 360) 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: if done:
Style.clabel(r, "abgeschlossen", 13, Style.GREEN, "right", false, 100) Style.button(self, "Wiederholen", Rect2(540, 500, 200, 50), _start.bind(selected))
Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i))
elif next: elif next:
Style.clabel(r, "", 13, Style.GREEN, "right", false, 100) var b := Style.button(self, "Kämpfen", Rect2(540, 500, 200, 50), _start.bind(selected), Style.PRIMARY)
Style.cbutton(r, "Kämpfen", Vector2(130, 36), _start.bind(i), Color("285a3c")) b.add_theme_font_size_override("font_size", 20)
else: else:
Style.clabel(r, "", 13, Style.GREEN, "right", false, 100) var b := Style.button(self, "Gesperrt", Rect2(540, 500, 200, 50), func(): pass)
var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
b.disabled = true b.disabled = true
if p.campaign_stage >= Campaign.total(): if p.campaign_stage >= Campaign.total():
Style.clabel(col, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true) Style.label(self, "Alle Kapitel abgeschlossen", 16, Style.GREEN, Rect2(0, 600, 1280, 24), "center", true)
var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
func set_chapter(c: int) -> void: func set_chapter(c: int) -> void:
chapter = c chapter = c
var p: Profile = app.profile
var first := (c - 1) * Campaign.STAGES.size()
selected = p.campaign_stage if Campaign.chapter(mini(p.campaign_stage, Campaign.total() - 1)) == c else first
build() build()
func _start(stage: int) -> void: func _start(stage: int) -> void:
app.show_screen(BattleScreen.new(0, stage)) app.show_screen(BattleScreen.new(stage))
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
+9 -14
View File
@@ -1,14 +1,13 @@
class_name CardGrid class_name CardGrid
extends ScrollContainer extends ScrollContainer
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte. ## Scrollbares Kartenraster.
signal card_clicked(card: Cards.Card) signal card_clicked(card: Cards.Card)
signal card_hover(card: Cards.Card, entered: bool)
const COLS := 7 const COLS := 6
const CW := 100 const CW := 118
const CH := 140 const CH := 165
const GAP := 12 const GAP := 10
var grid: GridContainer var grid: GridContainer
@@ -25,20 +24,16 @@ func _init(rect: Rect2) -> void:
add_child(grid) add_child(grid)
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool ## cards: Karten; rarity_fn(card) -> String: die Stufe, die die Karte zeigt (Optik)
func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void: func fill(cards: Array, rarity_fn: Callable) -> void:
Style.clear(grid) Style.clear(grid)
for c in cards: for c in cards:
var cell := Control.new() 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 cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
var v := CardView.new(c, Vector2(CW, CH), true) var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c))
v.dim = dim_fn.call(c)
v.clicked.connect(func(_view): card_clicked.emit(c)) v.clicked.connect(func(_view): card_clicked.emit(c))
v.hover.connect(func(card, entered): card_hover.emit(card, entered))
cell.add_child(v) 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) grid.add_child(cell)
+67 -44
View File
@@ -1,23 +1,40 @@
class_name CardView class_name CardView
extends Control extends Control
## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau). ## 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 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) signal hover(card: Cards.Card, entered: bool)
var card: Cards.Card 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 dim := false
var bonus := 0 # Vorschau im Kampf: Elementbonus, den die Karte offen gelegt bekäme
var lift := false # hebt sich beim Zeigen an (Hand)
var drag_enabled := false # Hand im Kampf: Drücken meldet pressed statt clicked
var drop := false # Ziel der gerade gezogenen Karte
## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick. ## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
func _init(c: Cards.Card, sz: Vector2, focusable: bool = false) -> void: func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "Common") -> void:
card = c card = c
rarity = rar
set_process(Style.rarity_animated(rarity)) # bewegter Glanz
custom_minimum_size = sz custom_minimum_size = sz
size = sz size = sz
pivot_offset = sz / 2
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(card, true)) mouse_entered.connect(func():
mouse_exited.connect(func(): hover.emit(card, false)) hover.emit(card, true)
_lift(true))
mouse_exited.connect(func():
hover.emit(card, false)
_lift(false))
if focusable: if focusable:
focus_mode = Control.FOCUS_ALL focus_mode = Control.FOCUS_ALL
focus_entered.connect(func(): focus_entered.connect(func():
@@ -28,9 +45,23 @@ func _init(c: Cards.Card, sz: Vector2, focusable: bool = false) -> void:
queue_redraw()) 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: func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event() accept_event()
if drag_enabled:
pressed.emit(self, event.global_position)
else:
clicked.emit(self) clicked.emit(self)
elif has_focus() and event.is_action_pressed("ui_accept"): elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event() accept_event()
@@ -39,48 +70,40 @@ func _gui_input(event: InputEvent) -> void:
func _draw() -> void: func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size) var rect := Rect2(Vector2.ZERO, size)
var big := size.x >= 180 var seed := hash(card.id)
var mid := size.x >= 115 var frame: Color = border if border != null else Style.rarity_frame(rarity, seed)
var f_name := 18 if big else (14 if mid else 12) if rarity != "Common":
var f_text := 14 if big else (11 if mid else 10) Style.glow(self, rect, Style.rarity_color(rarity, seed), 8, 8 if size.x >= 180 else 6)
var f_stat := 24 if big else (18 if mid else 15)
Style.fill_elements(self, rect, card.elements, 8) Style.fill_elements(self, rect, card.elements, 8)
var bcol: Color = border if border != null else Cards.RARITY_COLORS[card.rarity] var big := size.x >= 180
Style.border(self, rect, bcol, 3 if big else 2, 8) var mid := size.x >= 140
# Kosten var f_name := 17 if big else (14 if mid else 11)
var r := 16 if big else 12 var f_stat := 20 if big else (17 if mid else 14)
var cc := Vector2(r + 4, r + 4) var r_cost := 17.0 if big else (14.0 if mid else 12.0)
draw_circle(cc, r, Color("14141c")) var r_stat := 18.0 if big else (15.0 if mid else 12.0)
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true) var pad := 8.0 if mid else 6.0
# Name und Kombo var shiny := Style.rarity_stats_shiny(rarity)
var name_left := 2 * r + 10 # Oben: Kosten links, daneben der Name; das Namensfeld ist genau so hoch wie der Kristall
var nm := Style.fit(card.title(), true, f_name, size.x - name_left - 6) var top_h := r_cost * 2 + 4
Style.text(self, nm, f_name, Style.TEXT, Vector2((name_left + size.x - 6) / 2.0, 4), "midtop", true) draw_rect(Rect2(2, 2, size.x - 4, top_h), Color(0.05, 0.05, 0.08, 0.6))
if mid: 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)
var sub: String = (tr(card.combo()) + " · " + tr(card.kind)) if card.is_combo() else tr(card.kind) # Mitte: das Bild im Kreis, so groß wie der Platz bis zu den Werten erlaubt
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop") var pic_top := top_h + 4
# Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest var pic_bottom := size.y - r_stat - 4
var tsize := Style.fs(f_text) var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
var text_w: float = size.x - 20 Style.picture(self, card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rarity, seed)
var needed: float = Style.FONT.get_multiline_string_size(card.text(), HORIZONTAL_ALIGNMENT_LEFT, text_w, tsize).y + 8 if card.text() != "" else size.y * 0.42 # Unten: Angriff links, Leben rechts
var box_bottom: float = size.y - (28 if big else 22) 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)
var box_top: float = maxf(size.y * 0.30, box_bottom - maxf(needed, size.y * 0.42)) 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)
var box := Rect2(6, box_top, size.x - 12, box_bottom - box_top) if bonus > 0:
# Bild Style.text_outlined(self, "+%d" % bonus, 13 if big else 11, Style.GREEN, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3)
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0) Style.card_sheen(self, rect, rarity, seed)
var band_bottom: float = box_top - 4 Style.border(self, rect, frame, 3 if big else 2, 8)
if band_bottom - band_top > 14: Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, card.cost, f_stat, shiny)
var gsize: float = minf(band_bottom - band_top, size.x * 0.42) Style.rarity_sparkle(self, rect, rarity, seed)
Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize)
draw_rect(box, Color(0.06, 0.06, 0.09, 0.67))
var max_lines := maxi(1, int((box.size.y - 6) / Style.FONT.get_height(tsize))) # große Schrift: lieber kürzen als überlaufen
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)),
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
# Werte
if card.is_creature():
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
if dim: if dim:
draw_rect(rect, Color(0, 0, 0, 0.47)) draw_rect(rect, Color(0, 0, 0, 0.47))
if has_focus(): if has_focus():
Style.border(self, rect, Style.GOLD, 4, 8) Style.border(self, rect, Style.GOLD, 4, 8)
if drop:
Style.border(self, rect, Style.GOLD, 5, 8)
-42
View File
@@ -1,42 +0,0 @@
class_name ChallengeScreen
extends Control
## Wöchentliche Herausforderung: feste Regelvariante mit einmaliger Belohnung.
var app
var msg: Label
func _ready() -> void:
var p: Profile = app.profile
var ch := Challenge.current()
var done: bool = p.challenge_week == Challenge.week()
var col := Style.column(self, 760, 60, 14)
Style.clabel(col, "Herausforderung der Woche", 36, Style.GOLD, "center", true)
Style.clabel(col, "Jede Woche eine andere Regel. Die Belohnung gibt es einmal pro Woche, danach die normale.", 16, Style.MUTED)
Style.spacer(col, 20)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 10, Style.GREEN if done else Style.GOLD, 2))
col.add_child(panel)
var inner := VBoxContainer.new()
inner.add_theme_constant_override("separation", 10)
panel.add_child(inner)
Style.spacer(inner, 6)
Style.clabel(inner, ch.name, 30, Style.TEXT, "center", true)
Style.clabel(inner, ch.text, 18, Style.MUTED)
Style.clabel(inner, "Belohnung: %d Münzen%s" % [ch.reward, " · diese Woche schon bestanden ✓" if done else ""], 16, Style.GOLD)
Style.spacer(inner, 6)
Style.spacer(col, 10)
msg = Style.clabel(col, "", 16, Style.ORANGE)
var deck_err := Challenge.deck_allowed(ch, p.deck_cards())
if deck_err != "":
msg.text = deck_err + " (aktives Deck: %s)" % p.deck_name()
var br := Style.row(col, 20)
br.alignment = BoxContainer.ALIGNMENT_CENTER
var b := Style.cbutton(br, "Antreten", Vector2(200, 50), func(): app.show_screen(BattleScreen.weekly(ch)), Color("285a3c"))
b.disabled = deck_err != "" or not p.deck_valid()[0]
Style.cbutton(br, "Zurück", Vector2(160, 50), func(): app.show_screen(MenuScreen.new()))
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())
-1
View File
@@ -1 +0,0 @@
uid://cxheynenqemse
+174
View File
@@ -0,0 +1,174 @@
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 = Cards.ELEMENTS + [Cards.NEUTRAL]
for i in filters.size():
var e: String = filters[i]
var fb := Style.button(self, e, Rect2(108 + i * 82, 86, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14)
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()
## Eigene Karten zuerst (höhere Stufen vorn), dann die fehlenden, sonst in Kartenreihenfolge.
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.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
+106 -52
View File
@@ -2,7 +2,8 @@ class_name CreatureView
extends Control extends Control
## Zeichnet ein Wesen auf dem Feld. Bleibt über Zustandsänderungen bestehen (für Animationen). ## 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) signal hover(card: Cards.Card, entered: bool)
var creature: Game.Creature var creature: Game.Creature
@@ -11,6 +12,11 @@ var width := 2
var flash := false # kurz rot: wurde gerade angegriffen var flash := false # kurz rot: wurde gerade angegriffen
var slot: Vector2 # Zielposition im Raster var slot: Vector2 # Zielposition im Raster
var busy := false # wird gerade animiert (kein Neupositionieren) var busy := false # wird gerade animiert (kein Neupositionieren)
var matchup := 0 # Elementvorteil des gewählten Angreifers gegen dieses fremde Wesen (Game.matchup_bonus); die Werte zeigen dann, was ihn erwartet
var 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: func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
@@ -20,73 +26,121 @@ func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
custom_minimum_size = sz custom_minimum_size = sz
size = sz size = sz
pivot_offset = sz / 2 pivot_offset = sz / 2
set_process(Style.rarity_animated(rarity())) # bewegter Glanz
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(creature.card, true)) mouse_entered.connect(func(): hover.emit(creature.card, true))
mouse_exited.connect(func(): hover.emit(creature.card, false)) mouse_exited.connect(func(): hover.emit(creature.card, false))
func _process(_delta: float) -> void:
queue_redraw()
func _gui_input(event: InputEvent) -> void: func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event() accept_event()
clicked.emit(self) 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: func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size) var rect := Rect2(Vector2.ZERO, size)
if secret:
_draw_back(rect)
_draw_drop()
return
var small := size.x < 100 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) 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: if flash:
bcol = Style.RED 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), rechts der Elementbonus
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
if creature.aura != 0:
var badge := "+%d" % creature.aura if creature.aura > 0 else "−%d" % -creature.aura
var bw := Style.FONT_BOLD.get_string_size(badge, HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(10)).x + 8
draw_rect(Rect2(size.x - bw - 4, top_h + 5, bw, 16), Color(0.05, 0.05, 0.08, 0.75))
Style.text(self, badge, 10, Style.GREEN if creature.aura > 0 else Style.RED, Vector2(size.x - 4 - bw / 2, top_h + 13), "center", true)
_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) Style.border(self, rect, bcol, 4 if flash else width, 6)
if mini: Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, creature.card.cost, 13, shiny)
_draw_mini() if creature.hidden:
return draw_rect(rect, Color(0.05, 0.05, 0.1, 0.55))
var fs := 11 if small else 13 Style.text_outlined(self, tr("verdeckt"), 12, Style.TEXT, Vector2(size.x / 2, size.y / 2), "center", 3)
var nm := Style.fit(creature.card.title(), true, fs, size.x - 6) if doomed:
Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true) draw_rect(Rect2(0, size.y * 0.5 - 12, size.x, 24), Color(0.05, 0.02, 0.02, 0.8))
var badges: Array[String] = [] Style.text_outlined(self, tr("stirbt"), 14, Style.RED, Vector2(size.x / 2, size.y * 0.5), "center", 3)
if creature.taunt(): Style.rarity_sparkle(self, rect, rar, seed)
badges.append("Wächter") _draw_drop()
if creature.shield:
badges.append("Schild")
if creature.attacks_per_turn() == 2:
badges.append("x2")
if creature.frozen:
badges.append("Eis")
if creature.resonance > 0:
badges.append("Resonanz")
var y := 18.0 if small else 22.0
for b in badges:
Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop")
y += 12 if small else 15
var f := 16 if small else 20
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true)
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true)
if creature.frozen:
draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35))
## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe. ## Goldener Rahmen, solange eine Karte zum Verschmelzen darüber schwebt.
func _draw_mini() -> void: func _draw_drop() -> void:
var letters: Array[String] = [] if drop:
if creature.taunt(): Style.border(self, Rect2(Vector2.ZERO, size), Style.GOLD, 5, 6)
letters.append("W")
if creature.shield:
letters.append("S") ## Verdecktes fremdes Wesen: ein Kartenrücken.
if creature.attacks_per_turn() == 2: func _draw_back(rect: Rect2) -> void:
letters.append("2") var bcol: Color = border if border != null else Style.GOLD
if creature.frozen: if flash:
letters.append("E") bcol = Style.RED
if creature.resonance > 0: draw_style_box(Style.box(Color("3c285a"), 6, bcol, 4 if flash else maxi(width, 2)), rect)
letters.append("R") Style.border(self, rect.grow(-10), Color("5a3c82"), 2, 6)
Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true) Style.text(self, "?", 36, Style.GOLD, size / 2, "center", 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") ## Angriff und Leben für den Angriff, der gerade vorbereitet wird. Fremdes Wesen bei gewähltem
Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true) ## Angreifer: sein Konter gegen ihn (im Nachteil 1 weniger, im Vorteil 1 mehr, auch bei 0 Angriff),
if creature.frozen: ## das Leben bleibt echt. Eigener Angreifer über einem Ziel: sein Angriff mit Elementvorteil.
draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35)) func shown_stats() -> Array:
if matchup != 0:
var atk := maxi(0, creature.power() - matchup)
return [atk, 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)
+60 -381
View File
@@ -1,215 +1,44 @@
class_name DeckScreen class_name DeckScreen
extends Control extends Control
## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten. ## Deck zusammenstellen: alle eigenen Karten im Raster, ein Klick nimmt eine Karte in den
## Stapel oder wieder heraus. Karten außerhalb des Stapels sind abgedunkelt. Der Stapel hält
const SORTS := ["Kosten", "Name", "Element", "Seltenheit"] ## 40 bis 60 Karten; „Auffüllen“ nimmt die wertvollsten eigenen Karten des gewählten Filters.
## „Stärke“ ist der mittlere Kartenwert (100 Grundkarten … 500 Ultimate Rare).
var app var app
var slot := 0
var deck: Array = []
var filter := "" var filter := ""
var search := ""
var sort_mode := 0
var show_missing := false
var msg_label: Label
var grid: CardGrid var grid: CardGrid
var deck_list: Control var count_label: Label
var deck_label: Label var strength_label: Label
var elems_label: Label var msg_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 confirm: Control
var code_dialog: Control
var code_box: LineEdit
class DeckRow extends Control:
signal clicked(card)
signal hover(card, entered: bool)
var card: Cards.Card
var count: int
var lit := false
func _init(c: Cards.Card, n: int, sz: Vector2) -> void:
card = c
count = n
size = sz
custom_minimum_size = sz
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
var light := func(on: bool):
lit = on
queue_redraw()
hover.emit(card, on)
mouse_entered.connect(light.bind(true))
mouse_exited.connect(light.bind(false))
focus_entered.connect(light.bind(true))
focus_exited.connect(light.bind(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)
elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event()
clicked.emit(card)
func _draw() -> void:
if lit:
draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size))
for k in card.elements.size():
draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]])
Style.text(self, "%dx %s" % [count, card.title()], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true)
class EnergyCurve extends Control:
var cards: Array = []
func _draw() -> void:
Style.text(self, "Energiekurve", 12, Style.MUTED, Vector2(0, 0))
var counts := {}
for c in cards:
var k: int = mini(c.cost, 8)
counts[k] = counts.get(k, 0) + 1
for i in range(1, 9):
var n: int = counts.get(i, 0)
var x := (i - 1) * 48
draw_rect(Rect2(x, 104 - n * 5, 36, n * 5), Style.BLUE)
if n > 0:
Style.text(self, str(n), 11, Style.TEXT, Vector2(x + 18, 102 - n * 5), "midbottom")
Style.text(self, str(i) + ("+" if i == 8 else ""), 12, Style.MUTED, Vector2(x + 18, 108), "midtop")
func _ready() -> void: func _ready() -> void:
slot = app.profile.active_deck Style.header(self, "Deck", func(): app.show_screen(MenuScreen.new()))
deck = app.profile.decks[slot].cards.duplicate() Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
Style.label(self, "Deck bauen", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true) var filters: Array = Cards.ELEMENTS + [Cards.NEUTRAL]
Style.label(self, "Klick: Karte ins Deck. Klick in der Liste: Karte entfernen. Mausrad: scrollen.", for i in filters.size():
14, Style.MUTED, Rect2(300, 18, 700, 24)) var e: String = filters[i]
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) 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))
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14) fb.add_theme_font_size_override("font_size", 14)
search_box = LineEdit.new() grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
search_box.position = Vector2(424, 55) grid.card_clicked.connect(toggle)
search_box.size = Vector2(136, 34)
search_box.placeholder_text = "Suche"
search_box.text_changed.connect(func(t: String):
search = t
refresh())
add_child(search_box)
sort_box = OptionButton.new()
sort_box.position = Vector2(570, 55)
sort_box.size = Vector2(130, 34)
for s in SORTS:
sort_box.add_item(s)
sort_box.item_selected.connect(func(i: int):
sort_mode = i
refresh())
add_child(sort_box)
missing_box = CheckButton.new()
missing_box.position = Vector2(710, 58)
missing_box.text = "Fehlende"
missing_box.toggled.connect(func(on: bool):
show_missing = on
refresh())
add_child(missing_box)
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
grid.card_clicked.connect(add)
grid.card_hover.connect(show_preview)
add_child(grid) add_child(grid)
# Rechte Seite: Slots, Deckliste, Kurve Style.panel(self, Rect2(820, 86, 420, 696))
Style.panel(self, Rect2(845, 50, 420, 680)) count_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
for i in Profile.DECK_SLOTS: strength_label = Style.chip(self, "", Rect2(860, 144, 340, 36), Style.GOLD)
slot_buttons.append(Style.button(self, str(i + 1), Rect2(860 + i * 46, 58, 40, 32), select_slot.bind(i))) var hint := Style.label(self, "", 15, Style.MUTED, Rect2(840, 188, 380, 66), "center")
b_activate = Style.button(self, "Aktiv setzen", Rect2(1100, 58, 150, 32), activate) hint.autowrap_mode = TextServer.AUTOWRAP_WORD # vor dem Text, sonst wächst die Breite auf eine Zeile
name_box = LineEdit.new() hint.text = "Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die wertvollsten Karten des Filters."
name_box.position = Vector2(860, 98) Style.button(self, "Auffüllen", Rect2(840, 262, 380, 46), fill_up)
name_box.size = Vector2(200, 30) Style.button(self, "Leeren", Rect2(840, 318, 380, 46), clear_deck, Color("5a3c28"))
name_box.max_length = 20 msg_label = Style.label(self, "", 15, Style.RED, Rect2(840, 382, 380, 48), "center")
name_box.text_submitted.connect(func(t: String): rename(t))
name_box.focus_exited.connect(func(): rename(name_box.text))
add_child(name_box)
deck_label = Style.label(self, "", 22, Style.TEXT, Rect2(1070, 98, 120, 30), "left", true)
active_label = Style.label(self, "", 14, Style.GREEN, Rect2(1180, 104, 75, 24), "right")
b_autofill = Style.button(self, "Auto-Ausfüllen", Rect2(1100, 154, 150, 26), autofill)
b_autofill.add_theme_font_size_override("font_size", 13)
elems_label = Style.label(self, "", 16, Style.MUTED, Rect2(860, 130, 390, 24))
valid_label = Style.label(self, "", 14, Style.GREEN, Rect2(860, 156, 235, 22))
deck_list = Control.new()
deck_list.position = Vector2(860, 182)
deck_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(deck_list)
curve = EnergyCurve.new()
curve.position = Vector2(860, 604)
curve.size = Vector2(400, 125)
curve.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(curve)
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
Style.button(self, "Speichern", Rect2(860, 740, 140, 44), save_deck, Color("285a3c"))
Style.button(self, "Leeren", Rect2(1008, 740, 70, 44), clear_deck)
Style.button(self, "Code", Rect2(1086, 740, 84, 44), open_code)
Style.button(self, "Zurück", Rect2(1178, 740, 82, 44), leave)
# Deck-Code zum Teilen: kopieren, einfügen, übernehmen
code_dialog = Style.overlay(self)
Style.panel(code_dialog, Rect2(240, 270, 800, 240), Style.PANEL, 10, Style.GOLD, 2)
Style.label(code_dialog, "Deck-Code", 26, Style.TEXT, Rect2(240, 290, 800, 40), "center", true)
Style.label(code_dialog, "Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.", 15, Style.MUTED, Rect2(240, 334, 800, 24), "center")
code_box = LineEdit.new()
code_box.position = Vector2(270, 368)
code_box.size = Vector2(740, 36)
code_dialog.add_child(code_box)
Style.button(code_dialog, "In Zwischenablage", Rect2(270, 430, 180, 44), func(): DisplayServer.clipboard_set(code_box.text))
Style.button(code_dialog, "Aus Zwischenablage", Rect2(460, 430, 180, 44), func(): code_box.text = DisplayServer.clipboard_get())
Style.button(code_dialog, "Übernehmen", Rect2(650, 430, 170, 44), func(): apply_code(code_box.text), Color("285a3c"))
Style.button(code_dialog, "Schließen", Rect2(830, 430, 180, 44), func(): code_dialog.visible = false)
confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.",
"Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD)
preview = CardView.new(Cards.all()[0], Vector2(200, 280))
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview.visible = false
add_child(preview)
refresh() refresh()
func dirty() -> bool:
return deck != app.profile.decks[slot].cards
func leave() -> void:
if dirty():
confirm.visible = true
else:
app.show_screen(MenuScreen.new())
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel") and not search_box.has_focus() and not name_box.has_focus() and not code_box.has_focus(): if event.is_action_pressed("ui_cancel"):
if confirm.visible: app.show_screen(MenuScreen.new())
confirm.visible = false
elif code_dialog.visible:
code_dialog.visible = false
else:
leave()
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: func set_filter(e: String) -> void:
@@ -217,206 +46,56 @@ func set_filter(e: String) -> void:
refresh() refresh()
func select_slot(i: int) -> void: func toggle(card: Cards.Card) -> void:
slot = i var err: String = app.profile.toggle_deck(card.id)
deck = app.profile.decks[slot].cards.duplicate() msg_label.text = err
say("", true)
refresh()
func rename(t: String) -> void:
if t.strip_edges() == app.profile.deck_name(slot):
return
var err: String = app.profile.rename_deck(slot, t)
if err == "": if err == "":
app.save()
say("Umbenannt in %s" % app.profile.deck_name(slot), true)
else:
say(err, false)
refresh()
func autofill() -> void:
app.profile.decks[slot].cards = deck.duplicate()
var err: String = app.profile.autofill_deck(slot)
deck = app.profile.decks[slot].cards.duplicate()
if err == "":
app.save()
say("Aufgefüllt und gespeichert", true)
else:
say(err, false)
refresh()
func activate() -> void:
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
if not ok[0]:
say("Erst ein spielbares Deck speichern", false)
return
app.profile.decks[slot].cards = deck.duplicate()
app.profile.active_deck = slot
app.save()
say("%s ist jetzt das aktive Deck" % app.profile.deck_name(slot), true)
refresh()
## Karten der Sammlung (oder alle, wenn Fehlende gezeigt werden), gefiltert und sortiert.
func cards() -> Array:
var out: Array = Cards.all().duplicate() if show_missing else app.profile.owned_cards()
if filter != "":
out = out.filter(func(c): return filter in c.elements)
if search != "":
var q := search.to_lower()
out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().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") app.sfx.play("click")
say("", true) app.save()
refresh() refresh()
func remove(card: Cards.Card) -> void: ## Füllt mit den wertvollsten eigenen Karten des gewählten Filters bis zum Höchstwert auf.
deck.erase(card.id) func fill_up() -> void:
say("", true) var p: Profile = app.profile
var pool: Array = p.collection.keys()
if filter != "":
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() refresh()
func clear_deck() -> void: func clear_deck() -> void:
deck = [] app.profile.deck = []
refresh()
func save_deck() -> void:
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
if ok[0]:
app.profile.decks[slot].cards = deck.duplicate()
app.save() app.save()
say("%s gespeichert" % app.profile.deck_name(slot), true)
else:
say(ok[1], false)
refresh() refresh()
func open_code() -> void: ## Eigene Karten, die im Stapel zuerst, dann höchste Stufen vorn, dann nach Kosten.
code_box.text = Profile.deck_code(deck) func cards() -> Array:
code_dialog.visible = true
## Übernimmt einen Deck-Code; Karten, die in der Sammlung fehlen, werden gemeldet und weggelassen.
func apply_code(code: String) -> void:
var ids: Array = Profile.parse_deck_code(code)
if ids.is_empty():
say("Ungültiger Code", false)
return
var p: Profile = app.profile var p: Profile = app.profile
var missing := {} var out: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
deck = [] if filter != "":
for cid in ids: out = out.filter(func(c): return filter in c.elements)
var owned: int = p.collection.get(cid, 0) out.sort_custom(func(a, b):
if deck.count(cid) < owned and deck.size() < Game.DECK_SIZE: var ia: bool = a.id in p.deck
deck.append(cid) var ib: bool = b.id in p.deck
else: if ia != ib:
missing[cid] = true return ia
code_dialog.visible = false if p.level(a.id) != p.level(b.id):
if missing.is_empty(): return p.level(a.id) > p.level(b.id)
say("Code übernommen", true) return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
else:
say(tr("Code übernommen, es fehlen: ") + ", ".join(missing.keys().map(func(cid): return Cards.by_id(cid).title())), false)
refresh()
func deck_cards() -> Array:
var out := []
for cid in deck:
out.append(Cards.by_id(cid))
return out return out
func show_preview(card: Cards.Card, entered: bool) -> void:
if entered:
preview.card = card
preview.visible = true
preview.queue_redraw()
elif preview.card == card:
preview.visible = false
func refresh() -> void: func refresh() -> void:
var p: Profile = app.profile var p: Profile = app.profile
grid.fill(cards(), count_label.text = tr("Stapel: %d Karten (%d bis %d)") % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX]
func(c): count_label.add_theme_color_override("font_color", Style.RED if p.deck.size() < Game.DECK_MIN else Style.BLUE)
var owned: int = p.collection.get(c.id, 0) strength_label.text = tr("Stärke %d") % DeckGen.strength(p.deck, p.collection)
return "fehlt" if owned == 0 else "%d/%d im Deck" % [deck.count(c.id), owned], var shown := cards()
func(c): grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
var owned: int = p.collection.get(c.id, 0) var views := grid.views()
return owned == 0 or deck.count(c.id) >= mini(owned, Game.MAX_COPIES)) for i in views.size():
for i in slot_buttons.size(): views[i].dim = not (shown[i].id in p.deck)
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.tooltip_text = tr("Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten")
deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT)
var elems := Game.deck_elements(dc)
elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b))
elems_label.text = tr("Elemente: ") + (", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else "–")
if not dc.is_empty():
elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc)
var ok: Array = Game.validate_deck(dc, p.collection)
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()
+1 -1
View File
@@ -1 +1 @@
uid://yg8pbj56mib5 uid://ctt36od26uyea
+77
View File
@@ -0,0 +1,77 @@
class_name ElementView
extends Control
## Elementzähler einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente da, bei denen
## etwas passiert ist: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je
## offenem Wesen, beide: Effekt aktiv; Neutral hat keinen), die Zahl der ausgespielten Karten
## und ab Game.ELEMENT_COUNT der Bonus +1/+1.
const ROW := 32.0
var own := {} # offene eigene Wesen je Element
var played := {} # ausgespielte Karten 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
played[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
played[e] = int(me.played.get(e, 0)) 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
func bonus(e: String) -> int:
return int(played.get(e, 0)) / Game.ELEMENT_COUNT
## Elemente, bei denen sich etwas getan hat (offene Wesen oder ausgespielte Karten).
func shown() -> Array:
return all().filter(func(e): return int(own.get(e, 0)) > 0 or int(played.get(e, 0)) > 0)
## Der Inhalt als Text (Tests, Protokoll): Element offen/gespielt, dazu Effekt und Bonus.
func text() -> String:
var parts: Array[String] = []
for e in shown():
var s := "%s %d/%d" % [tr(e), int(own.get(e, 0)), int(played.get(e, 0))]
if paired(e):
s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e])
if bonus(e) > 0:
s += " (+%d/+%d)" % [bonus(e), bonus(e)]
parts.append(s)
return " · ".join(parts)
func _draw() -> void:
var y := ROW / 2
for e in shown():
var col: Color = Cards.ELEMENT_COLORS[e]
var active := paired(e) or bonus(e) > 0
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(played.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true)
if bonus(e) > 0:
Style.text(self, "+%d/+%d" % [bonus(e), bonus(e)], 12, Style.GREEN, Vector2(size.x - 6, y), "midright", true)
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
-124
View File
@@ -1,124 +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_watch: 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)
b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch)
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()
## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit.
func _watch() -> void:
net.spectator = true
net.join(ip_box.text.strip_edges())
refresh()
func _set_ai(n: int) -> void:
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 ""])
for id in net.spectators:
lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")])
list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
b_host.disabled = net.connected
b_join.disabled = net.connected
b_watch.disabled = net.connected
ip_box.editable = not net.connected
var humans := ids.size()
b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1)
for i in ai_buttons.size():
ai_buttons[i].disabled = not net.is_host or i == n_ai or humans + i > Game.MAX_OPPONENTS + 1
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
+31 -27
View File
@@ -5,7 +5,6 @@ var profile: Profile
var screen: Control var screen: Control
var save_path: String = Profile.SAVE_PATH var save_path: String = Profile.SAVE_PATH
var sfx: Sfx var sfx: Sfx
var net: Net
func _ready() -> void: func _ready() -> void:
@@ -17,15 +16,9 @@ func _ready() -> void:
add_child(bg) add_child(bg)
sfx = Sfx.new() sfx = Sfx.new()
add_child(sfx) 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 = Profile.load_or_new(save_path)
profile.refresh_daily()
apply_settings() apply_settings()
show_screen(MenuScreen.new()) show_screen(MenuScreen.new() if profile.started() else StartDeckScreen.new())
func apply_settings() -> void: func apply_settings() -> void:
@@ -33,29 +26,43 @@ func apply_settings() -> void:
sfx.set_music_volume(profile.settings.music) sfx.set_music_volume(profile.settings.music)
set_language(profile.settings.language) set_language(profile.settings.language)
Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0 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() theme = Style.make_theme()
## Neues Profil: Sammlung, Decks und Fortschritt werden verworfen. ## Neues Profil: Sammlung und Fortschritt werden verworfen, das Startdeck wird neu gewählt.
func reset_profile() -> void: func reset_profile() -> void:
profile = Profile.starter() profile = Profile.starter()
profile.refresh_daily()
clear_battle_save()
save() save()
apply_settings() apply_settings()
show_screen(StartDeckScreen.new())
func set_language(code: String) -> void: func set_language(code: String) -> void:
TranslationServer.set_locale(code) 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: if screen != null:
if keep:
remove_child(screen)
kept.append(screen)
else:
screen.queue_free() screen.queue_free()
for k in kept:
k.queue_free()
kept.clear()
screen = s screen = s
s.app = self s.app = self
s.set_anchors_preset(Control.PRESET_FULL_RECT) s.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(s) add_child(s)
if Style.animations:
s.modulate.a = 0.0
create_tween().tween_property(s, "modulate:a", 1.0, 0.15)
if pad_mode: if pad_mode:
focus_first_button.call_deferred() focus_first_button.call_deferred()
@@ -84,6 +91,7 @@ func _map_gamepad() -> void:
if not InputMap.action_get_events(action).any(func(e): return e is InputEventJoypadButton): if not InputMap.action_get_events(action).any(func(e): return e is InputEventJoypadButton):
var ev := InputEventJoypadButton.new() var ev := InputEventJoypadButton.new()
ev.button_index = pair[1] ev.button_index = pair[1]
ev.device = -1 # jedes Gamepad
InputMap.action_add_event(action, ev) InputMap.action_add_event(action, ev)
@@ -105,21 +113,17 @@ func _first_button(node: Node) -> Button:
return null 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: func save() -> void:
profile.save(save_path) profile.save(save_path)
## Datei für einen unterbrochenen Kampf (neben dem Spielstand).
func battle_save_path() -> String:
return save_path.get_basename() + "_kampf.json"
func clear_battle_save() -> void:
if FileAccess.file_exists(battle_save_path()):
DirAccess.remove_absolute(ProjectSettings.globalize_path(battle_save_path()))
func _exit_tree() -> void:
if net != null and net.is_inside_tree():
net.leave()
net.queue_free()
+40 -84
View File
@@ -1,106 +1,62 @@
class_name MenuScreen class_name MenuScreen
extends Control extends Control
## Hauptmenü: links Titel und Spielarten, rechts drei Karten der eigenen Sammlung als Schaufenster.
var app var app
var b_single: Button
var b_campaign: Button
var b_hotseat: Button
var b_online: Button
var b_tutorial: Button
var b_challenge: Button
var b_resume: Button
var coins_label: Label var coins_label: Label
var coll_label: Label var coll_label: Label
var deck_label: Label
var daily_label: Label
var warn_label: Label
var notice_label: Label var notice_label: Label
func _ready() -> void: func _ready() -> void:
var col := Style.column(self, 640, 24, 5) var p: Profile = app.profile
Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true) Style.label(self, "Dinger", 76, Style.GOLD, Rect2(80, 64, 600, 90), "left", true)
Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED) Style.label(self, "Ein Kartenspiel: Duell um das Feld", 22, Style.MUTED, Rect2(84, 156, 600, 30), "left")
Style.spacer(col, 8) coins_label = Style.chip(self, "", Rect2(930, 20, 310, 38))
coins_label = Style.clabel(col, "", 20, Style.GOLD) coll_label = Style.label(self, "", 15, Style.MUTED, Rect2(700, 64, 540, 24), "right")
coll_label = Style.clabel(col, "", 16, Style.MUTED) var y := 230
deck_label = Style.clabel(col, "", 16, Style.MUTED) for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())],
daily_label = Style.clabel(col, "", 16, Style.BLUE) ["Kampagne", func(): app.show_screen(CampaignScreen.new())],
Style.spacer(col, 8) ["Deck", func(): app.show_screen(DeckScreen.new())]]:
var grid := GridContainer.new() var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY)
grid.columns = 2 b.add_theme_font_size_override("font_size", 24)
grid.add_theme_constant_override("h_separation", 12) y += 74
grid.add_theme_constant_override("v_separation", 8) y += 18
var center := CenterContainer.new() var x := 80
center.add_child(grid) for entry in [["Booster öffnen", func(): app.show_screen(BoosterScreen.new())],
col.add_child(center) ["Sammlung", func(): app.show_screen(CollectionScreen.new())],
var sz := Vector2(270, 44) ["Einstellungen", func(): app.show_screen(SettingsScreen.new())],
var p0: Profile = app.profile ["Beenden", func(): get_tree().quit()]]:
var part2: bool = p0.tutorial_done and not p0.tutorial2_done Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
if FileAccess.file_exists(app.battle_save_path()): x += 176
b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628")) notice_label = Style.label(self, "", 16, Style.GREEN, Rect2(80, y + 70, 760, 60), "left")
b_tutorial = Style.cbutton(grid, "Anleitung 2: Fallen" if part2 else "Anleitung", sz,
func(): app.show_screen(BattleScreen.guided(2 if part2 else 1)),
Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null)
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
b_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"))
b_challenge = Style.cbutton(grid, "Herausforderung", sz, func(): app.show_screen(ChallengeScreen.new()), Color("285a3c"))
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new()))
Style.cbutton(grid, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new()))
Style.cbutton(grid, "Erfolge", sz, func(): app.show_screen(AchievementsScreen.new()))
Style.cbutton(grid, "Regeln", sz, func(): app.show_screen(RulesScreen.new()))
Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new()))
Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit())
Style.spacer(col, 10)
warn_label = Style.clabel(col, "", 16, Style.RED)
notice_label = Style.clabel(col, "", 16, Style.GREEN)
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
_showcase(p)
refresh() refresh()
## Unterbrochenen Kampf laden; eine unlesbare Datei wird verworfen. ## Drei eigene Karten, die höchsten Stufen zuerst, leicht gefächert.
func _resume() -> void: func _showcase(p: Profile) -> void:
var json := JSON.new() var cards: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
var text := FileAccess.get_file_as_string(app.battle_save_path()) cards.sort_custom(func(a, b):
if json.parse(text) != OK or not (json.data is Dictionary) or not json.data.has("game"): if p.level(a.id) != p.level(b.id):
app.clear_battle_save() return p.level(a.id) > p.level(b.id)
app.show_screen(MenuScreen.new()) return hash(a.id) % 97 < hash(b.id) % 97)
return var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]]
app.show_screen(BattleScreen.resume(json.data)) 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: func refresh() -> void:
var p: Profile = app.profile 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]
b_challenge.disabled = not ok[0]
b_challenge.text = tr("Herausforderung") + (" ✓" if p.challenge_week == Challenge.week() else "")
var total := 0
for cid in p.collection:
total += p.collection[cid]
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust] coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [ coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d") % [
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()] p.collection.size(), Cards.all().size(), p.campaign_stage, Campaign.total()]
var elems := Game.deck_elements(p.deck_cards())
deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else tr("leer")]
var task := p.daily_task()
if p.daily.done:
daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text
else:
daily_label.text = tr("Tagesaufgabe: %s (%d/%d) · +%d Münzen") % [task.text, p.daily.progress, task.goal, task.reward]
warn_label.text = "" if ok[0] else tr("Aktives Deck nicht spielbar: ") + tr(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)
elif ok[0] and not p.tutorial2_done:
warn_label.text = "Anleitung 2 zeigt Mulligan, Fallen und die Feldkombo Dampf."
warn_label.add_theme_color_override("font_color", Style.GOLD)
var notes := p.take_notices() var notes := p.take_notices()
notice_label.text = " · ".join(notes) notice_label.text = " · ".join(notes)
if not notes.is_empty(): if not notes.is_empty():
+26 -81
View File
@@ -1,109 +1,54 @@
class_name OpponentSelectScreen class_name OpponentSelectScreen
extends Control extends Control
## Einzelspieler: Schwierigkeit, Gegnerzahl und Vorschau der Gegner (Element, Stärke). ## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf
## vom gemeinsamen Stapel (siehe Game.start).
var app var app
var rating_label: Label
var tip_label: Label
var diff_buttons := {} var diff_buttons := {}
var previews := {} # n -> Array von Game.Player var info_label: Label
var preview_labels := {}
var b_tip: Button
var b_reroll: Button
var rerolls_left := 3
func _ready() -> void: func _ready() -> void:
var col := Style.column(self, 1000, 30, 10) Style.header(self, "Einzelspieler", func(): app.show_screen(MenuScreen.new()))
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true) var col := Style.column(self, 1000, 170, 12)
Style.clabel(col, "Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben. Du gewinnst, wenn alle anderen besiegt sind." % Game.START_LIFE, 15, Style.MUTED)
Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true) Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true)
var dr := Style.row(col, 10) var dr := Style.row(col, 10)
dr.alignment = BoxContainer.ALIGNMENT_CENTER dr.alignment = BoxContainer.ALIGNMENT_CENTER
for key in ["leicht", "normal", "schwer"]: for key in Profile.DIFFICULTIES:
var k: String = key var k: String = key
diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k)) diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k))
rating_label = Style.clabel(col, "", 14, Style.MUTED) info_label = Style.clabel(col, "", 15, Style.MUTED)
var tr_row := Style.row(col, 10) Style.spacer(col, 24)
tr_row.alignment = BoxContainer.ALIGNMENT_CENTER
tip_label = Style.clabel(tr_row, "", 14, Style.GOLD, "right", false, 520)
b_tip = Style.cbutton(tr_row, "Übernehmen", Vector2(140, 32), take_tip)
Style.spacer(col, 2)
for i in range(1, Game.MAX_OPPONENTS + 1):
var n := i
var r := Style.row(col, 16)
r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(r, "%d Gegner" % n, Vector2(150, 46), func(): start(n), Color("285a3c"))
var info := (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n]
Style.clabel(r, info, 13, Style.MUTED, "left", false, 150)
preview_labels[n] = Style.clabel(r, "", 13, Style.TEXT, "left", false, 600)
preview_labels[n].autowrap_mode = TextServer.AUTOWRAP_WORD
preview_labels[n].custom_minimum_size = Vector2(600, 0)
preview_labels[n].size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.spacer(col, 4)
var br := Style.row(col, 16) var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER br.alignment = BoxContainer.ALIGNMENT_CENTER
b_reroll = Style.cbutton(br, "Neu würfeln", Vector2(190, 40), reroll) var b := Style.cbutton(br, "Starten", Vector2(260, 56), start, Style.PRIMARY)
Style.cbutton(br, "Zurück", Vector2(150, 40), func(): app.show_screen(MenuScreen.new())) b.add_theme_font_size_override("font_size", 22)
refresh(true) refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
func set_difficulty(key: String) -> void: func set_difficulty(key: String) -> void:
app.profile.settings.difficulty = key app.profile.settings.difficulty = key
app.save() app.save()
refresh(true) refresh()
func take_tip() -> void: func start() -> void:
var tip: String = app.profile.suggest_difficulty() app.show_screen(BattleScreen.new())
if tip != "":
set_difficulty(tip)
## Höchstens dreimal neu würfeln, sonst ließen sich schwache Gegner erwürfeln. func refresh() -> void:
func reroll() -> void:
if rerolls_left <= 0:
return
rerolls_left -= 1
refresh(true)
func start(n: int) -> void:
app.show_screen(BattleScreen.against(previews[n]))
## Baut die Gegner für jede Anzahl vor und zeigt Element und Stärke; der Kampf nutzt genau diese.
func refresh(reroll: bool = false) -> void:
var p: Profile = app.profile var p: Profile = app.profile
var d := p.difficulty()
for key in diff_buttons: for key in diff_buttons:
var b: Button = diff_buttons[key] _mark(diff_buttons[key], key == p.settings.difficulty)
var active: bool = key == p.settings.difficulty var d := p.difficulty()
b.add_theme_stylebox_override("normal", Style.box(Color("285a3c") if active else Style.PANEL_LIGHT, 8, Style.GOLD if active else Style.MUTED, 2)) info_label.text = tr("Gegner mit %d Leben · %d Münzen bei Sieg") % [int(d.life), int(round(Profile.REWARD_WIN * float(d.reward)))]
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Color("285a3c") if active else Style.PANEL_LIGHT), 8, Style.GOLD if active else Style.MUTED, 2))
var arche: String = tr("schwache Archetypen") if d.tier == "leicht" else (tr("starke Archetypen") if d.tier == "schwer" else tr("alle Archetypen"))
rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s") % [
p.deck_rating(), p.target_rating(), arche, d.reward, tr("Vorausschau") if d.smart else tr("einfach")]
var tip := p.suggest_difficulty()
tip_label.text = (tr("Tipp: %s") % ((tr("%d Siege in Folge, versuche %s") % [p.records.streak, tr(Profile.DIFFICULTIES[tip].name)]) if p.records.streak >= 3
else (tr("%d Niederlagen in Folge, versuche %s") % [p.records.loss_streak, tr(Profile.DIFFICULTIES[tip].name)]))) if tip != "" else ""
b_tip.visible = tip != ""
if reroll:
var r := Game.make_rng()
for n in range(1, Game.MAX_OPPONENTS + 1):
var opps := []
for i in n:
opps.append(Game.random_opponent(r, i + 1, Game.START_LIFE, p.target_rating(), str(d.tier)))
previews[n] = opps
b_reroll.text = tr("Neu würfeln (%d)") % rerolls_left
b_reroll.disabled = rerolls_left <= 0
for n in previews:
var parts: Array[String] = []
for o in previews[n]:
parts.append("%s (%.1f)" % [o.title, Game.deck_rating(o.deck)])
preview_labels[n].text = ", ".join(parts)
func _input(event: InputEvent) -> void: func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: var base: Color = col if active else Style.PANEL_LIGHT
app.show_screen(MenuScreen.new()) 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
+5 -8
View File
@@ -16,9 +16,8 @@ var speed_label: Label
func _ready() -> void: func _ready() -> void:
var s: Dictionary = app.profile.settings var s: Dictionary = app.profile.settings
var col := Style.column(self, 700, 50, 24) Style.header(self, "Einstellungen", func(): app.back())
Style.clabel(col, "Einstellungen", 36, Style.GOLD, "center", true) var col := Style.column(self, 700, 110, 24)
Style.spacer(col, 30)
var grid := GridContainer.new() var grid := GridContainer.new()
grid.columns = 3 grid.columns = 3
grid.add_theme_constant_override("h_separation", 24) grid.add_theme_constant_override("h_separation", 24)
@@ -76,20 +75,17 @@ func _ready() -> void:
grid.add_child(language) grid.add_child(language)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100) Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.spacer(col, 20) Style.spacer(col, 20)
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
Style.spacer(col, 30) Style.spacer(col, 30)
var r := Style.row(col, 16) var r := Style.row(col, 16)
r.alignment = BoxContainer.ALIGNMENT_CENTER r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828")) Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new())) confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.",
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Decks, Münzen und Fortschritt gehen verloren.",
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED) "Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
_labels() _labels()
func _reset() -> void: func _reset() -> void:
app.reset_profile() app.reset_profile()
app.show_screen(MenuScreen.new())
func _on_big_font(on: bool) -> void: func _on_big_font(on: bool) -> void:
@@ -136,9 +132,10 @@ func _on_speed(v: float) -> void:
func _on_anim(on: bool) -> void: func _on_anim(on: bool) -> void:
app.profile.settings.animations = on app.profile.settings.animations = on
Style.animations = on
app.save() app.save()
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new()) app.back()
+7 -7
View File
@@ -2,25 +2,25 @@ class_name Sfx
extends Node extends Node
## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig). ## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig).
static var inst: Sfx
var streams := {} var streams := {}
var players: Array[AudioStreamPlayer] = [] 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_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_task := -1 # die Musik wird im Hintergrund berechnet (spart ~150 ms beim Start)
var music_box := {} var music_box := {}
func _ready() -> void: func _ready() -> void:
inst = self
streams["click"] = _tone(880, 0.05, "sine") streams["click"] = _tone(880, 0.05, "sine")
streams["play"] = _tone(440, 0.14, "sine", 660) streams["play"] = _tone(440, 0.14, "sine", 660)
streams["attack"] = _tone(220, 0.16, "square", 90) streams["attack"] = _tone(220, 0.16, "square", 90)
streams["damage"] = _tone(140, 0.12, "square") streams["damage"] = _tone(140, 0.12, "square")
streams["heal"] = _tone(523, 0.22, "sine", 784) streams["heal"] = _tone(523, 0.22, "sine", 784)
streams["combo"] = _tone(660, 0.2, "sine", 1100) 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["win"] = _tone(400, 0.6, "sine", 1200)
streams["lose"] = _tone(320, 0.7, "square", 90) streams["lose"] = _tone(320, 0.7, "square", 90)
for i in 4: for i in 4:
@@ -79,7 +79,7 @@ func _music() -> AudioStreamWAV:
func set_music_volume(v: float) -> void: func set_music_volume(v: float) -> void:
music_volume = clampf(v, 0.0, 1.0) music_volume = clampf(v, 0.0, 1.0)
if music_volume <= 0.0: if music_volume <= 0.0 or muted:
music_player.stop() music_player.stop()
else: else:
music_player.volume_db = linear_to_db(music_volume * 0.6) music_player.volume_db = linear_to_db(music_volume * 0.6)
@@ -92,7 +92,7 @@ func set_volume(v: float) -> void:
func play(kind: String) -> 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 return
for p in players: for p in players:
if not p.playing: if not p.playing:
+37
View File
@@ -0,0 +1,37 @@
class_name StartDeckScreen
extends Control
## Erster Start: eines von vier Startdecks wählen, je eines mit einem Element im Mittelpunkt
## (25 Karten davon, je fünf der anderen). Danach geht es ins Menü.
var app
func _ready() -> void:
Style.label(self, tr("Wähle dein Startdeck"), 40, Style.GOLD, Rect2(0, 60, 1280, 60), "center", true)
Style.label(self, tr("25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest."), 17, Style.MUTED, Rect2(140, 124, 1000, 30), "center")
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var x := 70 + i * 290
var col: Color = Cards.ELEMENT_COLORS[e]
Style.panel(self, Rect2(x, 190, 260, 470), Style.darker(col, 0.75), 12, col, 2)
var ids := DeckGen.start_deck(e)
var sample: Array = ids.filter(func(id): return Cards.by_id(id).elements[0] == e)
sample.sort_custom(func(a, b): return Cards.by_id(a).cost > Cards.by_id(b).cost)
for k in 3:
var c: Cards.Card = Cards.by_id(sample[k * 7])
var v := CardView.new(c, Vector2(120, 152), false, "Common")
v.position = Vector2(x + 20 + k * 40, 210 + k * 14)
v.rotation_degrees = -6 + k * 6
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
Style.label(self, tr(e), 28, Style.TEXT, Rect2(x, 400, 260, 40), "center", true)
Style.label(self, tr(Cards.KEYWORD_TEXT[Cards.ELEMENT_KEYWORD[e]]), 15, Style.MUTED, Rect2(x + 16, 446, 228, 60), "center")
var b := Style.button(self, tr("%s wählen") % tr(e), Rect2(x + 30, 590, 200, 50), choose.bind(e), Style.PRIMARY)
b.add_theme_font_size_override("font_size", 18)
func choose(element: String) -> void:
app.profile.choose_start(element)
app.save()
app.sfx.play("combo")
app.show_screen(MenuScreen.new())
+1
View File
@@ -0,0 +1 @@
uid://bxl6isdrgqb2b
+382 -95
View File
@@ -2,12 +2,9 @@ class_name Style
## Farben, Schriften, Theme und Zeichenhilfen. ## Farben, Schriften, Theme und Zeichenhilfen.
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf") const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
static var font_scale := 1.0 # Einstellung „Große Schrift“
static func fs(size: int) -> int:
return int(round(size * font_scale))
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf") const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
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 BG := Color("1a1c24")
const PANEL := Color("282b36") const PANEL := Color("282b36")
@@ -20,6 +17,17 @@ const RED := Color("e65555")
const BLUE := Color("6eaaf0") const BLUE := Color("6eaaf0")
const YELLOW := Color("ffe65a") const YELLOW := Color("ffe65a")
const ORANGE := Color("ff963c") 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“.
static func fs(size: int) -> int:
return int(round(size * font_scale))
static func darker(c: Color, f: float = 0.65) -> Color: static func darker(c: Color, f: float = 0.65) -> Color:
@@ -78,7 +86,11 @@ static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8,
var p := Panel.new() var p := Panel.new()
p.position = rect.position p.position = rect.position
p.size = rect.size 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 p.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(p) parent.add_child(p)
return p return p
@@ -113,10 +125,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("normal", box(color, 8, MUTED, 2))
b.add_theme_stylebox_override("hover", box(lighter(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)) b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2))
juice(b)
parent.add_child(b) parent.add_child(b)
return 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). ## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme).
static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer: static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer:
var v := VBoxContainer.new() var v := VBoxContainer.new()
@@ -228,6 +267,33 @@ static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vec
ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color) 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: static func fit(s: String, bold: bool, size: int, max_w: float) -> String:
var font: Font = FONT_BOLD if bold else FONT 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: while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3:
@@ -244,110 +310,326 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius
ci.draw_style_box(sb, rect) ci.draw_style_box(sb, rect)
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void: # --- Seltenheit als Aussehen ------------------------------------------------------
var a: Color = Cards.ELEMENT_COLORS[elements[0]] # Common matt; Rare silbern glänzender Name; Super Rare Holo-Bild (wandernder Regenbogen im
if elements.size() == 1: # Bildkreis); Ultra Rare goldener Name und Holo-Bild; Ultimate Rare goldener Name, goldenes
ci.draw_style_box(box(a, radius), rect) # Bild, glänzende Werte, Schimmer über die ganze Karte, Funkeln und schillernder Rahmen.
elif elements[0] == elements[1]: # Rahmen und Schein tragen ab Rare die Stufenfarbe.
# Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen
ci.draw_style_box(box(a, radius), rect) ## Ab Rare bewegt sich der Glanz; dann zeichnet die Karte jeden Frame neu.
border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3)) 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: else:
var b: Color = Cards.ELEMENT_COLORS[elements[1]] text(ci, s, fsize, rarity_name_color(rar), Vector2.ZERO, "topleft", true)
var half := floorf(rect.size.x / 2) ci.draw_set_transform(Vector2.ZERO)
var left := StyleBoxFlat.new()
left.bg_color = a
left.corner_radius_top_left = radius
left.corner_radius_bottom_left = radius
var right := StyleBoxFlat.new()
right.bg_color = b
right.corner_radius_top_right = radius
right.corner_radius_bottom_right = radius
ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y)))
ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y)))
static func rules_text() -> String: ## Metallisch glänzender fetter Text: dunkler Schatten, Grundfarbe, darüber Buchstabe für
var lines := [ ## Buchstabe ein heller Streifen, der mit Animation über den Text läuft.
"Ziel: Besiege alle anderen. Du verlierst, wenn dein Leben auf 0 fällt.", static func shiny_text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String, seed: int) -> void:
"Du spielst gegen 1 bis 5 Magier, jeder gegen jeden, alle nach denselben Regeln:", var size := fs(size_in)
"%d Leben, pro Zug 1 Energie mehr (höchstens 10) und 1 Karte. Die Zugreihenfolge wird ausgelost." % Game.START_LIFE, var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
"Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug 1 Energie extra.", var x := pos.x
"Fallen liegen verdeckt (höchstens 2) und lösen aus, wenn dich jemand angreift oder ein Wesen ausspielt.", var y := pos.y
"", match anchor:
"Karten kosten Energie. Wesen kommen aufs Feld (max. 5) und greifen ab dem nächsten Zug an.", "center":
"Zauber wirken sofort. Ein Wesen greift ein gegnerisches Wesen oder direkt den Gegner an;", x -= dim.x / 2
"beide Seiten verursachen ihren Angriffswert als Schaden.", y -= dim.y / 2
"", "midtop":
"Elemente: Feuer (Schaden), Wasser (Kontrolle), Erde (Zähigkeit), Luft (Tempo).", x -= dim.x / 2
"Kombos verbinden zwei Elemente, auch das gleiche doppelt (Feuer+Feuer = Inferno).", "midleft":
"Kombokarten sind seltener und stärker, Dreifach-Kombos sind mythisch. Ein Deck darf höchstens 2 Elemente enthalten.", y -= dim.y / 2
Cards.RESONANCE_TEXT, var phase := (seed % 100) / 100.0
"Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):", if animations:
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an das stärkste gegnerische Wesen, Blitz 2 Schaden", phase = fmod(Time.get_ticks_msec() / 1800.0 + phase, 1.0)
" an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen +1/+1.", 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))
"Schlüsselwörter:", var cx := x
] for i in s.length():
for k in Cards.KEYWORDS: var ch := s[i]
lines.append(" %s: %s" % [k, Cards.KEYWORD_HELP[k]]) var w := FONT_BOLD.get_string_size(ch, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
lines.append("") 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
lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES]) var k := clampf(1.0 - d * 2.4, 0.0, 1.0)
lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.") ci.draw_string(FONT_BOLD, Vector2(cx, at.y), ch, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color.lerp(Color.WHITE, 0.1 + 0.75 * k))
lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.") cx += w
lines.append("Elementbooster: 5 Karten eines Elements (und seiner Kombos) für %d Münzen." % Profile.ELEMENT_BOOSTER_COST)
lines.append("Deck-Code: im Deckbau unter „Code“ kopieren und mit anderen teilen.")
lines.append("Ein unterbrochener Einzelspieler-Kampf lässt sich im Menü fortsetzen.")
lines.append("")
lines.append("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("Netzwerkspiel: Fällt der Host aus, übernimmt ein Mitspieler; Zuschauer sehen die Partie ohne Hände.")
lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.")
lines.append("Deckstärke: Seltenheit, Schlüsselwörter und Effekte ergeben je Karte einen Wert. Gegnerdecks werden auf deine")
lines.append(" Stärke gebaut (Leicht 0,9-fach, Normal 1-fach, Schwer 1,2-fach mit mehr Münzen). Die Kampagne liegt nie darunter.")
lines.append("Kampagnen-Bosse haben ein Merkmal (★), zum Beispiel stärkere Feuerwesen. Netzwerkspiel im lokalen Netz.")
lines.append("Steuerung: Linksklick spielt Karten und wählt Ziele, Rechtsklick oder Esc bricht ab, Leertaste beendet den Zug, 1 bis 9 spielen Handkarten.")
return "\n".join(lines)
# --- Kartensymbole (prozedural, je Element und Kartenart) -------------------------- ## 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 variant := absi(hash(card.id)) % 3
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38) var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
col.a = 0.95 col.a = alpha
if card.is_trap(): if tint.a > 0:
# Falle: Kreuz im Kreis col = tint
ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08))
var d := size * 0.28
ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1))
ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1))
return
if card.kind == "Zauber":
_star(ci, center, size * 0.55, Color(col, 0.35))
var elems: Array = card.elements var elems: Array = card.elements
if elems.size() >= 2 and elems[0] != elems[1]: if elems.size() == 1:
_element_glyph(ci, elems[0], center, size, variant, col)
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) _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) var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
col2.a = 0.95 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) _element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
else:
_element_glyph(ci, elems[0], center, size, variant, col)
if elems.size() >= 2:
ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2)
if elems.size() >= 3: if elems.size() >= 3:
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2) ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void:
var pts := PackedVector2Array()
for i in 8:
var a := TAU * i / 8 - PI / 2
var rr := r if i % 2 == 0 else r * 0.45
pts.append(c + Vector2(cos(a), sin(a)) * rr)
ci.draw_colored_polygon(pts, col)
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void: static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
var h := size / 2 var h := size / 2
match element: match element:
@@ -375,6 +657,11 @@ static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: fl
for i in 2 + variant: for i in 2 + variant:
var r := h * (1.0 - i * 0.28) 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)) 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: static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
@@ -385,7 +672,7 @@ static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
ci.draw_colored_polygon(pts, col) ci.draw_colored_polygon(pts, col)
var inner := PackedVector2Array([ 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)]) 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. ## Dezenter Hintergrund: Farbverlauf und schräge Linien.
-152
View File
@@ -1,152 +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]
var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14)
search_box = LineEdit.new()
search_box.position = Vector2(424, 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_action_pressed("ui_cancel") 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.title().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(tr("%s zerlegt (+%d Staub)") % [selected.title(), 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(tr("%s hergestellt") % selected.title() + (" · " + " · ".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 · Wert %.1f\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [
selected.combo(), selected.rarity, Cards.card_value(selected), owned, st.played, st.games, st.wins]
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity]
b_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 . "$@"
+6 -6
View File
@@ -1,5 +1,5 @@
extends Node extends Node
## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen (5 Gegner). ## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen.
var main: Node var main: Node
var max_dt := 0.0 var max_dt := 0.0
@@ -14,19 +14,19 @@ func _ready() -> void:
add_child(main) add_child(main)
await get_tree().process_frame await get_tree().process_frame
await get_tree().process_frame await get_tree().process_frame
var scr: BattleScreen = BattleScreen.new(5) var scr: BattleScreen = BattleScreen.new()
main.show_screen(scr) main.show_screen(scr)
await get_tree().process_frame await get_tree().process_frame
await get_tree().process_frame await get_tree().process_frame
var h: Game.Player = scr.game.human var h: Game.Player = scr.game.human
while h.hand.size() < 8 and not h.deck.is_empty(): h.energy = 10
h.hand.pop_back()
if scr.game.current() == h:
scr.game.draw(h) scr.game.draw(h)
var t0 := Time.get_ticks_usec() var t0 := Time.get_ticks_usec()
for i in 30: for i in 30:
scr.refresh() scr.refresh()
print("refresh() mit 5 Gegnern und 8 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30)) print("refresh() mit 5 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
scr.mulligan.visible = false
h.mulligan_done = true
scr.ai_delay = 0.2 scr.ai_delay = 0.2
measuring = true measuring = true
var start := Time.get_ticks_msec() var start := Time.get_ticks_msec()
-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
-101
View File
@@ -1,101 +0,0 @@
extends Node
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge; mit --host eröffnet er
## selbst und startet, sobald ein zweiter Mensch am Tisch sitzt (ohne KI).
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
## godot --path . tests/net_bot.tscn -- --host --name=BotHost
var net: Net
var seat := -1
var game: Game
var t := 0.0
var hosting := false
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)
if a == "--host":
hosting = true
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
if hosting:
net.broadcast() # erster Zustand für alle, wie es sonst der Kampfbildschirm tut
_act_host.call_deferred())
net.snapshot_received.connect(_on_snapshot)
net.ended.connect(func(_r): get_tree().quit())
await get_tree().process_frame
if hosting:
net.host()
net.lobby_changed.connect(func():
if net.game == null and net.names.size() >= 2:
net.start_battle(0))
net.action_applied.connect(func():
net.broadcast()
_act_host.call_deferred())
else:
net.join(ip)
func _process(delta: float) -> void:
t += delta
if hosting and net.game != null:
net.tick()
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
_decide(game.players[seat])
func _act_host() -> void:
if not hosting or net.game == null or net.game.winner != "" or seat < 0 or net.game.current_idx != seat:
return
game = net.game
_decide(game.players[seat])
## Einfache Züge mit kurzer Bedenkzeit: Mulligan überspringen, erstes spielbares Wesen ohne Ziel,
## dann angreifen, sonst Zug beenden.
func _decide(me: Game.Player) -> void:
var g := game
await get_tree().create_timer(0.25).timeout
if game != g or (hosting and (net.game == null or net.game.current_idx != seat)):
return # inzwischen ein neuer Zustand
if not me.mulligan_done and me.turns == 1:
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")
+7 -15
View File
@@ -14,37 +14,29 @@ func _initialize() -> void:
print("Kartenpool laden: %.1f ms" % t) print("Kartenpool laden: %.1f ms" % t)
var r := RandomNumberGenerator.new() var r := RandomNumberGenerator.new()
r.seed = 3 r.seed = 3
var starter := Profile.starter().deck_cards() print("Stapel würfeln: %.2f ms" % ms(func(): DeckGen.random_deck(r), 20))
print("Gegnerdeck (Zielstärke): %.1f ms" % ms(func(): DeckGen.build_opponent_deck_rated(r, ["Feuer", "Erde"], 1.5), 10)) print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20))
print("Vorschau 1..5 Gegner: %.1f ms" % ms(func(): for n in [1]:
for n in range(1, 6): var g := Game.new_battle(r)
for i in n:
Game.random_opponent(r, i + 1, Game.START_LIFE, 1.3, "")))
for n in [1, 3, 5]:
var g := Game.new_battle(starter, n, r, {"target_rating": 1.5})
# ein volles Brett für alle, damit die Entscheidung teuer wird # ein volles Brett für alle, damit die Entscheidung teuer wird
var uid := 5000 var uid := 5000
for p in g.players: for p in g.players:
p.mulligan_done = true
for k in 4: for k in 4:
var c := Game.Creature.new(Cards.by_id("f_wolf"), p, uid) var c := Game.Creature.new(Cards.by_id("f_3_2"), p, uid)
uid += 1 uid += 1
c.ready = true c.ready = true
c.attacks_left = 1
p.board.append(c) p.board.append(c)
var h: Game.Player = g.players[0] var h: Game.Player = g.players[0]
h.energy = 10 h.energy = 10
h.max_energy = 10 h.max_energy = 10
for k in 6: for k in 6:
h.hand.append(Cards.by_id("f_funke")) h.hand.append(Cards.by_id("f_4_3"))
AI.time_budget_ms = 100000 AI.time_budget_ms = 100000
var t_full := ms(func(): AI.choose_action(g, h)) var t_full := ms(func(): AI.choose_action(g, h))
AI.time_budget_ms = 150 AI.time_budget_ms = 150
var t_budget := ms(func(): AI.choose_action(g, h)) var t_budget := ms(func(): AI.choose_action(g, h))
var cands := AI.candidate_actions(g, h).size() 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("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget])
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(starter, 5, r).clone(), 20)) print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(r).clone(), 20))
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music())) print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
print("Deckstärke berechnen: %.3f ms" % ms(func(): Game.deck_rating(starter), 100))
print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(starter, 5, r).snapshot(0), 10))
quit() quit()
+25 -18
View File
@@ -1,6 +1,6 @@
extends SceneTree extends SceneTree
## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln ## 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 ## godot --headless --path . -s tests/simulate.gd
@@ -8,31 +8,38 @@ func _initialize() -> void:
var games := 100 var games := 100
var r := RandomNumberGenerator.new() var r := RandomNumberGenerator.new()
r.seed = 0 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() var t0 := Time.get_ticks_msec()
print("Alle gierig (Erwartung etwa 1/(Gegner+1)):") print("Beide gierig (Erwartung etwa 50 %):")
for n in range(1, Game.MAX_OPPONENTS + 1): _run(games, r, true, true)
_run("Zufallsdeck", random_deck, n, games, r, true, true)
print("Du mit Vorausschau, Gegner gierig:") print("Du mit Vorausschau, Gegner gierig:")
for n in [1, 2, 3]: _run(games / 2, r, false, true)
_run("Zufallsdeck", random_deck, n, games / 2, r, false, true) print("Beide mit Vorausschau:")
print("Alle mit Vorausschau:") _run(games / 2, r, false, false)
_run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false) print("Kampagne (du mit Vorausschau), je Stufe:")
_run("Zufallsdeck", random_deck, 2, games / 4, r, false, false) for stage in [0, 4, 9, 14, 19, 24, 29]:
_campaign(stage, games / 4, r)
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0)) print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
quit() quit()
func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNumberGenerator, func _campaign(stage: int, games: int, r: RandomNumberGenerator) -> void:
human_greedy: bool, opp_greedy: bool) -> void: var wins := 0
for i in games:
var g := Game.new_battle_with(Campaign.opponent(stage), r)
while g.winner == "":
AI.take_turn(g, g.current(), false)
assert(g.turn < 300, "Spiel endet nicht")
if g.winner == "human":
wins += 1
var st := Campaign.stage(stage)
print("Stufe %2d %-12s %2d Leben, %d Merkmale: Siege %3d%%" % [stage + 1, st.name, int(st.life), st.perks.size(), wins * 100 / games])
func _run(games: int, r: RandomNumberGenerator, human_greedy: bool, opp_greedy: bool) -> void:
var wins := 0 var wins := 0
var turns := 0 var turns := 0
for i in games: 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 == "": while g.winner == "":
var p := g.current() var p := g.current()
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy) 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": if g.winner == "human":
wins += 1 wins += 1
turns += g.turn 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])
-41
View File
@@ -1,41 +0,0 @@
extends SceneTree
## Archetyp-Stärke: jede Elementkombination gegen zufällige andere, gleiche Zielstärke, beide KI.
func archetypes() -> Array:
var out := []
for e in Cards.ELEMENTS:
out.append([e])
for i in Cards.ELEMENTS.size():
for j in range(i + 1, Cards.ELEMENTS.size()):
out.append([Cards.ELEMENTS[i], Cards.ELEMENTS[j]])
return out
func _initialize() -> void:
var r := RandomNumberGenerator.new()
r.seed = 21
var arts := archetypes()
var n := int(OS.get_environment("N")) if OS.get_environment("N") != "" else 60
var results := {}
for a in arts:
var wins := 0
for i in n:
var b: Array = arts[r.randi_range(0, arts.size() - 1)]
var deck_a := Game.build_opponent_deck_rated(r, a, 1.6)
var deck_b := Game.build_opponent_deck_rated(r, b, 1.6)
var opp := Game.Player.new("Gegner 1", deck_b, false, Game.START_LIFE, "x")
var g := Game.new_battle_with(deck_a, [opp], r)
var guard := 0
while g.winner == "" and guard < 300:
AI.take_turn(g, g.current())
guard += 1
if g.winner == "human":
wins += 1
results["+".join(a)] = wins * 100 / n
print("%-14s %3d%%" % ["+".join(a), wins * 100 / n])
var f := FileAccess.open(DeckGen.ARCHETYPE_PATH, FileAccess.WRITE)
f.store_string(JSON.stringify(results, "\t"))
f.close()
print("nach %s geschrieben" % DeckGen.ARCHETYPE_PATH)
quit()
-1
View File
@@ -1 +0,0 @@
uid://d4m21t7od3kdm
+689 -1117
View File
File diff suppressed because it is too large. Load diff
+514 -692
View File
File diff suppressed because it is too large. Load diff
+100 -142
View File
@@ -1,41 +1,26 @@
keys,en keys,en
Dinger,Dinger 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 Einzelspieler,Single player
Kampagne,Campaign Kampagne,Campaign
Lokal zu mehreren,Local multiplayer Deck,Deck
Netzwerkspiel,Network game
Booster öffnen,Open boosters Booster öffnen,Open boosters
Deck bauen,Build deck
Werkstatt,Workshop
Erfolge,Achievements
Regeln,Rules
Einstellungen,Settings Einstellungen,Settings
Anleitung,Tutorial
Beenden,Quit Beenden,Quit
Zurück,Back Zurück,Back
Weiter,Continue
Start,Start Start,Start
Speichern,Save
Leeren,Clear
Suche,Search Suche,Search
Fehlende,Missing
Alle,All Alle,All
Aktiv setzen,Set active
Auto-Ausfüllen,Auto-fill
Umbenennen,Rename
Zug beenden,End turn Zug beenden,End turn
Aufgeben,Surrender Aufgeben,Surrender
Weiterspielen,Keep playing Weiterspielen,Keep playing
"Ja, aufgeben","Yes, surrender" "Ja, aufgeben","Yes, surrender"
Wirklich aufgeben?,Really surrender? Wirklich aufgeben?,Really surrender?
Der Kampf endet ohne Münzen.,The battle ends without coins.
Zurück zum Menü,Back to menu Zurück zum Menü,Back to menu
Lupe,Zoom Lupe,Zoom
Schließen,Close Schließen,Close
Held als Ziel,Target hero Held als Ziel,Target hero
Verlauf,Log Verlauf,Log
Karte überfahren für Details,Hover a card for details
Lautstärke,Volume Lautstärke,Volume
Musik,Music Musik,Music
KI-Tempo,AI speed KI-Tempo,AI speed
@@ -45,145 +30,97 @@ Booster,Boosters
Booster kaufen (100 Münzen),Buy booster (100 coins) Booster kaufen (100 Münzen),Buy booster (100 coins)
Alle aufdecken,Reveal all Alle aufdecken,Reveal all
Zerlegen,Dismantle Zerlegen,Dismantle
Herstellen,Craft
Kämpfen,Fight Kämpfen,Fight
Wiederholen,Replay Wiederholen,Replay
Gesperrt,Locked Gesperrt,Locked
Spiel eröffnen,Host game Spiel eröffnen,Host game
Beitreten,Join 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 Du bist dran,Your turn
Herausforderung,Challenge
Antreten,Enter
Statistik exportieren,Export statistics
Gegnerzüge überspringen,Skip AI turns
Rückgängig,Undo Rückgängig,Undo
Kapitel 1,Chapter 1 Kapitel 1,Chapter 1
Kapitel 2,Chapter 2 Kapitel 2,Chapter 2
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d" "Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
"Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d","Collection: %d of %d cards (%d total) · Campaign: %d/%d"
Aktives Deck: %s (%s),Active deck: %s (%s)
leer,empty
Tagesaufgabe erledigt: %s ✓,Daily task done: %s ✓
Tagesaufgabe: %s (%d/%d) · +%d Münzen,Daily task: %s (%d/%d) · +%d coins
"Aktives Deck nicht spielbar: ","Active deck not playable: "
Zug %d · Du bist dran,Turn %d · Your turn
Zug %d · %s ist dran …,Turn %d · %s is playing …
Wähle ein Ziel,Choose a target
Wähle ein Angriffsziel,Choose an attack target Wähle ein Angriffsziel,Choose an attack target
Abgebrochen,Cancelled Abgebrochen,Cancelled
%s kann nicht angreifen,%s cannot attack %s kann nicht angreifen,%s cannot attack
Nicht dein Zug,Not your turn Nicht dein Zug,Not your turn
Nicht genug Energie,Not enough energy Nicht auf der Hand,Not in your hand
Dein Feld ist voll,Your board is full Dein Feld ist voll,Your board is full
Kein gültiges Ziel,No valid target Kein gültiges Ziel,No valid target
Ungültiges Ziel,Invalid target Ungültiges Ziel,Invalid target
Höchstens 2 Fallen,At most 2 traps
Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first
Sieg!,Victory! Sieg!,Victory!
Niederlage,Defeat Niederlage,Defeat
+%d Münzen,+%d coins +%d Münzen,+%d coins
"Deck braucht genau %d Karten (%d)","Deck needs exactly %d cards (%d)"
Höchstens %dx dieselbe Karte,At most %dx the same card
Karte nicht oft genug in der Sammlung,Not enough copies in your collection
Höchstens %d Elemente im Deck,At most %d elements in a deck
Schwierigkeit,Difficulty Schwierigkeit,Difficulty
Leicht,Easy Leicht,Easy
Normal,Normal Normal,Normal
Schwer,Hard Schwer,Hard
"Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s","Your deck strength %.1f · Opponent decks %.1f · %s · Coins x%.2f · AI: %s"
schwache Archetypen,weaker archetypes
starke Archetypen,stronger archetypes
alle Archetypen,all archetypes
Vorausschau,lookahead
einfach,simple
Stärke %.1f,Strength %.1f
"Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten","Deck strength: average of rarity, keywords and effects"
Tipp: %s,Tip: %s
Übernehmen,Apply
Neu würfeln,Reroll
Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s)
Anleitung 2: Fallen,Tutorial 2: Traps
Neu würfeln (%d),Reroll (%d)
Profil zurücksetzen,Reset profile Profil zurücksetzen,Reset profile
Große Schrift,Large text Große Schrift,Large text
Verwerfen,Discard
Weiter bearbeiten,Keep editing
Ungespeicherte Änderungen,Unsaved changes
"Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick.",Welcome! Your hand is at the bottom. Every card costs energy (◆); you have 1 on your first turn. Click a creature that costs 1 to play it.
Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste.,Creatures attack from the next turn on. End your turn with the button on the right or the space bar.
Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen.,"Green frames mark creatures ready to attack. Click a creature, then Fenn or one of his creatures. Guardians must fall first."
Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen.,Resonance: creatures sharing an element boost each other's attack. Play a second fire or earth creature.
Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an das stärkste gegnerische Wesen.,Field combo: a creature next to one of yours with a different element triggers an effect. Lava deals 1 damage to the strongest enemy creature.
"Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab.",Spells resolve immediately. Spark or Fireball can hit Fenn directly. Pick targets by clicking; right-click cancels.
Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg!,Fenn loses at 0 life. Swap weak opening hands on your first turn (mulligan). Good luck!
"Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst.",Mulligan: on your first turn you may put cards back once and redraw. Decide in the dialog what to keep.
"Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast.",Traps lie face down and trigger during enemy turns. Set the Frost Trap once you have 2 energy.
"Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein.","End your turn and wait. If Mara attacks you or a creature, the attack fizzles and her creature freezes."
Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander.,Field combo Steam: a water creature next to a fire creature freezes the strongest enemy creature. Play both in sequence.
"Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara!",Undo (U) takes back a creature you just played as long as nothing was triggered. Good luck against Mara!
"%d Siege in Folge, versuche %s","%d wins in a row, try %s"
"%d Niederlagen in Folge, versuche %s","%d losses in a row, try %s"
Duell,Duel
%d am Tisch,%d at the table
%d Spieler am Tisch,%d players at the table
%d Münzen,%d coins %d Münzen,%d coins
Stärke %.1f · %d Münzen,Strength %.1f · %d coins
Abbrechen,Cancel Abbrechen,Cancel
"Ja, zurücksetzen","Yes, reset" "Ja, zurücksetzen","Yes, reset"
"Speichern und zurück","Save and go back"
"Kampf fortsetzen","Resume battle"
Aufgeben beendet den Kampf ohne Münzen.,Surrendering ends the battle without coins.
Speichern hebt ihn für später auf.,Saving keeps it for later.
%s Schaden an den Angreifer,%s damage to the attacker
%s Schaden an dieses Wesen,%s damage to that creature
Der Angreifer wird eingefroren,The attacker is frozen
Es wird eingefroren,It is frozen
Es geht zurück auf die Hand,It returns to its owner's hand
Der Angriff verpufft,The attack fizzles
Beschwöre einen %s,Summon a %s
%s Schaden an ein gegnerisches Wesen,%s damage to an enemy creature
%s Schaden an ein Ziel,%s damage to a target
%s Schaden an alle gegnerischen Wesen,%s damage to all enemy creatures
%s Schaden an alle Gegner,%s damage to all opponents
%s Schaden an alle Gegner und ihre Wesen,%s damage to all opponents and their creatures
Heile dich um %s,Heal yourself for %s
Ziehe 1 Karte,Draw 1 card
Ziehe %s Karten,Draw %s cards
Friere ein gegnerisches Wesen ein,Freeze an enemy creature
Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature
Friere alle gegnerischen Wesen ein,Freeze all enemy creatures
Ein gegnerisches Wesen zurück auf die Hand,Return an enemy creature to its owner's hand
Alle gegnerischen Wesen zurück auf die Hand,Return all enemy creatures to their owners' hands
Ein befreundetes Wesen erhält %s,A friendly creature gets %s
Alle befreundeten Wesen erhalten %s,All friendly creatures get %s
Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield
Erhält Schild,Gets Shield
Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield
Falle. %s: %s,Trap. %s: %s
Beim Ausspielen: %s,On play: %s
Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift,When an enemy creature attacks you or one of your creatures
Wenn ein Gegner ein Wesen ausspielt,When an opponent plays a creature
Wächter,Guardian
Flink,Swift Flink,Swift
Schild,Shield
Doppelschlag,Double Strike Doppelschlag,Double Strike
Wesen,Creature Common,Common
Zauber,Spell Rare,Rare
Falle,Trap Super Rare,Super Rare
gewöhnlich,common Ultra Rare,Ultra Rare
selten,rare Ultimate Rare,Ultimate Rare
episch,epic
legendär,legendary
mythisch,mythic
Feuer,Fire Feuer,Fire
Wasser,Water Wasser,Water
Erde,Earth Erde,Earth
Luft,Air 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,Collection: %d of %d cards · Campaign: %d/%d
%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
Gegner mit %d Leben · %d Münzen bei Sieg,Opponent with %d life · %d coins for a win
Start gegen die KI,Start against the AI
Ziehen,Draw
Verdeckt legen ◆ %d,Set face-down ◆ %d
Aufdecken,Reveal
verdeckt,face-down
Aufgedeckt!,Revealed!
Elementbonus +%d,Element bonus +%d
Merkmal +%d,Perk +%d
Erst die Wesen,Creatures first
%s liegt verdeckt,%s is face-down
%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
Elementbonus zählt doppelt,Element bonus counts twice
Beginnt mit 5 Leben mehr,Starts with 5 extra life
Greift zweimal je Zug,Attacks twice per turn
Verdeckt,Face-down
Inferno,Inferno Inferno,Inferno
Tiefsee,Deep Sea Tiefsee,Deep Sea
Berg,Mountain Berg,Mountain
@@ -194,26 +131,47 @@ Blitz,Lightning
Schlamm,Mud Schlamm,Mud
Sturm,Storm Sturm,Storm
Sand,Sand Sand,Sand
Sonne,Sun Feuerwesen haben +1 Angriff,Fire creatures have +1 attack
Ozean,Ocean Wesen kommen mit +1 Leben,Creatures enter with +1 health
Gebirge,Mountains Bekommt jeden Zug 1 Mana mehr,Gets 1 extra mana each turn
Himmel,Sky Ziehen kostet kein Mana,Drawing costs no mana
"Code übernommen, es fehlen: ","Code applied, missing: " Spott,Taunt
Elemente: ,Elements: Regeneration,Regeneration
%s zerlegt (+%d Staub),%s dismantled (+%d dust) 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 %s hergestellt,%s crafted
%s ist jetzt im Deck %s,%s is now in deck %s Neu!,New!
Deck-Code,Deck code Auffüllen,Fill up
In Zwischenablage,To clipboard Leeren,Clear
Aus Zwischenablage,From clipboard Dein Stapel ist leer,Your deck is empty
Code,Code Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die wertvollsten Karten des Filters.,Click adds a card to the deck or removes it. Fill up takes the most valuable cards of the filter.
Ungültiger Code,Invalid code Wähle dein Startdeck,Choose your starting deck
Code übernommen,Code applied "25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest.","25 cards of the element plus five of each other one. Boosters and the workshop bring the rest later."
Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.,Copy to share or paste a code. Cards you do not own are skipped. %s wählen,Choose %s
Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos,Element booster (%d coins): only cards of that element and its combos Optionen,Options
"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks.","Chapter 3: three perks, full tables and the strongest decks." Hauptmenü,Main menu
Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!,All three chapters completed. The Archmage is defeated for good! Neutral,Neutral
Kapitel %d,Chapter %d Nie im Vorteil oder Nachteil,Never at an advantage or disadvantage
Zuschauen,Watch Veteran,Veteran
Zuschauer,Spectator Feuerritter,Fire Knight
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden" 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
30 Booster kaufen (100 Münzen) Buy booster (100 coins)
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
Herausforderung Challenge
Antreten Enter
Statistik exportieren Export statistics
Gegnerzüge überspringen Skip AI turns
39 Rückgängig Undo
40 Kapitel 1 Chapter 1
41 Kapitel 2 Chapter 2
42 Münzen: %d · Staub: %d Coins: %d · Dust: %d
Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d Collection: %d of %d cards (%d total) · Campaign: %d/%d
Aktives Deck: %s (%s) Active deck: %s (%s)
leer empty
Tagesaufgabe erledigt: %s ✓ Daily task done: %s ✓
Tagesaufgabe: %s (%d/%d) · +%d Münzen Daily task: %s (%d/%d) · +%d coins
Aktives Deck nicht spielbar: Active deck not playable:
Zug %d · Du bist dran Turn %d · Your turn
Zug %d · %s ist dran … Turn %d · %s is playing …
Wähle ein Ziel Choose a target
43 Wähle ein Angriffsziel Choose an attack target
44 Abgebrochen Cancelled
45 %s kann nicht angreifen %s cannot attack
46 Nicht dein Zug Not your turn
47 Nicht genug Energie Nicht auf der Hand Not enough energy Not in your hand
48 Dein Feld ist voll Your board is full
49 Kein gültiges Ziel No valid target
50 Ungültiges Ziel Invalid target
Höchstens 2 Fallen At most 2 traps
51 Ein Wächter muss zuerst angegriffen werden A guardian must be attacked first
52 Sieg! Victory!
53 Niederlage Defeat
54 +%d Münzen +%d coins
Deck braucht genau %d Karten (%d) Deck needs exactly %d cards (%d)
Höchstens %dx dieselbe Karte At most %dx the same card
Karte nicht oft genug in der Sammlung Not enough copies in your collection
Höchstens %d Elemente im Deck At most %d elements in a deck
55 Schwierigkeit Difficulty
56 Leicht Easy
57 Normal Normal
58 Schwer Hard
Deine Deckstärke %.1f · Gegnerdecks %.1f · %s · Münzen x%.2f · KI: %s Your deck strength %.1f · Opponent decks %.1f · %s · Coins x%.2f · AI: %s
schwache Archetypen weaker archetypes
starke Archetypen stronger archetypes
alle Archetypen all archetypes
Vorausschau lookahead
einfach simple
Stärke %.1f Strength %.1f
Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten Deck strength: average of rarity, keywords and effects
Tipp: %s Tip: %s
Übernehmen Apply
Neu würfeln Reroll
Deck verbessern: %s ins aktive Deck (%s) Improve deck: add %s to the active deck (%s)
Anleitung 2: Fallen Tutorial 2: Traps
Neu würfeln (%d) Reroll (%d)
59 Profil zurücksetzen Reset profile
60 Große Schrift Large text
Verwerfen Discard
Weiter bearbeiten Keep editing
Ungespeicherte Änderungen Unsaved changes
Willkommen! Unten liegt deine Hand. Jede Karte kostet Energie (◆), du hast im ersten Zug 1. Spiele ein Wesen mit Kosten 1 per Klick. Welcome! Your hand is at the bottom. Every card costs energy (◆); you have 1 on your first turn. Click a creature that costs 1 to play it.
Wesen greifen erst ab dem nächsten Zug an. Beende deinen Zug mit dem Knopf rechts oder der Leertaste. Creatures attack from the next turn on. End your turn with the button on the right or the space bar.
Grüne Rahmen zeigen angriffsbereite Wesen. Klicke ein Wesen und dann Fenn oder eines seiner Wesen. Wächter müssen zuerst fallen. Green frames mark creatures ready to attack. Click a creature, then Fenn or one of his creatures. Guardians must fall first.
Resonanz: Wesen mit gleichem Element geben sich gegenseitig Angriff. Spiele ein zweites Feuer- oder Erde-Wesen. Resonance: creatures sharing an element boost each other's attack. Play a second fire or earth creature.
Feldkombo: Ein Wesen neben einem eigenen Wesen mit anderem Element löst einen Effekt aus. Lava macht 1 Schaden an das stärkste gegnerische Wesen. Field combo: a creature next to one of yours with a different element triggers an effect. Lava deals 1 damage to the strongest enemy creature.
Zauber wirken sofort. Funke oder Feuerball treffen auch Fenn direkt. Ziele wählst du per Klick, Rechtsklick bricht ab. Spells resolve immediately. Spark or Fireball can hit Fenn directly. Pick targets by clicking; right-click cancels.
Fenn verliert bei 0 Leben. Tausche im ersten Zug einer Partie schwache Starthände (Mulligan). Viel Erfolg! Fenn loses at 0 life. Swap weak opening hands on your first turn (mulligan). Good luck!
Mulligan: Im ersten Zug darfst du einmal Handkarten zurücklegen und neu ziehen. Entscheide in der Maske, was du behältst. Mulligan: on your first turn you may put cards back once and redraw. Decide in the dialog what to keep.
Fallen liegen verdeckt und lösen im gegnerischen Zug aus. Lege die Frostfalle, sobald du 2 Energie hast. Traps lie face down and trigger during enemy turns. Set the Frost Trap once you have 2 energy.
Beende den Zug und warte. Greift Mara dich oder ein Wesen an, verpufft der Angriff und ihr Wesen friert ein. End your turn and wait. If Mara attacks you or a creature, the attack fizzles and her creature freezes.
Feldkombo Dampf: Ein Wasser-Wesen neben einem Feuer-Wesen friert das stärkste gegnerische Wesen ein. Spiele beide nacheinander. Field combo Steam: a water creature next to a fire creature freezes the strongest enemy creature. Play both in sequence.
Rückgängig (U) nimmt ein gerade gespieltes Wesen zurück, solange nichts ausgelöst wurde. Viel Erfolg gegen Mara! Undo (U) takes back a creature you just played as long as nothing was triggered. Good luck against Mara!
%d Siege in Folge, versuche %s %d wins in a row, try %s
%d Niederlagen in Folge, versuche %s %d losses in a row, try %s
Duell Duel
%d am Tisch %d at the table
%d Spieler am Tisch %d players at the table
61 %d Münzen %d coins
Stärke %.1f · %d Münzen Strength %.1f · %d coins
62 Abbrechen Cancel
63 Ja, zurücksetzen Yes, reset
Speichern und zurück Save and go back
Kampf fortsetzen Resume battle
Aufgeben beendet den Kampf ohne Münzen. Surrendering ends the battle without coins.
Speichern hebt ihn für später auf. Saving keeps it for later.
%s Schaden an den Angreifer %s damage to the attacker
%s Schaden an dieses Wesen %s damage to that creature
Der Angreifer wird eingefroren The attacker is frozen
Es wird eingefroren It is frozen
Es geht zurück auf die Hand It returns to its owner's hand
Der Angriff verpufft The attack fizzles
Beschwöre einen %s Summon a %s
%s Schaden an ein gegnerisches Wesen %s damage to an enemy creature
%s Schaden an ein Ziel %s damage to a target
%s Schaden an alle gegnerischen Wesen %s damage to all enemy creatures
%s Schaden an alle Gegner %s damage to all opponents
%s Schaden an alle Gegner und ihre Wesen %s damage to all opponents and their creatures
Heile dich um %s Heal yourself for %s
Ziehe 1 Karte Draw 1 card
Ziehe %s Karten Draw %s cards
Friere ein gegnerisches Wesen ein Freeze an enemy creature
Friere das stärkste gegnerische Wesen ein Freeze the strongest enemy creature
Friere alle gegnerischen Wesen ein Freeze all enemy creatures
Ein gegnerisches Wesen zurück auf die Hand Return an enemy creature to its owner's hand
Alle gegnerischen Wesen zurück auf die Hand Return all enemy creatures to their owners' hands
Ein befreundetes Wesen erhält %s A friendly creature gets %s
Alle befreundeten Wesen erhalten %s All friendly creatures get %s
Ein befreundetes Wesen erhält Schild A friendly creature gets Shield
Erhält Schild Gets Shield
Alle befreundeten Wesen erhalten Schild All friendly creatures get Shield
Falle. %s: %s Trap. %s: %s
Beim Ausspielen: %s On play: %s
Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift When an enemy creature attacks you or one of your creatures
Wenn ein Gegner ein Wesen ausspielt When an opponent plays a creature
Wächter Guardian
64 Flink Swift
Schild Shield
65 Doppelschlag Double Strike
66 Wesen Common Creature Common
67 Zauber Rare Spell Rare
68 Falle Super Rare Trap Super Rare
69 gewöhnlich Ultra Rare common Ultra Rare
70 selten Ultimate Rare rare Ultimate Rare
episch epic
legendär legendary
mythisch mythic
71 Feuer Fire
72 Wasser Water
73 Erde Earth
74 Luft Air
75 %s zerlegt (+%d Staub) %s dismantled (+%d dust)
76 Kapitel %d Chapter %d
77 Zuschauen Watch
78 Zuschauer Spectator
79 Zuschauer: Hände sind verdeckt Spectator: hands are hidden
80 Starten Start
81 Sammlung Collection
82 Verschmelzen Fuse
83 Nur eigene Wesen Only your own creatures
84 Nur eigene Karten Only your own cards
85 Sammlung: %d von %d Karten · Kampagne: %d/%d Collection: %d of %d cards · Campaign: %d/%d
86 %s ist jetzt %s %s is now %s
87 Alle Kapitel abgeschlossen All chapters completed
88 Elemente Elements
89 Aufwerten Upgrade
90 Aufwerten zu %s (%d Staub) Upgrade to %s (%d dust)
91 Höchste Stufe Top tier
92 Keine Karte gewählt No card selected
93 Zerlegen (+%d Staub) Dismantle (+%d dust)
94 Nicht genug Staub (%d nötig) Not enough dust (%d needed)
95 Schon auf der höchsten Stufe Already at the top tier
96 Common-Karten lassen sich nicht zerlegen Common cards cannot be dismantled
97 Aufgewertet Upgraded
98 +%d Staub +%d dust
99 Dein Zug Your turn
100 Nicht genug Mana Not enough mana
101 Profil wirklich zurücksetzen? Really reset your profile?
102 Sammlung, Münzen und Fortschritt gehen verloren. Collection, coins and progress are lost.
103 %d Leben %d life
104 Gegner mit %d Leben · %d Münzen bei Sieg Opponent with %d life · %d coins for a win
105 Start gegen die KI Start against the AI
106 Ziehen Draw
107 Verdeckt legen ◆ %d Set face-down ◆ %d
108 Aufdecken Reveal
109 verdeckt face-down
110 Aufgedeckt! Revealed!
111 Elementbonus +%d Element bonus +%d
112 Merkmal +%d Perk +%d
113 Erst die Wesen Creatures first
114 %s liegt verdeckt %s is face-down
115 %s liegt schon offen %s is already face-up
116 Unbekannte Eigenschaft Unknown trait
117 %s hat schon %s %s already has %s
118 Wesen kommen mit +1 Angriff Creatures enter with +1 attack
119 Hat im ersten Zug 2 Mana mehr Has 2 extra mana on the first turn
120 Elementbonus zählt doppelt Element bonus counts twice
121 Beginnt mit 5 Leben mehr Starts with 5 extra life
122 Greift zweimal je Zug Attacks twice per turn
123 Verdeckt Face-down
124 Inferno Inferno
125 Tiefsee Deep Sea
126 Berg Mountain
131 Schlamm Mud
132 Sturm Storm
133 Sand Sand
134 Sonne Feuerwesen haben +1 Angriff Sun Fire creatures have +1 attack
135 Ozean Wesen kommen mit +1 Leben Ocean Creatures enter with +1 health
136 Gebirge Bekommt jeden Zug 1 Mana mehr Mountains Gets 1 extra mana each turn
137 Himmel Ziehen kostet kein Mana Sky Drawing costs no mana
138 Code übernommen, es fehlen: Spott Code applied, missing: Taunt
139 Elemente: Regeneration Elements: Regeneration
140 %s zerlegt (+%d Staub) Muss zuerst angegriffen werden %s dismantled (+%d dust) Must be attacked first
141 Greift sofort an Attacks right away
142 Heilt sich am Zugende ganz Heals fully at the end of the turn
143 Wesen greifen sofort an Creatures attack right away
144 Verschmelzen mit … Fuse with …
145 Ziel wählen Pick the partner
146 Erst die Wesen mit Spott Taunting creatures first
147 %s kann erst im nächsten Zug aufgedeckt werden %s can be revealed next turn
148 %s greift erst im nächsten Zug an %s attacks next turn
149 greift erst im nächsten Zug an attacks next turn
150 %s durch das Element %s from its element
151 Zwei verschiedene Karten wählen Pick two different cards
152 stirbt dies
153 Abwerfen +%d ◆ Discard +%d ◆
154 Überspringen Skip
155 Zug %d Turn %d
156 Stapel: %d Karten (%d bis %d) Deck: %d cards (%d to %d)
157 Stärke %d Strength %d
158 Herstellen (%d Staub) Craft (%d dust)
159 Nicht in der Sammlung Not in the collection
160 %s hergestellt %s crafted
161 %s ist jetzt im Deck %s Neu! %s is now in deck %s New!
162 Deck-Code Auffüllen Deck code Fill up
163 In Zwischenablage Leeren To clipboard Clear
164 Aus Zwischenablage Dein Stapel ist leer From clipboard Your deck is empty
165 Code Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die wertvollsten Karten des Filters. Code Click adds a card to the deck or removes it. Fill up takes the most valuable cards of the filter.
166 Ungültiger Code Wähle dein Startdeck Invalid code Choose your starting deck
167 Code übernommen 25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest. Code applied 25 cards of the element plus five of each other one. Boosters and the workshop bring the rest later.
168 Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen. %s wählen Copy to share or paste a code. Cards you do not own are skipped. Choose %s
169 Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos Optionen Element booster (%d coins): only cards of that element and its combos Options
170 Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks. Hauptmenü Chapter 3: three perks, full tables and the strongest decks. Main menu
171 Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt! Neutral All three chapters completed. The Archmage is defeated for good! Neutral
172 Kapitel %d Nie im Vorteil oder Nachteil Chapter %d Never at an advantage or disadvantage
173 Zuschauen Veteran Watch Veteran
174 Zuschauer Feuerritter Spectator Fire Knight
175 Zuschauer: Hände sind verdeckt Wasserritter Spectator: hands are hidden Water Knight
176 Erdritter Earth Knight
177 Luftritter Air Knight
Binary file not shown.