Compare commits
26 Commits
536a523f50
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2c609a08b | ||
|
|
b91f475a6e | ||
|
|
a11cee2e0a | ||
|
|
c245c7af87 | ||
|
|
5bc8569717 | ||
|
|
0a612f91b3 | ||
|
|
5f9442497a | ||
|
|
24a03ed1ee | ||
|
|
f0a8f18de8 | ||
|
|
dfcd3cb713 | ||
|
|
8944fdd017 | ||
|
|
89db101137 | ||
|
|
43073351b0 | ||
|
|
0f2dc22ef6 | ||
|
|
5c1084fd79 | ||
|
|
f7d06b1e7c | ||
|
|
7ed58ed69a | ||
|
|
4c6bf96eeb | ||
|
|
b6d5916a8d | ||
|
|
e1e6211ef8 | ||
|
|
77d1b318d6 | ||
|
|
f68ac07550 | ||
|
|
4246d7e970 | ||
|
|
0dbc98b456 | ||
|
|
7ae6017f12 | ||
|
|
46c3fae233 |
49
.github/workflows/tests.yml
vendored
49
.github/workflows/tests.yml
vendored
@@ -1,6 +1,11 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
headless:
|
||||
@@ -13,10 +18,11 @@ jobs:
|
||||
unzip -q godot.zip
|
||||
mv Godot_v4.7.2-stable_linux.x86_64 godot
|
||||
chmod +x godot
|
||||
- name: Tests
|
||||
- name: Statische Prüfung und Tests
|
||||
run: ./tests/run_headless.sh ./godot
|
||||
|
||||
fenster:
|
||||
fenster-schnell:
|
||||
if: github.event_name != 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -29,7 +35,42 @@ jobs:
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
|
||||
- name: Import
|
||||
run: ./godot --headless --path . --import || true
|
||||
- name: Fenster-Test (virtueller Bildschirm)
|
||||
- name: Fenster-Test (schnell)
|
||||
run: DINGER_FAST=1 xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
|
||||
- name: Ergebnis
|
||||
run: grep -q " 0 fehlgeschlagen" fenster.log
|
||||
|
||||
fenster-voll:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Godot und Xvfb laden
|
||||
run: |
|
||||
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
|
||||
unzip -q godot.zip
|
||||
mv Godot_v4.7.2-stable_linux.x86_64 godot
|
||||
chmod +x godot
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq xvfb libgl1 libegl1
|
||||
- name: Import
|
||||
run: ./godot --headless --path . --import || true
|
||||
- name: Fenster-Test (voll)
|
||||
run: xvfb-run -a --server-args="-screen 0 1280x800x24" ./godot --path . tests/ui_test.tscn 2>&1 | tee fenster.log | grep -E "FEHLER|Prüfungen" || true
|
||||
- name: Ergebnis
|
||||
run: grep -q " 0 fehlgeschlagen" fenster.log
|
||||
|
||||
balance:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Godot laden
|
||||
run: |
|
||||
curl -sSL -o godot.zip https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_linux.x86_64.zip
|
||||
unzip -q godot.zip
|
||||
mv Godot_v4.7.2-stable_linux.x86_64 godot
|
||||
chmod +x godot
|
||||
- name: Import
|
||||
run: ./godot --headless --path . --import || true
|
||||
- name: Balance messen
|
||||
run: ./godot --headless --path . -s tests/simulate.gd 2>&1 | grep -E "Siege|Dauer"
|
||||
|
||||
61
README.md
61
README.md
@@ -1,43 +1,59 @@
|
||||
# Dinger
|
||||
|
||||
Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle mit denselben Regeln.
|
||||
Kartenspiel in 2D (Godot 4.7): ein Duell gegen einen KI-Magier, beide mit denselben Regeln.
|
||||
|
||||
## Start
|
||||
## Starten
|
||||
|
||||
Projektordner in Godot öffnen und auf „Play“ drücken, oder im Terminal:
|
||||
Projektordner in Godot öffnen und auf „Play" drücken, oder im Terminal:
|
||||
|
||||
```
|
||||
godot --path .
|
||||
./start.sh # Linux/macOS (start.bat unter Windows); Godot 4.7 muss als `godot` im Pfad liegen
|
||||
godot --path . # wenn das Projekt schon einmal importiert wurde
|
||||
```
|
||||
|
||||
Frisch geklont fehlt das Verzeichnis `.godot` mit Godots Import-Cache. Die Startskripte holen den Import
|
||||
beim ersten Start nach; alternativ das Projekt einmal im Godot-Editor öffnen oder
|
||||
`godot --headless --path . --import` ausführen.
|
||||
|
||||
Der Spielstand liegt unter `user://spielstand.json`
|
||||
(Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`).
|
||||
|
||||
## Spielsysteme
|
||||
|
||||
- 100 Karten in `data/karten.json`: 4 Elemente, 10 Zweier-Kombos (auch gleiche Elemente), 4 mythische Dreifach-Kombos, 4 Fallen
|
||||
- Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus
|
||||
- Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht)
|
||||
- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern, Erfolge, Tagesaufgabe
|
||||
- Lokal zu mehreren: bis zu 4 Menschen am selben Rechner plus KI
|
||||
- Booster, fünf Deck-Slots, Werkstatt (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen)
|
||||
- 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
|
||||
- Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts)
|
||||
- Mana: 1 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen und Legen
|
||||
- Jeder spielt mit seinem eigenen Stapel aus 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)
|
||||
- Eigenschaften gibt es nur über Elementpaare (siehe Elementboni): Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug). Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink; verletzte Wesen heilen nicht von selbst
|
||||
- Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar 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`)
|
||||
- Anleitung (geführte erste Partie), Mulligan im ersten Zug, Tastatur (Leertaste, 1 bis 9, Esc, Enter)
|
||||
- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten
|
||||
- Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand)
|
||||
- Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch
|
||||
- Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`)
|
||||
- Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen; Ton und Musik sind standardmäßig aus
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/run_headless.sh` führt Import und Headless-Prüfungen aus (auch in `.github/workflows/tests.yml`).
|
||||
`tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`:
|
||||
Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft).
|
||||
Beide Suiten brechen beim ersten Fehler ab. Der Fenster-Test spielt eine kurze Partie (einfache KI, Gegner mit 1 Leben) und läuft in wenigen Sekunden.
|
||||
|
||||
```
|
||||
godot --headless --path . --import # einmalig: Ressourcen importieren
|
||||
godot --headless --path . --import # einmalig nach dem Klonen: Ressourcen importieren
|
||||
godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand
|
||||
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
|
||||
godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar
|
||||
godot --headless --path . -s tests/dump_cards.gd # Kartenpool aus Code nach JSON schreiben (nur bei Bedarf)
|
||||
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
|
||||
DINGER_ONLY=test_sammlung godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
|
||||
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Stapel, Musik
|
||||
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf
|
||||
```
|
||||
|
||||
## Export
|
||||
@@ -52,11 +68,12 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
|
||||
## Aufbau
|
||||
|
||||
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
|
||||
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln, Gegner, Hotseat
|
||||
- `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementboni, Kampf, Verschmelzen
|
||||
- `scripts/deckgen.gd` – Stapel: zufällige für Gegner, Standardstapel aus der Sammlung
|
||||
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
|
||||
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Erfolge, Tagesaufgabe, Einstellungen
|
||||
- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Stapel, Booster, Werkstatt, Einstellungen
|
||||
- `scripts/campaign.gd` – Kampagnenstufen
|
||||
- `scripts/ui/` – Oberfläche: Menü, Gegnerauswahl, Kampagne, Hotseat, Lobby, Booster, Deckbau, Werkstatt, Kampf (Bildschirm, Layout, Effekte), Erfolge, Einstellungen, Regeln, Ton
|
||||
- `scenes/main.tscn` – Startszene
|
||||
- `scripts/net.gd` – Netzwerkspiel (ENet)
|
||||
- `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `element_view.gd` den Elementzähler, `battle_layout.gd` die Aufteilung des Kampfes
|
||||
- `translations/ui.csv` – englische Oberflächentexte
|
||||
- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
# Dinger
|
||||
# Cards
|
||||
##
|
||||
|
||||
|
||||
## Grundidee
|
||||
- Kartenspiel wie zum Beispiel Magic
|
||||
- PC-Spiel
|
||||
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
|
||||
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
|
||||
- Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt
|
||||
- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich
|
||||
- Geführte erste Partie als Einstieg
|
||||
- Duell: Spieler gegen einen KI-Gegner, beide mit gleichen Regeln
|
||||
- Einzelspieler-Knopf führt zur Wahl der Schwierigkeit
|
||||
- Kampagne gegen benannte Magier als Fortschritt
|
||||
|
||||
## Sammeln
|
||||
- Booster öffnen
|
||||
- Karten haben Seltenheiten
|
||||
- Aus der Sammlung werden Decks gebaut (mehrere Slots)
|
||||
- Überschüssige Karten lassen sich zerlegen und neue herstellen
|
||||
- Man startet mit 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
|
||||
- Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare); je Stufe +1/+1 bei gleichen Kosten, dazu Optik (Glanz, Holo, Relief, Funkeln); Fusionen tragen die höhere Stufe der Eltern
|
||||
- Booster öffnen (Chancen 45/25/15/10/5 %, mindestens eine Rare): neue Karten kommen in Sammlung und Stapel, von jeder Karte zählt die seltenste Version, die alte wird zu Staub, der Rest wird zu Staub (50/100/200/500/1000)
|
||||
- Werkstatt: Staub wertet gezielt auf (100/200/500/1000), Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
|
||||
- Jeder hat sein Deck: 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
|
||||
- Karten gehören zu Elementen
|
||||
- Elemente sind kombinierbar, daraus entstehen viele Varianten
|
||||
- Gleiche Elemente sind auch eine Kombo (Feuer + Feuer)
|
||||
- Jedes Element und jede Kombo hat eine eigene Spielweise
|
||||
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus
|
||||
- Verdeckte Fallen bringen Bluff und Reaktion im gegnerischen Zug
|
||||
- Jede Karte gehört zu genau einem Element oder ist neutral; je Element und neutral gibt es alle Werte von 0/1 bis 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
|
||||
- Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan)
|
||||
- Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag)
|
||||
- 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
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
3206
data/karten.json
3206
data/karten.json
File diff suppressed because it is too large
Load Diff
144
design.md
Normal file
144
design.md
Normal 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/
|
||||
@@ -1,6 +1,10 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
; [section] ; section goes between []
|
||||
; param=value ; assign values to parameters
|
||||
|
||||
config_version=5
|
||||
|
||||
@@ -15,7 +19,6 @@ config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=800
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="keep"
|
||||
|
||||
[internationalization]
|
||||
|
||||
|
||||
346
scripts/ai.gd
346
scripts/ai.gd
@@ -1,21 +1,19 @@
|
||||
class_name AI
|
||||
## KI mit einfacher Vorausschau: jede mögliche Aktion wird auf einer Kopie des
|
||||
## Spiels ausgeführt und die Stellung danach bewertet. Die gierige Variante
|
||||
## (choose_action_greedy) dient als Vergleich.
|
||||
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels ausgeführt und die
|
||||
## Stellung danach bewertet; die besten Erstschritte werden um einen Folgeschritt vertieft.
|
||||
## Verdeckte gegnerische Wesen kennt die KI nicht (Game.masked_clone). Die gierige Variante
|
||||
## (choose_action_greedy) dient als Vergleich und für die Schwierigkeit Leicht. Alles hier ist
|
||||
## zustandslos und darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
|
||||
|
||||
|
||||
# --- Bewertung -------------------------------------------------------------------
|
||||
|
||||
static func creature_value(c: Game.Creature) -> float:
|
||||
var v := float(c.power() + c.health)
|
||||
if c.taunt():
|
||||
v += 2.0
|
||||
if c.shield:
|
||||
v += 1.5
|
||||
if c.attacks_per_turn() == 2:
|
||||
v += c.power()
|
||||
if c.frozen:
|
||||
v -= c.power() * 0.5
|
||||
v += c.power() * 0.5
|
||||
if c.has("Regeneration"):
|
||||
v += 1.0
|
||||
return v
|
||||
|
||||
|
||||
@@ -26,13 +24,22 @@ static func board_value(p: Game.Player) -> float:
|
||||
return v
|
||||
|
||||
|
||||
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv,
|
||||
## die der Gegner negativ. Ein besiegter Gegner ist viel wert.
|
||||
## Karten auf der Hand zählen nach ihren Werten: ein Drache ist mehr wert als ein Kobold.
|
||||
static func hand_value(p: Game.Player) -> float:
|
||||
var v := 0.0
|
||||
for c in p.hand:
|
||||
v += 0.15 * (c.attack + c.health)
|
||||
return v
|
||||
|
||||
|
||||
## Stellung aus Sicht von p: eigenes Leben, Brett und Hand zählen positiv, die des Gegners
|
||||
## negativ. Ohne eigene Wesen zielt gegnerische Angriffskraft aufs Leben, tödliche besonders.
|
||||
static func evaluate(g: Game, p: Game.Player) -> float:
|
||||
if not p.alive():
|
||||
return -10000.0
|
||||
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
|
||||
var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.01
|
||||
var alive_enemies := 0
|
||||
var threat := 0.0
|
||||
for e in g.players:
|
||||
if e == p:
|
||||
continue
|
||||
@@ -43,47 +50,120 @@ static func evaluate(g: Game, p: Game.Player) -> float:
|
||||
score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
|
||||
if e.life <= 6:
|
||||
score += 3.0 # fast besiegt: dranbleiben
|
||||
for c in e.board:
|
||||
if not c.hidden:
|
||||
threat += c.power() * c.attacks_per_turn()
|
||||
if alive_enemies == 0:
|
||||
return 10000.0
|
||||
if p.board.is_empty():
|
||||
if threat >= p.life:
|
||||
score -= 25.0
|
||||
else:
|
||||
score -= threat * 0.3
|
||||
else:
|
||||
score -= threat * 0.12 # auch hinter Wesen tut gegnerische Angriffskraft weh
|
||||
return score
|
||||
|
||||
|
||||
# --- Vorausschau ---------------------------------------------------------------
|
||||
|
||||
static func candidate_actions(game: Game, p: Game.Player) -> Array:
|
||||
const MAX_TARGETS := 4 # je Angreifer nur die aussichtsreichsten Ziele prüfen
|
||||
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
|
||||
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
|
||||
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
|
||||
|
||||
|
||||
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann der Rest.
|
||||
static func _attack_rank(cr: Game.Creature, t) -> float:
|
||||
if t is Game.Player:
|
||||
return 50.0 - t.life
|
||||
if _kills(cr, t):
|
||||
return (100.0 if not _kills(t, cr) else 60.0) + _value(t)
|
||||
return -float(t.health)
|
||||
|
||||
|
||||
static func _top(list: Array, rank: Callable, n: int) -> Array:
|
||||
if list.size() <= n:
|
||||
return list
|
||||
var ranked := []
|
||||
for t in list:
|
||||
ranked.append([rank.call(t), t])
|
||||
ranked.sort_custom(func(a, b): return a[0] > b[0])
|
||||
return ranked.slice(0, n).map(func(pair): return pair[1])
|
||||
|
||||
|
||||
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Angreifer.
|
||||
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
|
||||
var out := []
|
||||
for card in p.hand:
|
||||
if not game.can_play(p, card)[0]:
|
||||
continue
|
||||
var tt: String = card.target_type()
|
||||
if tt == "":
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
else:
|
||||
var valid := game.valid_targets(p, tt)
|
||||
if valid.is_empty():
|
||||
out.append({"type": "play", "card": card, "target": null})
|
||||
for t in valid:
|
||||
out.append({"type": "play", "card": card, "target": t})
|
||||
if game.can_play(p, card)[0]:
|
||||
out.append({"type": "play", "card": card, "face_down": false})
|
||||
for cr in p.board:
|
||||
if cr.hidden and game.can_reveal(p, cr)[0]:
|
||||
out.append({"type": "reveal", "creature": cr})
|
||||
for pr in fusion_pairs(game, p, prune):
|
||||
out.append({"type": "fuse", "creature": pr[0], "other": pr[1]})
|
||||
for cr in p.board:
|
||||
if not cr.can_attack():
|
||||
continue
|
||||
var targets := []
|
||||
for e in game.enemies_of(p):
|
||||
for t in e.board + [e]:
|
||||
if game.can_attack(cr, t)[0]:
|
||||
targets.append_array(e.board)
|
||||
if e.board.is_empty():
|
||||
targets.append(e)
|
||||
targets = targets.filter(func(t): return game.can_attack(cr, t)[0])
|
||||
if prune:
|
||||
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
|
||||
for t in targets:
|
||||
out.append({"type": "attack", "creature": cr, "target": t})
|
||||
return out
|
||||
|
||||
|
||||
## Verschmelzungen, die sich lohnen könnten: Paare eigener Wesen (mit prune die schwächsten, die
|
||||
## Plätze frei machen), jedes angeschlagene Wesen mit der billigsten Handkarte, die zwei
|
||||
## billigsten Handkarten. Das Ergebnis kommt auf die Hand, die Vorausschau legt es im Folgeschritt.
|
||||
static func fusion_pairs(game: Game, p: Game.Player, prune: bool) -> Array:
|
||||
var pairs := []
|
||||
for i in p.board.size():
|
||||
for j in range(i + 1, p.board.size()):
|
||||
pairs.append([p.board[i], p.board[j]])
|
||||
if prune and pairs.size() > MAX_TARGETS:
|
||||
pairs = _top(pairs, func(pr): return -(_value(pr[0]) + _value(pr[1])), MAX_TARGETS)
|
||||
var cheap := _cheapest(p)
|
||||
if not cheap.is_empty():
|
||||
for cr in p.board:
|
||||
if cr.health < cr.max_health:
|
||||
pairs.append([cr, cheap[0]])
|
||||
if cheap.size() >= 2:
|
||||
pairs.append([cheap[0], cheap[1]])
|
||||
return pairs.filter(func(pr): return game.can_fuse(p, pr[0], pr[1])[0])
|
||||
|
||||
|
||||
## Handkarten nach Kosten aufsteigend.
|
||||
static func _cheapest(p: Game.Player) -> Array:
|
||||
var cheap: Array = p.hand.duplicate()
|
||||
cheap.sort_custom(func(a, b): return a.cost < b.cost)
|
||||
return cheap
|
||||
|
||||
|
||||
## Teil einer Verschmelzung in einem anderen Spiel: Handkarten sind in Kopien dieselben Objekte,
|
||||
## Wesen werden über ihre uid gefunden.
|
||||
static func _part(g: Game, x):
|
||||
return x if x is Cards.Card else g.find_creature(x.uid)
|
||||
|
||||
|
||||
## Führt eine Aktion des Originals auf der Kopie aus.
|
||||
static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
|
||||
var p2: Game.Player = g2.players[game.current_idx]
|
||||
var target = null
|
||||
if action.get("target") != null:
|
||||
target = map_target(g2, game, action.target)
|
||||
if action.type == "play":
|
||||
g2.play_card(p2, action.card, target)
|
||||
else:
|
||||
g2.attack(g2.find_creature(action.creature.uid), target)
|
||||
match action.type:
|
||||
"play":
|
||||
g2.play_card(p2, action.card, bool(action.get("face_down", false)))
|
||||
"reveal":
|
||||
g2.reveal(p2, g2.find_creature(action.creature.uid))
|
||||
"fuse":
|
||||
g2.fuse(p2, _part(g2, action.creature), _part(g2, action.other))
|
||||
_:
|
||||
g2.attack(g2.find_creature(action.creature.uid), map_target(g2, game, action.target))
|
||||
|
||||
|
||||
static func map_target(g2: Game, game: Game, t):
|
||||
@@ -92,84 +172,102 @@ static func map_target(g2: Game, game: Game, t):
|
||||
return g2.players[game.players.find(t)]
|
||||
|
||||
|
||||
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe
|
||||
## Überträgt eine auf der Kopie gewählte Aktion zurück auf das Original.
|
||||
static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary:
|
||||
var out := action.duplicate()
|
||||
if action.has("creature"):
|
||||
out["creature"] = _part(game, action.creature)
|
||||
if action.has("other"):
|
||||
out["other"] = _part(game, action.other)
|
||||
if action.get("target") != null:
|
||||
out["target"] = map_target(game, copy, action.target)
|
||||
return out
|
||||
|
||||
|
||||
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
|
||||
static func mulligan_cards(p: Game.Player) -> Array:
|
||||
return p.hand.filter(func(c): return c.cost >= 5)
|
||||
|
||||
|
||||
## Beste Aktion nach einer Vorausschau von zwei Schritten: jede Aktion wird auf einer
|
||||
## Kopie ausgeführt und mit ihrem besten Folgeschritt bewertet (z. B. verstärken, dann angreifen).
|
||||
## Beste Aktion nach einer Vorausschau von zwei Schritten auf einer Kopie, die verdeckte
|
||||
## gegnerische Wesen nicht kennt. Bei Zeitmangel gilt der beste bisherige Zug.
|
||||
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
||||
if game.winner != "" or game.enemies_of(p).is_empty():
|
||||
return {"type": "end"}
|
||||
if not p.mulligan_done and p.turns == 1 and p == game.current():
|
||||
return {"type": "mulligan", "cards": mulligan_cards(p)}
|
||||
var view := game.masked_clone(p)
|
||||
var me: Game.Player = view.players[game.players.find(p)]
|
||||
var t0 := Time.get_ticks_msec()
|
||||
var base := evaluate(view, me) + 0.01
|
||||
var firsts := [] # {action, game, score}
|
||||
for action in candidate_actions(view, me, true):
|
||||
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||
break
|
||||
var g2 := view.clone()
|
||||
apply_on_clone(g2, view, action)
|
||||
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[view.current_idx])})
|
||||
firsts.sort_custom(func(a, b): return a.score > b.score)
|
||||
var best := {"type": "end"}
|
||||
var best_score := evaluate(game, p) + 0.01
|
||||
for action in candidate_actions(game, p):
|
||||
var g2 := game.clone_for(p)
|
||||
apply_on_clone(g2, game, action)
|
||||
var p2: Game.Player = g2.players[game.current_idx]
|
||||
var score := evaluate(g2, p2)
|
||||
var best_score := base
|
||||
for i in mini(firsts.size(), DEEPEN):
|
||||
var f: Dictionary = firsts[i]
|
||||
var g2: Game = f.game
|
||||
var score: float = f.score
|
||||
if g2.winner == "":
|
||||
var p2: Game.Player = g2.players[view.current_idx]
|
||||
var count := 0
|
||||
for f in candidate_actions(g2, p2):
|
||||
if count >= FOLLOW_UPS:
|
||||
for nxt in candidate_actions(g2, p2, true):
|
||||
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||
break
|
||||
count += 1
|
||||
var g3 := g2.clone_for(p2)
|
||||
apply_on_clone(g3, g2, f)
|
||||
score = maxf(score, evaluate(g3, g3.players[g2.current_idx]) - 0.001)
|
||||
var g3 := g2.clone()
|
||||
apply_on_clone(g3, g2, nxt)
|
||||
score = maxf(score, evaluate(g3, g3.players[view.current_idx]) - 0.001)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = action
|
||||
best = f.action
|
||||
# Erstschritte ohne Vertiefung zählen mit ihrer Einzelbewertung
|
||||
for i in range(DEEPEN, firsts.size()):
|
||||
if firsts[i].score > best_score:
|
||||
best_score = firsts[i].score
|
||||
best = firsts[i].action
|
||||
if best.type == "end":
|
||||
if game.can_draw(p)[0]:
|
||||
return {"type": "draw"} # Mana übrig und Platz auf der Hand: lieber ziehen als passen
|
||||
return best
|
||||
return map_action(game, view, best)
|
||||
|
||||
|
||||
# --- Gierige Vergleichs-KI -------------------------------------------------------
|
||||
# --- Einfache Heuristiken ----------------------------------------------------------
|
||||
|
||||
static func _kills(attacker, defender) -> bool:
|
||||
return not defender.shield and defender.health <= attacker.power()
|
||||
if defender is Game.Player:
|
||||
return attacker.power() >= defender.life
|
||||
return attacker.power() + Game.matchup_bonus(attacker, defender) >= defender.health
|
||||
|
||||
|
||||
static func _value(c) -> int:
|
||||
return c.power() + c.health + (2 if c.taunt() else 0)
|
||||
if c is Game.Player:
|
||||
return 1000
|
||||
return c.power() + c.health
|
||||
|
||||
|
||||
static func _best(arr: Array, key: Callable):
|
||||
static func _best(list: Array, key: Callable):
|
||||
var best = null
|
||||
var best_v := 0
|
||||
for x in arr:
|
||||
var v: int = key.call(x)
|
||||
if best == null or v > best_v:
|
||||
best = x
|
||||
var best_v := -INF
|
||||
for t in list:
|
||||
var v := float(key.call(t))
|
||||
if v > best_v:
|
||||
best_v = v
|
||||
return best
|
||||
|
||||
|
||||
static func _threat_score(h) -> int:
|
||||
var board_attack := 0
|
||||
for c in h.board:
|
||||
board_attack += c.power()
|
||||
return h.life - board_attack
|
||||
|
||||
|
||||
static func _weakest(heroes: Array):
|
||||
var best = null
|
||||
for h in heroes:
|
||||
if best == null or _threat_score(h) < _threat_score(best):
|
||||
best = h
|
||||
best = t
|
||||
return best
|
||||
|
||||
|
||||
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
|
||||
if game.winner != "" or game.enemies_of(p).is_empty():
|
||||
return {"type": "end"}
|
||||
if not p.mulligan_done and p.turns == 1 and p == game.current():
|
||||
return {"type": "mulligan", "cards": mulligan_cards(p)}
|
||||
# Gierig: ein Wesen mit höchstens halbem Leben heilen (mit der billigsten Handkarte
|
||||
# verschmelzen, wenn das Ergebnis bezahlbar bleibt), dann das teuerste Spielbare legen (bei
|
||||
# vollem Feld die zwei schwächsten Wesen verschmelzen), dann aufdecken und angreifen, dann ziehen.
|
||||
var cheap := _cheapest(p)
|
||||
if not cheap.is_empty():
|
||||
for cr in p.board:
|
||||
if cr.health * 2 <= cr.max_health and game.can_fuse(p, cr, cheap[0])[0] and Game.fusion_result(p, cr, cheap[0]).cost <= p.energy:
|
||||
return {"type": "fuse", "creature": cr, "other": cheap[0]}
|
||||
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
|
||||
if not playable.is_empty():
|
||||
Game.shuffle(playable, game.rng)
|
||||
@@ -177,71 +275,40 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
|
||||
for c in playable:
|
||||
if c.cost > card.cost:
|
||||
card = c
|
||||
return {"type": "play", "card": card, "target": pick_target(game, p, card)}
|
||||
return {"type": "play", "card": card, "face_down": false}
|
||||
if p.board.size() >= Game.MAX_BOARD and p.hand.any(func(c): return c.cost <= p.energy):
|
||||
var weakest: Array = p.board.duplicate()
|
||||
weakest.sort_custom(func(a, b): return _value(a) < _value(b))
|
||||
if game.can_fuse(p, weakest[0], weakest[1])[0]:
|
||||
return {"type": "fuse", "creature": weakest[0], "other": weakest[1]}
|
||||
for cr in p.board:
|
||||
if cr.hidden and game.can_reveal(p, cr)[0]:
|
||||
return {"type": "reveal", "creature": cr}
|
||||
for cr in p.board:
|
||||
if cr.can_attack():
|
||||
var t = pick_attack_target(game, p, cr)
|
||||
if t != null:
|
||||
return {"type": "attack", "creature": cr, "target": t}
|
||||
if game.can_draw(p)[0]:
|
||||
return {"type": "draw"}
|
||||
return {"type": "end"}
|
||||
|
||||
|
||||
static func pick_target(game: Game, p: Game.Player, card: Cards.Card):
|
||||
var tt := card.target_type()
|
||||
if tt == "":
|
||||
return null
|
||||
var valid := game.valid_targets(p, tt)
|
||||
if valid.is_empty():
|
||||
return null
|
||||
var fx: Cards.Effect = null
|
||||
for f in card.effects:
|
||||
if f.target == tt:
|
||||
fx = f
|
||||
break
|
||||
var creatures := valid.filter(func(t): return t is Game.Creature)
|
||||
var heroes := valid.filter(func(t): return t is Game.Player)
|
||||
match fx.kind:
|
||||
"damage":
|
||||
var killable := creatures.filter(func(c): return not c.shield and c.health <= fx.amount)
|
||||
if not killable.is_empty():
|
||||
return _best(killable, Callable(AI, "_value"))
|
||||
if not heroes.is_empty():
|
||||
return _weakest(heroes)
|
||||
return _best(creatures, Callable(AI, "_value"))
|
||||
"freeze", "bounce":
|
||||
return _best(creatures, func(c): return c.card.cost * 100 + c.power())
|
||||
"buff", "shield":
|
||||
return _best(creatures, func(c): return (100 if c.can_attack() else 0) + c.power())
|
||||
return valid[0]
|
||||
|
||||
|
||||
## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
|
||||
## der Held nur, wenn kein Wesen mehr steht.
|
||||
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
|
||||
var enemies := game.enemies_of(p)
|
||||
var total := 0
|
||||
for c in p.board:
|
||||
if c.can_attack():
|
||||
total += c.power() * c.attacks_left
|
||||
var options := []
|
||||
for e in enemies:
|
||||
var taunts: Array = e.board.filter(func(c): return c.taunt())
|
||||
if not taunts.is_empty():
|
||||
options.append_array(taunts)
|
||||
else:
|
||||
if total >= e.life:
|
||||
return e
|
||||
options.append_array(e.board)
|
||||
options.append(e)
|
||||
var creatures := options.filter(func(t): return t is Game.Creature)
|
||||
var heroes := options.filter(func(t): return t is Game.Player)
|
||||
var good := creatures.filter(func(c): return _kills(cr, c) and not _kills(c, cr))
|
||||
for e in game.enemies_of(p):
|
||||
if e.board.is_empty():
|
||||
return e if game.can_attack(cr, e)[0] else null
|
||||
var creatures: Array = e.board.filter(func(c): return game.can_attack(cr, c)[0])
|
||||
if creatures.is_empty():
|
||||
continue
|
||||
var good: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and not _kills(c, cr))
|
||||
if not good.is_empty():
|
||||
return _best(good, Callable(AI, "_value"))
|
||||
var trade := 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():
|
||||
return _best(trade, Callable(AI, "_value"))
|
||||
if not heroes.is_empty():
|
||||
return _weakest(heroes)
|
||||
if not creatures.is_empty():
|
||||
return _best(creatures, func(c): return 1000 - c.health)
|
||||
return null
|
||||
|
||||
@@ -251,11 +318,15 @@ static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
|
||||
static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
|
||||
match action.type:
|
||||
"play":
|
||||
game.play_card(p, action.card, action.target)
|
||||
game.play_card(p, action.card, bool(action.get("face_down", false)))
|
||||
"draw":
|
||||
game.draw(p)
|
||||
"reveal":
|
||||
game.reveal(p, action.creature)
|
||||
"attack":
|
||||
game.attack(action.creature, action.target)
|
||||
"mulligan":
|
||||
game.mulligan(p, action.cards)
|
||||
"fuse":
|
||||
game.fuse(p, action.creature, action.other)
|
||||
_:
|
||||
game.end_turn()
|
||||
|
||||
@@ -267,3 +338,4 @@ static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: i
|
||||
apply_action(game, p, action)
|
||||
if action.type == "end" or game.winner != "":
|
||||
return
|
||||
game.end_turn()
|
||||
|
||||
@@ -1,41 +1,58 @@
|
||||
class_name Campaign
|
||||
## Kampagne: zehn benannte Magier, 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 := [
|
||||
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "feuer_angriff"},
|
||||
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "bias": 1.0, "reward": 100, "perk": "leben_35"},
|
||||
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "waechter_leben"},
|
||||
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "bias": 1.2, "reward": 150, "perk": "flink_angriff"},
|
||||
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "zauber_schaden"},
|
||||
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "bias": 1.5, "reward": 200, "perk": "extra_karten"},
|
||||
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "resonanz_doppelt"},
|
||||
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "bias": 2.0, "reward": 250, "perk": "schild_wesen"},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "bias": 2.5, "reward": 300, "perk": "alle_flink"},
|
||||
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "bias": 3.0, "reward": 500, "perk": "start_energie"},
|
||||
{"name": "Fenn", "title": "Kobold-Zähmer", "life": 22, "reward": 100, "perk": "feuer_angriff"},
|
||||
{"name": "Mara", "title": "Gezeitenhexe", "life": 22, "reward": 100, "perk": "leben_35"},
|
||||
{"name": "Borin", "title": "Steinfaust", "life": 24, "reward": 150, "perk": "wesen_angriff"},
|
||||
{"name": "Liska", "title": "Windläuferin", "life": 24, "reward": 150, "perk": "ziehen_gratis"},
|
||||
{"name": "Vulkar", "title": "Herr der Lava", "life": 24, "reward": 200, "perk": "wesen_leben"},
|
||||
{"name": "Nyx", "title": "Sturmruferin", "life": 26, "reward": 200, "perk": "extra_mana"},
|
||||
{"name": "Ignis", "title": "Inferno-Priester", "life": 26, "reward": 250, "perk": "element_doppelt"},
|
||||
{"name": "Thalassa", "title": "Tiefseekönigin", "life": 26, "reward": 250, "perk": "alle_flink"},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 300, "perk": "alle_flink"},
|
||||
{"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 := [
|
||||
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
|
||||
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "bias": 2.0, "reward": 300, "perks": ["leben_35", "extra_karten"]},
|
||||
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]},
|
||||
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "bias": 2.2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]},
|
||||
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]},
|
||||
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "bias": 2.5, "reward": 400, "perks": ["extra_karten", "flink_angriff"]},
|
||||
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]},
|
||||
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "bias": 3.0, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "bias": 3.5, "reward": 500, "perks": ["alle_flink", "start_energie"]},
|
||||
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "bias": 4.0, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]},
|
||||
{"name": "Fenn", "title": "Flammenzähmer", "life": 22, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
|
||||
{"name": "Mara", "title": "Herrin der Gezeiten", "life": 22, "reward": 300, "perks": ["leben_35", "extra_mana"]},
|
||||
{"name": "Borin", "title": "Fels der Ewigkeit", "life": 24, "reward": 350, "perks": ["wesen_angriff", "alle_flink"]},
|
||||
{"name": "Liska", "title": "Sturmläuferin", "life": 24, "reward": 350, "perks": ["ziehen_gratis", "wesen_angriff"]},
|
||||
{"name": "Vulkar", "title": "Aschekönig", "life": 24, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
|
||||
{"name": "Nyx", "title": "Gewitterruferin", "life": 26, "reward": 400, "perks": ["extra_mana", "ziehen_gratis"]},
|
||||
{"name": "Ignis", "title": "Inferno-Erzpriester", "life": 26, "reward": 450, "perks": ["element_doppelt", "wesen_leben"]},
|
||||
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "life": 26, "reward": 450, "perks": ["alle_flink", "leben_35"]},
|
||||
{"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 500, "perks": ["alle_flink", "start_energie"]},
|
||||
{"name": "Archimagier", "title": "Herr aller Elemente", "life": 28, "reward": 800, "perks": ["start_energie", "element_doppelt"]},
|
||||
]
|
||||
|
||||
|
||||
## Kapitel 3: drei Merkmale und volle Tische.
|
||||
const STAGES3 := [
|
||||
{"name": "Fenn", "title": "Aschefürst", "life": 20, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
|
||||
{"name": "Mara", "title": "Sturmflut", "life": 20, "reward": 600, "perks": ["leben_35", "extra_mana", "alle_flink"]},
|
||||
{"name": "Borin", "title": "Berg selbst", "life": 22, "reward": 700, "perks": ["wesen_angriff", "alle_flink", "leben_35"]},
|
||||
{"name": "Liska", "title": "Windbraut", "life": 22, "reward": 700, "perks": ["ziehen_gratis", "wesen_angriff", "extra_mana"]},
|
||||
{"name": "Vulkar", "title": "Magmakaiser", "life": 22, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "wesen_angriff"]},
|
||||
{"name": "Nyx", "title": "Orkanmutter", "life": 24, "reward": 800, "perks": ["extra_mana", "ziehen_gratis", "element_doppelt"]},
|
||||
{"name": "Ignis", "title": "Sonnenpriester", "life": 24, "reward": 900, "perks": ["element_doppelt", "wesen_leben", "start_energie"]},
|
||||
{"name": "Thalassa", "title": "Herz der Tiefe", "life": 24, "reward": 900, "perks": ["alle_flink", "leben_35", "element_doppelt"]},
|
||||
{"name": "Zephyra", "title": "Gewitterkaiserin", "life": 26, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]},
|
||||
{"name": "Archimagier", "title": "Ewiger Meister", "life": 26, "reward": 1200, "perks": ["start_energie", "element_doppelt", "leben_35"]},
|
||||
]
|
||||
|
||||
const CHAPTERS := [STAGES, STAGES2, STAGES3]
|
||||
|
||||
static func total() -> int:
|
||||
return STAGES.size() + STAGES2.size()
|
||||
return STAGES.size() * CHAPTERS.size()
|
||||
|
||||
|
||||
static func chapter(stage: int) -> int:
|
||||
return 1 if stage < STAGES.size() else 2
|
||||
return stage / STAGES.size() + 1
|
||||
|
||||
|
||||
## Stufe mit einheitlichem Feld "perks".
|
||||
@@ -44,16 +61,19 @@ static func stage(i: int) -> Dictionary:
|
||||
var st: Dictionary = STAGES[i].duplicate()
|
||||
st["perks"] = [st.perk]
|
||||
return st
|
||||
return STAGES2[i - STAGES.size()]
|
||||
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
|
||||
|
||||
|
||||
static func opponents(stage_index: int, r: RandomNumberGenerator) -> Array:
|
||||
## Boss einer Stufe mit dem Leben, den Merkmalen und einem Stapel der Stufe; ab Kapitel 2 sind
|
||||
## 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 life: int = 35 if "leben_35" in st.perks else Game.START_LIFE
|
||||
var boss := Game.Player.new(st.name, Game.build_opponent_deck(r, st.elements, st.bias), false, life, st.title)
|
||||
var boss := Game.Player.new(st.name, false, int(st.life), st.title)
|
||||
boss.perks = Array(st.perks)
|
||||
var out := [boss]
|
||||
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
|
||||
for k in extra:
|
||||
out.append(Game.random_opponent(r, k + 1))
|
||||
return out
|
||||
if r == null:
|
||||
r = Game.make_rng()
|
||||
var ch := chapter(stage_index)
|
||||
boss.deck = DeckGen.opponent_deck(r, ch)
|
||||
for id in boss.deck:
|
||||
boss.looks[id] = ch - 1
|
||||
return boss
|
||||
|
||||
362
scripts/cards.gd
362
scripts/cards.gd
@@ -1,15 +1,25 @@
|
||||
class_name Cards
|
||||
## Elemente, Kombos und der Kartenpool.
|
||||
## Elemente, Kombonamen, Schlüsselwörter und der Kartenpool.
|
||||
|
||||
const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"]
|
||||
# Neutral ist kein Element: nie im Vorteil oder Nachteil, zählt nicht für Paare und Zähler.
|
||||
# Verschmolzen mit einem Element nimmt das Wesen im schlechten Matchup die neutrale Seite.
|
||||
const NEUTRAL := "Neutral"
|
||||
const NEUTRAL_TEXT := "Nie im Vorteil oder Nachteil"
|
||||
const ELEMENT_COLORS := {
|
||||
"Feuer": Color("be3c23"),
|
||||
"Wasser": Color("2d64be"),
|
||||
"Erde": Color("73552d"),
|
||||
"Luft": Color("78aac8"),
|
||||
"Neutral": Color("6e7382"),
|
||||
}
|
||||
# Jede Paarung ist eine Kombo, auch gleiche Elemente.
|
||||
# Jede Karte hat genau ein Element (oder ist neutral). Kombos sind die Namen verschmolzener Wesen.
|
||||
const COMBOS := {
|
||||
"Neutral+Neutral": "Veteran",
|
||||
"Feuer+Neutral": "Feuerritter",
|
||||
"Wasser+Neutral": "Wasserritter",
|
||||
"Erde+Neutral": "Erdritter",
|
||||
"Luft+Neutral": "Luftritter",
|
||||
"Feuer+Feuer": "Inferno",
|
||||
"Wasser+Wasser": "Tiefsee",
|
||||
"Erde+Erde": "Berg",
|
||||
@@ -20,236 +30,238 @@ const COMBOS := {
|
||||
"Wasser+Erde": "Schlamm",
|
||||
"Wasser+Luft": "Sturm",
|
||||
"Erde+Luft": "Sand",
|
||||
# Dreifach-Kombos: die mythische Stufe
|
||||
"Feuer+Feuer+Feuer": "Sonne",
|
||||
"Wasser+Wasser+Wasser": "Ozean",
|
||||
"Erde+Erde+Erde": "Gebirge",
|
||||
"Luft+Luft+Luft": "Himmel",
|
||||
}
|
||||
# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird.
|
||||
const FIELD_COMBO_TEXT := {
|
||||
"Dampf": "Friere das stärkste gegnerische Wesen ein",
|
||||
"Lava": "1 Schaden an alle gegnerischen Wesen",
|
||||
"Blitz": "2 Schaden an den schwächsten Gegner",
|
||||
"Schlamm": "Heile dich um 3",
|
||||
"Sturm": "Ziehe 1 Karte",
|
||||
"Sand": "Das neue Wesen erhält Schild",
|
||||
}
|
||||
const RESONANCE_TEXT := "Resonanz: Ein Wesen hat +1 Angriff je anderem eigenen Wesen, das ein Element mit ihm teilt (höchstens +4)."
|
||||
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
|
||||
# Elementkreis: Wasser löscht Feuer, Feuer verbrennt Luft, Luft trägt Erde ab, Erde saugt Wasser auf.
|
||||
const BEATS := {"Wasser": "Feuer", "Feuer": "Luft", "Luft": "Erde", "Erde": "Wasser"}
|
||||
# Seltenheit ist reine Optik: jede Karte gibt es in jeder Stufe, die Werte bleiben gleich.
|
||||
# Stufen wie bei Yu-Gi-Oh: Common grau, Rare silber, Super Rare blau, Ultra Rare gold,
|
||||
# Ultimate Rare schillert durch alle Farben.
|
||||
const RARITIES: Array[String] = ["Common", "Rare", "Super Rare", "Ultra Rare", "Ultimate Rare"]
|
||||
const RARITY_COLORS := {
|
||||
"gewöhnlich": Color("aaaaaa"),
|
||||
"selten": Color("4682e6"),
|
||||
"episch": Color("aa46dc"),
|
||||
"legendär": Color("f0aa28"),
|
||||
"mythisch": Color("ff5a8c"),
|
||||
"Common": Color("aaaaaa"),
|
||||
"Rare": Color("c8d0dc"),
|
||||
"Super Rare": Color("4682e6"),
|
||||
"Ultra Rare": Color("f0aa28"),
|
||||
"Ultimate Rare": Color("ff5a8c"),
|
||||
}
|
||||
const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"]
|
||||
const KEYWORD_HELP := {
|
||||
"Wächter": "Muss zuerst angegriffen werden.",
|
||||
"Flink": "Kann sofort angreifen.",
|
||||
"Schild": "Blockt den nächsten Schaden.",
|
||||
"Doppelschlag": "Greift zweimal pro Zug an.",
|
||||
# Eigenschaften haben Karten nicht von Haus aus; sie kommen allein vom Elementpaar der eigenen
|
||||
# Seite (ELEMENT_KEYWORD, siehe Game.Creature.has).
|
||||
const KEYWORDS: Array[String] = ["Spott", "Flink", "Regeneration", "Doppelschlag"]
|
||||
const KEYWORD_TEXT := {
|
||||
"Spott": "Muss zuerst angegriffen werden",
|
||||
"Flink": "Greift sofort an",
|
||||
"Regeneration": "Heilt sich am Zugende ganz",
|
||||
"Doppelschlag": "Greift zweimal je Zug",
|
||||
}
|
||||
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"]
|
||||
const ELEMENT_KEYWORD := {"Erde": "Spott", "Luft": "Flink", "Wasser": "Regeneration", "Feuer": "Doppelschlag"}
|
||||
const DATA_PATH := "res://data/karten.json"
|
||||
|
||||
|
||||
## Reihenfolge der Elemente in Namen und Darstellung: wie ELEMENTS, Neutral zuletzt.
|
||||
static func element_order(e: String) -> int:
|
||||
var i := ELEMENTS.find(e)
|
||||
return i if i >= 0 else ELEMENTS.size()
|
||||
|
||||
|
||||
static func combo_key(elements: Array) -> String:
|
||||
var sorted := elements.duplicate()
|
||||
sorted.sort_custom(func(a, b): return ELEMENTS.find(a) < ELEMENTS.find(b))
|
||||
sorted.sort_custom(func(a, b): return element_order(a) < element_order(b))
|
||||
return "+".join(sorted)
|
||||
|
||||
|
||||
## Name der Elementkombination eines verschmolzenen Wesens; drei oder vier Elemente sind eine Chimäre.
|
||||
static func combo_name(elements: Array) -> String:
|
||||
if elements.size() == 1:
|
||||
return elements[0]
|
||||
return COMBOS[combo_key(elements)]
|
||||
return COMBOS.get(combo_key(elements), "Chimäre")
|
||||
|
||||
|
||||
static func field_combo_effect(name: String) -> Effect:
|
||||
match name:
|
||||
"Dampf":
|
||||
return Effect.new("freeze", "strongest_enemy_creature")
|
||||
"Lava":
|
||||
return Effect.new("damage", "all_enemy_creatures", 1)
|
||||
"Blitz":
|
||||
return Effect.new("damage", "weakest_enemy_hero", 2)
|
||||
"Schlamm":
|
||||
return Effect.new("heal", "self_hero", 3)
|
||||
"Sturm":
|
||||
return Effect.new("draw", "none", 1)
|
||||
"Sand":
|
||||
return Effect.new("shield", "self_creature")
|
||||
return null
|
||||
## Elementvorteil aus Sicht von own: +1 im Vorteil, -1 im Nachteil, sonst 0.
|
||||
## Hat ein Wesen mehrere Elemente, kämpft es mit dem, das gegen die Gegenseite am besten steht.
|
||||
static func matchup(own: Array, other: Array) -> int:
|
||||
var mine := best_element(own, other)
|
||||
var theirs := best_element(other, own)
|
||||
if mine == "" or theirs == "":
|
||||
return 0
|
||||
return duel(mine, theirs)
|
||||
|
||||
|
||||
const TRIGGER_TEXT := {
|
||||
"attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift",
|
||||
"creature_played": "Wenn ein Gegner ein Wesen ausspielt",
|
||||
}
|
||||
## +1, wenn a b schlägt, -1, wenn b a schlägt, sonst 0.
|
||||
static func duel(a: String, b: String) -> int:
|
||||
if BEATS.get(a, "") == b:
|
||||
return 1
|
||||
if BEATS.get(b, "") == a:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
|
||||
class Effect extends RefCounted:
|
||||
var kind: String # damage, heal, draw, freeze, bounce, buff, shield, summon, cancel
|
||||
var target: String # siehe CHOSEN_TARGETS und Game.resolve()
|
||||
var amount: int
|
||||
var amount2: int
|
||||
var card_id: String # für summon
|
||||
## Das Element aus own, das gegen die Elemente in other am besten steht.
|
||||
static func best_element(own: Array, other: Array) -> String:
|
||||
var best := ""
|
||||
var best_score := -9
|
||||
for e in own:
|
||||
var score := 0
|
||||
for o in other:
|
||||
score += duel(e, o)
|
||||
if score > best_score:
|
||||
best = e
|
||||
best_score = score
|
||||
return best
|
||||
|
||||
func _init(k: String, t: String, a: int = 0, a2: int = 0, cid: String = "") -> void:
|
||||
kind = k
|
||||
target = t
|
||||
amount = a
|
||||
amount2 = a2
|
||||
card_id = cid
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
var d := {"kind": kind, "target": target}
|
||||
if amount != 0:
|
||||
d["amount"] = amount
|
||||
if amount2 != 0:
|
||||
d["amount2"] = amount2
|
||||
if card_id != "":
|
||||
d["card_id"] = card_id
|
||||
return d
|
||||
## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts).
|
||||
static func cost_of(attack: int, health: int) -> int:
|
||||
return mini(10, (2 * attack + health) / 3)
|
||||
|
||||
static func from_dict(d: Dictionary) -> Effect:
|
||||
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
|
||||
|
||||
func text() -> String:
|
||||
var n := str(amount)
|
||||
if target == "trigger_source":
|
||||
match kind:
|
||||
"damage":
|
||||
return n + " Schaden an den Angreifer"
|
||||
"freeze":
|
||||
return "Der Angreifer wird eingefroren"
|
||||
"bounce":
|
||||
return "Es geht zurück auf die Hand"
|
||||
match kind:
|
||||
"cancel":
|
||||
return "Der Angriff verpufft"
|
||||
"summon":
|
||||
return "Beschwöre einen " + Cards.by_id(card_id).name
|
||||
"damage":
|
||||
match target:
|
||||
"enemy_creature":
|
||||
return n + " Schaden an ein gegnerisches Wesen"
|
||||
"enemy_any":
|
||||
return n + " Schaden an ein Ziel"
|
||||
"all_enemy_creatures":
|
||||
return n + " Schaden an alle gegnerischen Wesen"
|
||||
"all_enemy_heroes":
|
||||
return n + " Schaden an alle Gegner"
|
||||
"all_enemies":
|
||||
return n + " Schaden an alle Gegner und ihre Wesen"
|
||||
"heal":
|
||||
return "Heile dich um " + n
|
||||
"draw":
|
||||
return "Ziehe 1 Karte" if amount == 1 else "Ziehe " + n + " Karten"
|
||||
"freeze":
|
||||
if target == "enemy_creature":
|
||||
return "Friere ein gegnerisches Wesen ein"
|
||||
return "Friere alle gegnerischen Wesen ein"
|
||||
"bounce":
|
||||
if target == "enemy_creature":
|
||||
return "Ein gegnerisches Wesen zurück auf die Hand"
|
||||
return "Alle gegnerischen Wesen zurück auf die Hand"
|
||||
"buff":
|
||||
var b := "+%d/+%d" % [amount, amount2]
|
||||
if target == "friendly_creature":
|
||||
return "Ein befreundetes Wesen erhält " + b
|
||||
return "Alle befreundeten Wesen erhalten " + b
|
||||
"shield":
|
||||
if target == "friendly_creature":
|
||||
return "Ein befreundetes Wesen erhält Schild"
|
||||
if target == "self_creature":
|
||||
return "Erhält Schild"
|
||||
return "Alle befreundeten Wesen erhalten Schild"
|
||||
return ""
|
||||
## Wert eines verschmolzenen Wesens aus den Grundwerten der Eltern: die aufgerundete Hälfte der
|
||||
## Summe plus eins. Ein starker und ein schwacher Partner ergeben also weniger als der starke.
|
||||
static func fusion_value(a: int, b: int) -> int:
|
||||
return ceili((a + b) / 2.0) + 1
|
||||
|
||||
|
||||
## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt).
|
||||
static func fusion_name(elements: Array) -> String:
|
||||
return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]])
|
||||
|
||||
|
||||
## Verschmelzung zweier Karten: Elemente (sortiert) und Eigenschaften beider, Angriff und Leben
|
||||
## nach fusion_value aus den Grundwerten, Kosten daraus wie bei jeder Karte; dann die Stufe level
|
||||
## mit ihrem +1/+1 (ohne Angabe die höhere der Eltern). Das Bild trägt der stärkere Elternteil
|
||||
## (Angriff plus Leben).
|
||||
static func fused(a: Card, b: Card, level: int = -1) -> Card:
|
||||
var attack := fusion_value(a.base_attack(), b.base_attack())
|
||||
var health := fusion_value(a.base_health(), b.base_health())
|
||||
var els := []
|
||||
for e in a.elements + b.elements:
|
||||
if not (e in els):
|
||||
els.append(e)
|
||||
els.sort_custom(func(x, y): return element_order(x) < element_order(y))
|
||||
var c := Card.new()
|
||||
c.id = "fus_%s_%s" % [a.id, b.id]
|
||||
c.name = fusion_name(els)
|
||||
c.elements = els
|
||||
c.attack = attack
|
||||
c.health = health
|
||||
c.cost = cost_of(attack, health)
|
||||
for k in KEYWORDS:
|
||||
if k in a.keywords or k in b.keywords:
|
||||
c.keywords.append(k)
|
||||
var strong := a if a.attack + a.health >= b.attack + b.health else b
|
||||
c.art = strong.art_card().id
|
||||
return c.leveled(maxi(level, maxi(maxi(a.look, b.look), 0)))
|
||||
|
||||
|
||||
## Platzhalter für ein verdecktes gegnerisches Wesen: unbekanntes Element, geschätzte Werte.
|
||||
static func unknown() -> Card:
|
||||
var c := Card.new()
|
||||
c.id = "?"
|
||||
c.name = "Verdeckt"
|
||||
c.name_en = "Face-down"
|
||||
c.elements = []
|
||||
c.attack = 2
|
||||
c.health = 3
|
||||
c.cost = cost_of(2, 3)
|
||||
return c
|
||||
|
||||
|
||||
class Card extends RefCounted:
|
||||
var id: String
|
||||
var name: String
|
||||
var name: String # deutscher Name (auch im Verlauf und in Tests)
|
||||
var name_en := "" # englischer Name
|
||||
var elements: Array
|
||||
var cost: int
|
||||
var rarity: String
|
||||
var kind: String # "Wesen", "Zauber" oder "Falle"
|
||||
var attack: int = 0
|
||||
var health: int = 0
|
||||
var keywords: Array = []
|
||||
var effects: Array = []
|
||||
var trigger: String = "" # nur Fallen: attacked, creature_played
|
||||
|
||||
func is_creature() -> bool:
|
||||
return kind == "Wesen"
|
||||
|
||||
func is_trap() -> bool:
|
||||
return kind == "Falle"
|
||||
var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet, höchstens 10 (aus den Grundwerten)
|
||||
var keywords: Array = [] # feste Eigenschaften der Karte (Elementpaare geben ihre zur Laufzeit)
|
||||
var look := -1 # Seltenheitsstufe des Exemplars (gibt +1/+1 je Stufe); -1: Grundkarte aus dem Pool
|
||||
var art := "" # verschmolzen: Karte, deren Bild das Wesen trägt (der stärkere Elternteil)
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
var d := {"id": id, "name": name, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind}
|
||||
if is_creature():
|
||||
d["attack"] = attack
|
||||
d["health"] = health
|
||||
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health}
|
||||
if not keywords.is_empty():
|
||||
d["keywords"] = keywords
|
||||
if not effects.is_empty():
|
||||
d["effects"] = effects.map(func(e): return e.to_dict())
|
||||
if trigger != "":
|
||||
d["trigger"] = trigger
|
||||
if look >= 0:
|
||||
d["look"] = look
|
||||
if art != "":
|
||||
d["art"] = art
|
||||
if cost != Cards.cost_of(attack, health):
|
||||
d["cost"] = cost
|
||||
return d
|
||||
|
||||
## Wert der Karte: was sie über ihre Manakosten hinaus bringt, mal 100. Grundkarten liegen
|
||||
## bei 100, jede Seltenheitsstufe gibt 100 dazu (Ultimate Rare 500); Fusionen aus ihren Werten.
|
||||
func value() -> int:
|
||||
return (Cards.cost_of(attack, health) - cost + 1) * 100
|
||||
|
||||
## Grundwerte ohne den Stufenbonus.
|
||||
func base_attack() -> int:
|
||||
return attack - maxi(look, 0)
|
||||
|
||||
func base_health() -> int:
|
||||
return health - maxi(look, 0)
|
||||
|
||||
## Stufe dieser Karte für einen Besitzer mit dieser Sammlung (Game.Player.looks).
|
||||
func rarity_in(looks: Dictionary) -> String:
|
||||
return Cards.RARITIES[look if look >= 0 else int(looks.get(id, 0))]
|
||||
|
||||
static func from_dict(d: Dictionary) -> Card:
|
||||
var c := Card.new()
|
||||
c.id = d.id
|
||||
c.name = d.name
|
||||
c.name_en = str(d.get("name_en", ""))
|
||||
c.elements = Array(d.elements)
|
||||
c.cost = int(d.cost)
|
||||
c.rarity = d.rarity
|
||||
c.kind = d.kind
|
||||
c.attack = int(d.get("attack", 0))
|
||||
c.health = int(d.get("health", 0))
|
||||
c.cost = int(d.get("cost", Cards.cost_of(c.attack, c.health)))
|
||||
c.keywords = Array(d.get("keywords", []))
|
||||
for e in d.get("effects", []):
|
||||
c.effects.append(Effect.from_dict(e))
|
||||
c.trigger = str(d.get("trigger", ""))
|
||||
c.look = int(d.get("look", -1))
|
||||
c.art = str(d.get("art", ""))
|
||||
return c
|
||||
|
||||
## Karte, deren Bild gezeichnet wird: bei Verschmelzungen der stärkere Elternteil, sonst sie selbst.
|
||||
func art_card() -> Card:
|
||||
return Cards.by_id(art) if art != "" and Cards._by_id.has(art) else self
|
||||
|
||||
## Eigenes Exemplar (Hand, Brett): Schlüsselwörter gehören dem Exemplar, nicht dem Pool.
|
||||
func copy() -> Card:
|
||||
return Card.from_dict(to_dict())
|
||||
|
||||
## Exemplar in einer Seltenheitsstufe: +1/+1 je Stufe, die Kosten bleiben die der Grundkarte.
|
||||
func leveled(level: int) -> Card:
|
||||
var c := copy()
|
||||
c.look = level
|
||||
c.attack += level
|
||||
c.health += level
|
||||
c.cost = cost
|
||||
return c
|
||||
|
||||
## Anzeigename in der aktuellen Sprache.
|
||||
func title() -> String:
|
||||
if name_en != "" and TranslationServer.get_locale().begins_with("en"):
|
||||
return name_en
|
||||
return name
|
||||
|
||||
func is_combo() -> bool:
|
||||
return elements.size() >= 2
|
||||
|
||||
func combo() -> String:
|
||||
return Cards.combo_name(elements)
|
||||
|
||||
func target_type() -> String:
|
||||
for fx in effects:
|
||||
if fx.target in Cards.CHOSEN_TARGETS:
|
||||
return fx.target
|
||||
return ""
|
||||
func is_neutral() -> bool:
|
||||
return Cards.NEUTRAL in elements
|
||||
|
||||
## Kartentext: Neutral und die festen Eigenschaften.
|
||||
func text() -> String:
|
||||
var parts: Array[String] = []
|
||||
if not keywords.is_empty():
|
||||
var kws: Array[String] = []
|
||||
if is_neutral():
|
||||
kws.append(tr(Cards.NEUTRAL))
|
||||
for k in Cards.KEYWORDS:
|
||||
if k in keywords:
|
||||
kws.append(k)
|
||||
parts.append(", ".join(kws) + ".")
|
||||
if not effects.is_empty():
|
||||
var fx: Array[String] = []
|
||||
for f in effects:
|
||||
fx.append(f.text())
|
||||
var s := ", ".join(fx) + "."
|
||||
if is_trap():
|
||||
if trigger == "creature_played":
|
||||
s = s.replace("an den Angreifer", "an dieses Wesen").replace("Der Angreifer wird", "Es wird")
|
||||
parts.append("Falle. %s: %s" % [Cards.TRIGGER_TEXT[trigger], s])
|
||||
else:
|
||||
parts.append(("Beim Ausspielen: " + s) if is_creature() else s)
|
||||
return " ".join(parts)
|
||||
kws.append(tr(k))
|
||||
if kws.is_empty():
|
||||
return ""
|
||||
return ", ".join(kws) + "."
|
||||
|
||||
|
||||
static var _all: Array = []
|
||||
@@ -274,10 +286,6 @@ static func has_id(id) -> bool:
|
||||
return _by_id.has(id)
|
||||
|
||||
|
||||
static func of_rarity(r: String) -> Array:
|
||||
return all().filter(func(c): return c.rarity == r)
|
||||
|
||||
|
||||
## Lädt den Kartenpool aus data/karten.json.
|
||||
static func _build() -> void:
|
||||
var json := JSON.new()
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
uid://n5h6q6kfoo34
|
||||
71
scripts/deckgen.gd
Normal file
71
scripts/deckgen.gd
Normal file
@@ -0,0 +1,71 @@
|
||||
class_name DeckGen
|
||||
## Stapel: die vier Startdecks, Auffüllen aus der Sammlung, zufällige Stapel für Gegner.
|
||||
|
||||
const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens
|
||||
|
||||
|
||||
## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang.
|
||||
static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array:
|
||||
var cheap := Cards.all().filter(func(c): return c.cost <= Game.START_MANA)
|
||||
var rest := Cards.all().filter(func(c): return c.cost > Game.START_MANA and c.cost <= max_cost)
|
||||
Game.shuffle(cheap, r)
|
||||
Game.shuffle(rest, r)
|
||||
var out := []
|
||||
for c in cheap.slice(0, CHEAP_IN_RANDOM) + rest + cheap.slice(CHEAP_IN_RANDOM):
|
||||
if out.size() >= Game.DECK_MIN:
|
||||
break
|
||||
out.append(c.id)
|
||||
return out
|
||||
|
||||
|
||||
## Stapel eines Kampagnengegners: Kapitel 1 bis Kosten 3, Kapitel 2 bis 5, Kapitel 3 alles.
|
||||
static func opponent_deck(r: RandomNumberGenerator, chapter: int) -> Array:
|
||||
return random_deck(r, [3, 5, 99][clampi(chapter - 1, 0, 2)])
|
||||
|
||||
|
||||
## Startdeck für ein Element: die 25 Karten des Elements bis Angriff 4, dazu je fünf billige der
|
||||
## 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
|
||||
var cheap := Cards.all().filter(func(c): return c.elements[0] == e and c.cost <= Game.START_MANA)
|
||||
cheap.sort_custom(func(a, b): return a.cost < b.cost if a.cost != b.cost else a.attack > b.attack)
|
||||
for c in cheap.slice(0, 5):
|
||||
out.append(c.id)
|
||||
return out
|
||||
|
||||
|
||||
## Füllt deck aus pool (Karten-IDs) bis limit auf, die wertvollsten zuerst (Card.value in der
|
||||
## Stufe aus levels: ID -> Stufe), bei gleichem Wert die teuersten; nichts doppelt.
|
||||
static func fill(deck: Array, pool: Array, limit: int, levels: Dictionary = {}) -> Array:
|
||||
var out := deck.duplicate()
|
||||
var cards := pool.filter(func(id): return not (id in out)).map(func(id): return Cards.by_id(id).leveled(int(levels.get(id, 0))))
|
||||
cards.sort_custom(func(a, b):
|
||||
if a.value() != b.value():
|
||||
return a.value() > b.value()
|
||||
return a.cost > b.cost if a.cost != b.cost else a.id < b.id)
|
||||
for c in cards:
|
||||
if out.size() >= limit:
|
||||
break
|
||||
out.append(c.id)
|
||||
return out
|
||||
|
||||
|
||||
## Stärke eines Stapels: der mittlere Kartenwert (Card.value) in den Stufen aus levels,
|
||||
## 100 für lauter Grundkarten bis 500 für lauter Ultimate Rare; leer: 0.
|
||||
static func strength(deck: Array, levels: Dictionary) -> int:
|
||||
if deck.is_empty():
|
||||
return 0
|
||||
var sum := 0
|
||||
for id in deck:
|
||||
sum += Cards.by_id(id).leveled(int(levels.get(id, 0))).value()
|
||||
return roundi(float(sum) / deck.size())
|
||||
|
||||
|
||||
static func random_opponent(life: int = Game.START_LIFE) -> Game.Player:
|
||||
return Game.Player.new("Gegner", false, life)
|
||||
1
scripts/deckgen.gd.uid
Normal file
1
scripts/deckgen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ev8t5ar278vl
|
||||
941
scripts/game.gd
941
scripts/game.gd
File diff suppressed because it is too large
Load Diff
279
scripts/net.gd
279
scripts/net.gd
@@ -1,279 +0,0 @@
|
||||
class_name Net
|
||||
extends Node
|
||||
## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten
|
||||
## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen).
|
||||
|
||||
signal lobby_changed
|
||||
signal started(seat: int)
|
||||
signal snapshot_received(snap: Dictionary)
|
||||
signal action_applied
|
||||
signal ended(reason: String)
|
||||
|
||||
const PORT := 24455
|
||||
|
||||
var peer: ENetMultiplayerPeer
|
||||
var is_host := false
|
||||
var connected := false
|
||||
var my_name := "Spieler"
|
||||
var my_deck: Array = [] # Karten-IDs
|
||||
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
|
||||
var decks := {} # peer_id -> Karten-IDs (Host)
|
||||
var seats := {} # peer_id -> Spielerindex (Host)
|
||||
var my_seat := -1
|
||||
var game: Game # Host
|
||||
var status := ""
|
||||
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
|
||||
const REJOIN_SECONDS := 60.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
multiplayer.connected_to_server.connect(_on_connected)
|
||||
multiplayer.connection_failed.connect(func(): _fail("Verbindung fehlgeschlagen"))
|
||||
multiplayer.server_disconnected.connect(func(): _fail("Host hat das Spiel beendet"))
|
||||
|
||||
|
||||
func host(port: int = PORT) -> Error:
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, Game.MAX_OPPONENTS)
|
||||
if err != OK:
|
||||
status = "Kann nicht hosten (Port %d belegt?)" % port
|
||||
return err
|
||||
multiplayer.multiplayer_peer = peer
|
||||
is_host = true
|
||||
connected = true
|
||||
names = {1: my_name}
|
||||
decks = {1: my_deck}
|
||||
status = "Warte auf Mitspieler …"
|
||||
lobby_changed.emit()
|
||||
return OK
|
||||
|
||||
|
||||
func join(ip: String, port: int = PORT) -> Error:
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(ip, port)
|
||||
if err != OK:
|
||||
status = "Adresse ungültig"
|
||||
return err
|
||||
multiplayer.multiplayer_peer = peer
|
||||
is_host = false
|
||||
status = "Verbinde mit %s …" % ip
|
||||
return OK
|
||||
|
||||
|
||||
func leave() -> void:
|
||||
if multiplayer.multiplayer_peer != null:
|
||||
multiplayer.multiplayer_peer.close()
|
||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||
peer = null
|
||||
is_host = false
|
||||
connected = false
|
||||
names = {}
|
||||
decks = {}
|
||||
seats = {}
|
||||
waiting = {}
|
||||
my_seat = -1
|
||||
game = null
|
||||
status = ""
|
||||
|
||||
|
||||
func local_addresses() -> Array:
|
||||
return Array(IP.get_local_addresses()).filter(func(a): return "." in a and not a.begins_with("127."))
|
||||
|
||||
|
||||
func _fail(reason: String) -> void:
|
||||
status = reason
|
||||
ended.emit(reason)
|
||||
leave()
|
||||
|
||||
|
||||
func _on_connected() -> void:
|
||||
connected = true
|
||||
status = "Verbunden"
|
||||
register.rpc_id(1, my_name, my_deck)
|
||||
|
||||
|
||||
func _on_peer_connected(_id: int) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func _on_peer_disconnected(id: int) -> void:
|
||||
if not is_host:
|
||||
return
|
||||
var pname: String = names.get(id, "")
|
||||
names.erase(id)
|
||||
decks.erase(id)
|
||||
if game != null and seats.has(id):
|
||||
var p: Game.Player = game.players[seats[id]]
|
||||
if game.winner == "":
|
||||
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
|
||||
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(REJOIN_SECONDS)])
|
||||
seats.erase(id)
|
||||
action_applied.emit()
|
||||
lobby_update.rpc(names)
|
||||
lobby_changed.emit()
|
||||
|
||||
|
||||
## Host, je Bild: wartende Spieler überspringen; nach Ablauf der Frist scheiden sie aus.
|
||||
func tick() -> void:
|
||||
if not is_host or game == null or game.winner != "":
|
||||
return
|
||||
var now := Time.get_unix_time_from_system()
|
||||
for pname in waiting.keys():
|
||||
var w: Dictionary = waiting[pname]
|
||||
var p: Game.Player = game.players[w.seat]
|
||||
if now - float(w.since) > REJOIN_SECONDS:
|
||||
waiting.erase(pname)
|
||||
game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
|
||||
game.damage(p, 999)
|
||||
game.check_end()
|
||||
action_applied.emit()
|
||||
elif game.current() == p:
|
||||
p.mulligan_done = true
|
||||
game.say("%s ist nicht da, der Zug wird übersprungen." % p.name)
|
||||
game.end_turn()
|
||||
action_applied.emit()
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func register(name: String, deck_ids: Array) -> void:
|
||||
if not is_host:
|
||||
return
|
||||
var id := multiplayer.get_remote_sender_id()
|
||||
if game != null:
|
||||
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
|
||||
if waiting.has(name):
|
||||
seats[id] = waiting[name].seat
|
||||
names[id] = name
|
||||
waiting.erase(name)
|
||||
game.say("%s ist zurück." % name)
|
||||
start_game.rpc_id(id, seats[id])
|
||||
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
||||
action_applied.emit()
|
||||
else:
|
||||
status = "%s wollte beitreten, das Spiel läuft schon" % name
|
||||
return
|
||||
names[id] = name
|
||||
decks[id] = deck_ids
|
||||
lobby_update.rpc(names)
|
||||
lobby_changed.emit()
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func lobby_update(n: Dictionary) -> void:
|
||||
names = n
|
||||
lobby_changed.emit()
|
||||
|
||||
|
||||
## Host: Spiel mit allen Verbundenen und n_ai KI-Gegnern starten.
|
||||
func start_battle(n_ai: int) -> void:
|
||||
var ids: Array = names.keys()
|
||||
ids.sort()
|
||||
var r := Game.make_rng()
|
||||
var players := []
|
||||
seats = {}
|
||||
for id in ids:
|
||||
var deck_ids: Array = decks.get(id, [])
|
||||
var deck := []
|
||||
for cid in deck_ids:
|
||||
if Cards.has_id(cid):
|
||||
deck.append(Cards.by_id(cid))
|
||||
if Game.validate_deck(deck)[0] == false:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
Game.shuffle(pool, r)
|
||||
deck = Game.build_opponent_deck(r, [pool[0], pool[1]])
|
||||
players.append(Game.Player.new(names[id], deck, true, Game.START_LIFE))
|
||||
for i in n_ai:
|
||||
players.append(Game.random_opponent(r, i + 1))
|
||||
game = Game.new(players[0], players.slice(1), r)
|
||||
game.start()
|
||||
for id in ids:
|
||||
seats[id] = game.players.find(players[ids.find(id)])
|
||||
my_seat = seats[1]
|
||||
for id in ids:
|
||||
if id != 1:
|
||||
start_game.rpc_id(id, seats[id])
|
||||
started.emit(my_seat)
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func start_game(seat: int) -> void:
|
||||
my_seat = seat
|
||||
started.emit(seat)
|
||||
|
||||
|
||||
## Host: Zustand an alle Mitspieler senden (jeder aus seiner Sicht).
|
||||
func broadcast() -> void:
|
||||
if not is_host or game == null:
|
||||
return
|
||||
for id in seats:
|
||||
if id != 1:
|
||||
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func receive_snapshot(snap: Dictionary) -> void:
|
||||
snapshot_received.emit(snap)
|
||||
|
||||
|
||||
## Mitspieler: Aktion an den Host schicken.
|
||||
func send_action(action: Dictionary) -> void:
|
||||
if is_host:
|
||||
_apply(1, action)
|
||||
else:
|
||||
request_action.rpc_id(1, action)
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func request_action(action: Dictionary) -> void:
|
||||
if not is_host:
|
||||
return
|
||||
_apply(multiplayer.get_remote_sender_id(), action)
|
||||
|
||||
|
||||
func _apply(id: int, action: Dictionary) -> void:
|
||||
if game == null or not seats.has(id):
|
||||
return
|
||||
var p: Game.Player = game.players[seats[id]]
|
||||
if game.current() != p or game.winner != "":
|
||||
return
|
||||
match str(action.get("type", "")):
|
||||
"play":
|
||||
var idx := int(action.get("hand", -1))
|
||||
if idx < 0 or idx >= p.hand.size():
|
||||
return
|
||||
game.play_card(p, p.hand[idx], resolve_ref(action.get("target")))
|
||||
"attack":
|
||||
var cr := game.find_creature(int(action.get("uid", -1)))
|
||||
var t = resolve_ref(action.get("target"))
|
||||
if cr != null and t != null:
|
||||
game.attack(cr, t)
|
||||
"mulligan":
|
||||
var cards := []
|
||||
for i in action.get("hand", []):
|
||||
if int(i) >= 0 and int(i) < p.hand.size():
|
||||
cards.append(p.hand[int(i)])
|
||||
game.mulligan(p, cards)
|
||||
"end":
|
||||
game.end_turn()
|
||||
_:
|
||||
return
|
||||
action_applied.emit()
|
||||
|
||||
|
||||
func resolve_ref(ref):
|
||||
if ref == null or not (ref is Dictionary):
|
||||
return null
|
||||
if ref.get("kind") == "creature":
|
||||
return game.find_creature(int(ref.get("uid", -1)))
|
||||
var i := int(ref.get("index", -1))
|
||||
if i >= 0 and i < game.players.size():
|
||||
return game.players[i]
|
||||
return null
|
||||
|
||||
|
||||
static func ref_dict(target) -> Dictionary:
|
||||
if target is Game.Creature:
|
||||
return {"kind": "creature", "uid": target.uid}
|
||||
return {"kind": "player", "index": target.order}
|
||||
@@ -1 +0,0 @@
|
||||
uid://drhh23lblvq8o
|
||||
@@ -1,99 +1,59 @@
|
||||
class_name Profile
|
||||
extends RefCounted
|
||||
## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Statistik, Erfolge, Tagesaufgabe, Einstellungen.
|
||||
## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen.
|
||||
## Man startet mit 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 PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
|
||||
const SAVE_VERSION := 7
|
||||
const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
|
||||
const SAVE_PATH := "user://spielstand.json"
|
||||
const BOOSTER_COST := 100
|
||||
const BOOSTER_SIZE := 5
|
||||
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
|
||||
const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0}
|
||||
const START_COINS := 150
|
||||
const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300}
|
||||
const REWARD_WIN := 100
|
||||
const REWARD_LOSS := 50
|
||||
const DECK_SLOTS := 5
|
||||
# Werkstatt: Zerlegen bringt Staub, Herstellen kostet Staub.
|
||||
const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600}
|
||||
const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200}
|
||||
|
||||
const ACHIEVEMENTS := [
|
||||
{"id": "erster_sieg", "name": "Erster Sieg", "text": "Gewinne einen Kampf.", "reward": 50},
|
||||
{"id": "duellant", "name": "Duellant", "text": "Gewinne ein Duell.", "reward": 50},
|
||||
{"id": "tischherr", "name": "Herr des Tisches", "text": "Gewinne gegen 3 oder mehr Gegner.", "reward": 100},
|
||||
{"id": "chaosmeister", "name": "Chaosmeister", "text": "Gewinne gegen 5 Gegner.", "reward": 200},
|
||||
{"id": "sammler", "name": "Sammler", "text": "Besitze 40 verschiedene Karten.", "reward": 100},
|
||||
{"id": "vollstaendig", "name": "Vollständig", "text": "Besitze jede Karte.", "reward": 500},
|
||||
{"id": "boosterfreund", "name": "Boosterfreund", "text": "Öffne 10 Booster.", "reward": 100},
|
||||
{"id": "elementarist", "name": "Elementarist", "text": "Gewinne mit einem Deck aus nur einem Element.", "reward": 100},
|
||||
{"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100},
|
||||
{"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300},
|
||||
{"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500},
|
||||
{"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150},
|
||||
{"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100},
|
||||
]
|
||||
|
||||
const DAILY_TASKS := [
|
||||
{"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80},
|
||||
{"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60},
|
||||
{"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40},
|
||||
{"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50},
|
||||
{"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50},
|
||||
{"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60},
|
||||
{"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50},
|
||||
{"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120},
|
||||
{"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90},
|
||||
{"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90},
|
||||
{"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90},
|
||||
{"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90},
|
||||
]
|
||||
|
||||
static var today_override := "" # für Tests
|
||||
# Schwierigkeit: Leben der Gegner, KI und Münzen.
|
||||
const DIFFICULTIES := {
|
||||
"leicht": {"name": "Leicht", "life": 25, "reward": 0.75, "smart": false},
|
||||
"normal": {"name": "Normal", "life": 30, "reward": 1.0, "smart": true},
|
||||
"schwer": {"name": "Schwer", "life": 35, "reward": 1.5, "smart": true},
|
||||
}
|
||||
# Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub.
|
||||
const DUST_DISMANTLE := {"Common": 50, "Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
|
||||
const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
|
||||
|
||||
var coins := 0
|
||||
var dust := 0
|
||||
var collection := {} # id -> Anzahl
|
||||
var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids}
|
||||
var active_deck := 0
|
||||
var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}}
|
||||
var achievements: Array = []
|
||||
var collection := {} # eigene Karten: id -> Seltenheitsstufe (Index in Cards.RARITIES)
|
||||
var deck: Array = [] # Karten-IDs des eigenen Stapels (Game.DECK_MIN bis DECK_MAX)
|
||||
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
|
||||
var daily := {"date": "", "task": "", "progress": 0, "done": false}
|
||||
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de"}
|
||||
var notices: Array[String] = [] # Meldungen für die Oberfläche (Erfolge, Tagesaufgabe)
|
||||
var card_stats := {} # id -> {"played", "games", "wins"}
|
||||
var booster_since_mythic := 0
|
||||
var tutorial_done := false
|
||||
var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
|
||||
var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
|
||||
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
|
||||
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0}
|
||||
var challenge_week := -1 # Woche der zuletzt bestandenen Herausforderung
|
||||
|
||||
## Das aktive Deck (Kartenliste des gewählten Slots).
|
||||
var deck: Array:
|
||||
get:
|
||||
return decks[active_deck].cards
|
||||
set(value):
|
||||
decks[active_deck].cards = value
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
for i in DECK_SLOTS:
|
||||
decks.append({"name": "Deck %d" % (i + 1), "cards": []})
|
||||
|
||||
|
||||
## Neues Profil: noch ohne Karten, das Startdeck wählt der Spieler (choose_start).
|
||||
static func starter() -> Profile:
|
||||
var p := Profile.new()
|
||||
p.coins = START_COINS
|
||||
for c in Cards.of_rarity("gewöhnlich"):
|
||||
if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"):
|
||||
p.collection[c.id] = 2
|
||||
var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche",
|
||||
"e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"]
|
||||
for cid in starter_ids:
|
||||
p.deck.append(cid)
|
||||
p.deck.append(cid)
|
||||
return p
|
||||
|
||||
|
||||
## 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.
|
||||
static func load_or_new(path: String = SAVE_PATH) -> Profile:
|
||||
for candidate in [path, path + ".bak"]:
|
||||
@@ -108,71 +68,39 @@ static func load_or_new(path: String = SAVE_PATH) -> Profile:
|
||||
return p
|
||||
|
||||
|
||||
## Liest jede Spielstand-Version; fehlende Felder bekommen Standardwerte (Migration).
|
||||
## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und
|
||||
## Einstellungen und bekommen die Startsammlung.
|
||||
static func from_dict(d: Dictionary) -> Profile:
|
||||
var p := Profile.new()
|
||||
var version := int(d.get("version", 1))
|
||||
p.coins = int(d.get("coins", 0))
|
||||
p.dust = int(d.get("dust", 0))
|
||||
p.campaign_stage = int(d.get("campaign_stage", 0))
|
||||
if int(d.get("version", 1)) >= SAVE_VERSION:
|
||||
var coll = d.get("collection", {})
|
||||
for cid in coll:
|
||||
if Cards.has_id(cid):
|
||||
p.collection[cid] = int(coll[cid])
|
||||
if version >= 3:
|
||||
var list = d.get("decks", [])
|
||||
for i in mini(list.size(), DECK_SLOTS):
|
||||
p.decks[i].name = str(list[i].get("name", "Deck %d" % (i + 1)))
|
||||
p.decks[i].cards = []
|
||||
for cid in list[i].get("cards", []):
|
||||
if Cards.has_id(cid):
|
||||
p.decks[i].cards.append(cid)
|
||||
p.active_deck = clampi(int(d.get("active_deck", 0)), 0, DECK_SLOTS - 1)
|
||||
else:
|
||||
p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
|
||||
var ids := []
|
||||
for cid in d.get("deck", []):
|
||||
if Cards.has_id(cid):
|
||||
p.decks[0].cards.append(cid)
|
||||
if version >= 2:
|
||||
var st = d.get("stats", {})
|
||||
p.stats.games = int(st.get("games", 0))
|
||||
p.stats.wins = int(st.get("wins", 0))
|
||||
p.stats.boosters = int(st.get("boosters", 0))
|
||||
for k in st.get("wins_by_opp", {}):
|
||||
p.stats.wins_by_opp[str(k)] = int(st.wins_by_opp[k])
|
||||
for a in d.get("achievements", []):
|
||||
p.achievements.append(str(a))
|
||||
p.campaign_stage = int(d.get("campaign_stage", 0))
|
||||
var dl = d.get("daily", {})
|
||||
p.daily = {"date": str(dl.get("date", "")), "task": str(dl.get("task", "")),
|
||||
"progress": int(dl.get("progress", 0)), "done": bool(dl.get("done", false))}
|
||||
if p.collection.has(cid) and not (cid in ids):
|
||||
ids.append(cid)
|
||||
p.deck = ids.slice(0, Game.DECK_MAX)
|
||||
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)),
|
||||
"language": str(se.get("language", "de"))}
|
||||
if version >= 4:
|
||||
for cid in d.get("card_stats", {}):
|
||||
if Cards.has_id(cid):
|
||||
var st = d.card_stats[cid]
|
||||
p.card_stats[cid] = {"played": int(st.get("played", 0)), "games": int(st.get("games", 0)), "wins": int(st.get("wins", 0))}
|
||||
p.booster_since_mythic = int(d.get("booster_since_mythic", 0))
|
||||
p.tutorial_done = bool(d.get("tutorial_done", false))
|
||||
if version >= 5:
|
||||
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
|
||||
"big_font": bool(se.get("big_font", false))}
|
||||
if not DIFFICULTIES.has(p.settings.difficulty):
|
||||
p.settings.difficulty = "normal"
|
||||
p.perk = str(d.get("perk", ""))
|
||||
if not Game.PERKS.has(p.perk):
|
||||
p.perk = ""
|
||||
var rc = d.get("records", {})
|
||||
p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)),
|
||||
"fastest_win": int(rc.get("fastest_win", 0))}
|
||||
p.challenge_week = int(d.get("challenge_week", -1))
|
||||
return p
|
||||
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection,
|
||||
"decks": decks, "active_deck": active_deck,
|
||||
"stats": stats, "achievements": achievements, "campaign_stage": campaign_stage,
|
||||
"daily": daily, "settings": settings, "card_stats": card_stats,
|
||||
"booster_since_mythic": booster_since_mythic, "tutorial_done": tutorial_done,
|
||||
"perk": perk, "records": records, "challenge_week": challenge_week}
|
||||
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, "deck": deck,
|
||||
"campaign_stage": campaign_stage, "settings": settings, "perk": perk}
|
||||
|
||||
|
||||
## Speichert und sichert den vorherigen Stand als .bak.
|
||||
@@ -191,329 +119,153 @@ func take_notices() -> Array[String]:
|
||||
return out
|
||||
|
||||
|
||||
# --- Decks und Sammlung -----------------------------------------------------------
|
||||
# --- Sammlung und Stapel -----------------------------------------------------------
|
||||
|
||||
func deck_cards(slot: int = -1) -> Array:
|
||||
var ids: Array = decks[slot if slot >= 0 else active_deck].cards
|
||||
var out := []
|
||||
for cid in ids:
|
||||
out.append(Cards.by_id(cid))
|
||||
func owns(cid: String) -> bool:
|
||||
return collection.has(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
|
||||
|
||||
|
||||
func deck_valid(slot: int = -1) -> Array:
|
||||
return Game.validate_deck(deck_cards(slot), collection)
|
||||
## Der Stapel für den Kampf: der gewählte, mit weiteren eigenen Karten bis DECK_MIN aufgefüllt,
|
||||
## falls er zu klein ist (Karten können durch Zerlegen wegfallen).
|
||||
func deck_cards() -> Array:
|
||||
return DeckGen.fill(deck.filter(func(id): return collection.has(id)), collection.keys(), Game.DECK_MIN, collection)
|
||||
|
||||
|
||||
func deck_name(slot: int = -1) -> String:
|
||||
return decks[slot if slot >= 0 else active_deck].name
|
||||
|
||||
|
||||
func rename_deck(slot: int, name: String) -> String:
|
||||
var n := name.strip_edges()
|
||||
if n.is_empty() or n.length() > 20:
|
||||
return "Name muss 1 bis 20 Zeichen haben"
|
||||
decks[slot].name = n
|
||||
## Karte in den Stapel oder heraus. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func toggle_deck(cid: String) -> String:
|
||||
if cid in deck:
|
||||
deck.erase(cid)
|
||||
return ""
|
||||
if not owns(cid):
|
||||
return "Karte nicht in der Sammlung"
|
||||
if deck.size() >= Game.DECK_MAX:
|
||||
return "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX
|
||||
deck.append(cid)
|
||||
return ""
|
||||
|
||||
|
||||
## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve.
|
||||
## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt.
|
||||
func autofill_deck(slot: int) -> String:
|
||||
var cards: Array = decks[slot].cards
|
||||
var elems := Game.deck_elements(deck_cards(slot))
|
||||
if elems.is_empty():
|
||||
var count := {}
|
||||
for c in owned_cards():
|
||||
for e in c.elements:
|
||||
count[e] = count.get(e, 0) + 1
|
||||
var ranked: Array = count.keys()
|
||||
ranked.sort_custom(func(a, b): return count[a] > count[b])
|
||||
elems = ranked.slice(0, Game.MAX_DECK_ELEMENTS)
|
||||
var pool := owned_cards().filter(func(c): return c.elements.all(func(e): return e in elems))
|
||||
pool.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
|
||||
var counts := {}
|
||||
for cid in cards:
|
||||
counts[cid] = counts.get(cid, 0) + 1
|
||||
var progressed := true
|
||||
while cards.size() < Game.DECK_SIZE and progressed:
|
||||
progressed = false
|
||||
for cost in range(1, 11):
|
||||
if cards.size() >= Game.DECK_SIZE:
|
||||
break
|
||||
for c in pool:
|
||||
if c.cost != cost:
|
||||
continue
|
||||
var have: int = counts.get(c.id, 0)
|
||||
if have < mini(collection.get(c.id, 0), Game.MAX_COPIES):
|
||||
cards.append(c.id)
|
||||
counts[c.id] = have + 1
|
||||
progressed = true
|
||||
break
|
||||
var ok := Game.validate_deck(deck_cards(slot), collection)
|
||||
return "" if ok[0] else ok[1]
|
||||
|
||||
|
||||
## Bestenliste: Karten mit den meisten Siegen (mindestens eine Partie).
|
||||
func top_cards(n: int) -> Array:
|
||||
var rows := []
|
||||
for cid in card_stats:
|
||||
var st: Dictionary = card_stats[cid]
|
||||
if st.games > 0:
|
||||
rows.append({"card": Cards.by_id(cid), "games": st.games, "wins": st.wins, "played": st.played,
|
||||
"rate": float(st.wins) / st.games})
|
||||
rows.sort_custom(func(a, b): return a.wins > b.wins or (a.wins == b.wins and a.rate > b.rate))
|
||||
return rows.slice(0, n)
|
||||
|
||||
|
||||
func owned_cards() -> Array:
|
||||
return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0)
|
||||
|
||||
|
||||
func distinct_owned() -> int:
|
||||
var n := 0
|
||||
for cid in collection:
|
||||
if collection[cid] > 0:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
# --- Werkstatt --------------------------------------------------------------------
|
||||
|
||||
## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func dismantle(cid: String, n: int = 1) -> String:
|
||||
var owned: int = collection.get(cid, 0)
|
||||
if owned < n:
|
||||
return "Nicht genug Kopien"
|
||||
var needed := 0
|
||||
for d in decks:
|
||||
needed = maxi(needed, d.cards.count(cid))
|
||||
if owned - n < needed:
|
||||
return "Wird in einem Deck gebraucht"
|
||||
collection[cid] = owned - n
|
||||
dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n
|
||||
return ""
|
||||
|
||||
|
||||
## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func craft(cid: String) -> String:
|
||||
var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity]
|
||||
## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
|
||||
func upgrade(cid: String) -> String:
|
||||
if not owns(cid):
|
||||
return "Karte nicht in der Sammlung"
|
||||
var lvl := level(cid)
|
||||
if lvl >= Cards.RARITIES.size() - 1:
|
||||
return "Schon auf der höchsten Stufe"
|
||||
var cost: int = DUST_UPGRADE[Cards.RARITIES[lvl + 1]]
|
||||
if dust < cost:
|
||||
return "Nicht genug Staub (%d nötig)" % cost
|
||||
dust -= cost
|
||||
collection[cid] = collection.get(cid, 0) + 1
|
||||
daily_event("craft")
|
||||
check_achievements()
|
||||
collection[cid] = lvl + 1
|
||||
return ""
|
||||
|
||||
|
||||
## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück.
|
||||
func dismantle_extras() -> int:
|
||||
var before := dust
|
||||
for cid in collection.keys():
|
||||
var extra: int = collection[cid] - Game.MAX_COPIES
|
||||
if extra > 0:
|
||||
dismantle(cid, extra)
|
||||
return dust - before
|
||||
## Entzaubern: eine Stufe abgeben bringt Staub; eine Common-Karte verlässt die Sammlung.
|
||||
## Gibt "" zurück oder eine Fehlermeldung.
|
||||
func dismantle(cid: String) -> String:
|
||||
if not owns(cid):
|
||||
return "Karte nicht in der Sammlung"
|
||||
var lvl := level(cid)
|
||||
dust += DUST_DISMANTLE[Cards.RARITIES[lvl]]
|
||||
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 ----------------------------------------------------------------------
|
||||
|
||||
## Fünf Karten mit gewürfelter Seltenheit.
|
||||
static func roll_booster(r: RandomNumberGenerator) -> Array:
|
||||
var rarities := RARITY_WEIGHTS.keys()
|
||||
var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
|
||||
var cards := []
|
||||
var pool := Cards.all()
|
||||
var out := []
|
||||
for i in BOOSTER_SIZE:
|
||||
var rarity: String = rarities[r.rand_weighted(weights)]
|
||||
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
|
||||
and cards.all(func(c): return c.rarity == "gewöhnlich"):
|
||||
rarity = "selten" # mindestens eine seltene Karte pro Booster
|
||||
var pool := Cards.of_rarity(rarity)
|
||||
cards.append(pool[r.randi_range(0, pool.size() - 1)])
|
||||
return cards
|
||||
var rar: String = rarities[r.rand_weighted(weights)]
|
||||
if i == BOOSTER_SIZE - 1 and out.all(func(x): return x.rarity == "Common"):
|
||||
rar = "Rare" # mindestens eine Rare pro Booster
|
||||
out.append({"card": pool[r.randi_range(0, pool.size() - 1)], "rarity": rar})
|
||||
return out
|
||||
|
||||
|
||||
## Öffnet einen Booster. 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
|
||||
## Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine gleiche
|
||||
## oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn die Münzen nicht reichen.
|
||||
func open_booster(r: RandomNumberGenerator = null) -> Array:
|
||||
if coins < BOOSTER_COST:
|
||||
return []
|
||||
if r == null:
|
||||
r = Game.make_rng()
|
||||
coins -= BOOSTER_COST
|
||||
var cards := roll_booster(r)
|
||||
booster_since_mythic += 1
|
||||
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch")
|
||||
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic:
|
||||
var pool := Cards.of_rarity("mythisch")
|
||||
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)]
|
||||
has_mythic = true
|
||||
if has_mythic:
|
||||
booster_since_mythic = 0
|
||||
for c in cards:
|
||||
collection[c.id] = collection.get(c.id, 0) + 1
|
||||
stats.boosters += 1
|
||||
daily_event("booster")
|
||||
check_achievements()
|
||||
return cards
|
||||
var out := roll_booster(r)
|
||||
for res in out:
|
||||
var lvl: int = Cards.RARITIES.find(res.rarity)
|
||||
res["new"] = not owns(res.card.id)
|
||||
res["upgraded"] = false
|
||||
res["dust"] = 0
|
||||
if res.new:
|
||||
collection[res.card.id] = lvl
|
||||
if deck.size() < Game.DECK_MAX:
|
||||
deck.append(res.card.id)
|
||||
elif lvl > level(res.card.id):
|
||||
res["upgraded"] = true
|
||||
res["dust"] = DUST_DISMANTLE[rarity(res.card.id)]
|
||||
collection[res.card.id] = lvl
|
||||
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.
|
||||
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
|
||||
## turns: Dauer der Partie für die Bestenliste. challenge: Herausforderung dieser Woche.
|
||||
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
|
||||
turns: int = 0, challenge: Dictionary = {}) -> int:
|
||||
stats.games += 1
|
||||
if won:
|
||||
records.streak += 1
|
||||
records.best_streak = maxi(records.best_streak, records.streak)
|
||||
if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win):
|
||||
records.fastest_win = turns
|
||||
else:
|
||||
records.streak = 0
|
||||
for cid in played_ids:
|
||||
_stat(cid).played += 1
|
||||
var seen := {}
|
||||
for c in deck_used:
|
||||
if not seen.has(c.id):
|
||||
seen[c.id] = true
|
||||
_stat(c.id).games += 1
|
||||
if won:
|
||||
_stat(c.id).wins += 1
|
||||
func record_battle(won: bool, stage: int = -1, reward_factor: float = 1.0) -> int:
|
||||
var reward := REWARD_LOSS
|
||||
if won:
|
||||
stats.wins += 1
|
||||
var key := str(n_opp)
|
||||
stats.wins_by_opp[key] = int(stats.wins_by_opp.get(key, 0)) + 1
|
||||
reward = REWARD_WIN[n_opp]
|
||||
reward = int(round(REWARD_WIN * reward_factor))
|
||||
if stage >= 0 and stage == campaign_stage:
|
||||
campaign_stage += 1
|
||||
reward = Campaign.stage(stage).reward
|
||||
notices.append("Kampagne: %s besiegt!" % Campaign.stage(stage).name)
|
||||
if not challenge.is_empty() and challenge_week != Challenge.week():
|
||||
challenge_week = Challenge.week()
|
||||
reward = challenge.reward
|
||||
notices.append("Herausforderung bestanden: %s (+%d Münzen)" % [challenge.name, challenge.reward])
|
||||
notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name)
|
||||
coins += reward
|
||||
daily_event("game")
|
||||
if won:
|
||||
daily_event("win")
|
||||
if n_opp >= 3:
|
||||
daily_event("win_vs3")
|
||||
for e in Game.deck_elements(deck_used):
|
||||
daily_event("win_element:" + e)
|
||||
check_achievements({"won": won, "n_opp": n_opp, "deck": deck_used})
|
||||
return reward
|
||||
|
||||
|
||||
func _stat(cid: String) -> Dictionary:
|
||||
if not card_stats.has(cid):
|
||||
card_stats[cid] = {"played": 0, "games": 0, "wins": 0}
|
||||
return card_stats[cid]
|
||||
|
||||
|
||||
func check_achievements(ctx: Dictionary = {}) -> Array:
|
||||
var unlocked := []
|
||||
for a in ACHIEVEMENTS:
|
||||
if a.id in achievements:
|
||||
continue
|
||||
if _achieved(a.id, ctx):
|
||||
achievements.append(a.id)
|
||||
coins += a.reward
|
||||
unlocked.append(a)
|
||||
notices.append("Erfolg: %s (+%d Münzen)" % [a.name, a.reward])
|
||||
return unlocked
|
||||
|
||||
|
||||
func _achieved(id: String, ctx: Dictionary) -> bool:
|
||||
var won: bool = ctx.get("won", false)
|
||||
var n: int = ctx.get("n_opp", 0)
|
||||
var deck_used: Array = ctx.get("deck", [])
|
||||
match id:
|
||||
"erster_sieg":
|
||||
return stats.wins >= 1
|
||||
"duellant":
|
||||
return won and n == 1
|
||||
"tischherr":
|
||||
return won and n >= 3
|
||||
"chaosmeister":
|
||||
return won and n == 5
|
||||
"sammler":
|
||||
return distinct_owned() >= 40
|
||||
"vollstaendig":
|
||||
return distinct_owned() >= Cards.all().size()
|
||||
"boosterfreund":
|
||||
return stats.boosters >= 10
|
||||
"elementarist":
|
||||
return won and Game.deck_elements(deck_used).size() == 1
|
||||
"kombokoenig":
|
||||
return won and deck_used.filter(func(c): return c.is_combo()).size() >= 6
|
||||
"kampagnenheld":
|
||||
return campaign_stage >= Campaign.STAGES.size()
|
||||
"elementmeister":
|
||||
return campaign_stage >= Campaign.total()
|
||||
"serie":
|
||||
return records.best_streak >= 5
|
||||
"wochenheld":
|
||||
return challenge_week >= 0
|
||||
return false
|
||||
func difficulty() -> Dictionary:
|
||||
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
|
||||
|
||||
|
||||
func perk_unlocked() -> bool:
|
||||
return campaign_stage >= Campaign.STAGES.size()
|
||||
|
||||
|
||||
## Schreibt Statistik als CSV (Kartenwerte und Gesamtzahlen), z. B. für Spieltests.
|
||||
func export_stats(path: String) -> String:
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return "Datei nicht schreibbar"
|
||||
f.store_line("karte;seltenheit;gespielt;partien;siege")
|
||||
for cid in card_stats:
|
||||
var st: Dictionary = card_stats[cid]
|
||||
var c := Cards.by_id(cid)
|
||||
f.store_line("%s;%s;%d;%d;%d" % [c.name, c.rarity, st.played, st.games, st.wins])
|
||||
f.store_line("")
|
||||
f.store_line("kaempfe;%d" % stats.games)
|
||||
f.store_line("siege;%d" % stats.wins)
|
||||
f.store_line("booster;%d" % stats.boosters)
|
||||
f.store_line("beste_serie;%d" % records.best_streak)
|
||||
f.store_line("schnellster_sieg;%d" % records.fastest_win)
|
||||
f.close()
|
||||
return ""
|
||||
|
||||
|
||||
static func today() -> String:
|
||||
return today_override if today_override != "" else Time.get_date_string_from_system()
|
||||
|
||||
|
||||
## Wählt die Tagesaufgabe neu, wenn ein neuer Tag begonnen hat.
|
||||
func refresh_daily() -> void:
|
||||
var t := today()
|
||||
if daily.date != t:
|
||||
var day_number := int(Time.get_unix_time_from_datetime_string(t + "T00:00:00") / 86400)
|
||||
var task: Dictionary = DAILY_TASKS[day_number % DAILY_TASKS.size()]
|
||||
daily = {"date": t, "task": task.id, "progress": 0, "done": false}
|
||||
|
||||
|
||||
func daily_task() -> Dictionary:
|
||||
refresh_daily()
|
||||
for t in DAILY_TASKS:
|
||||
if t.id == daily.task:
|
||||
return t
|
||||
return DAILY_TASKS[0]
|
||||
|
||||
|
||||
## Zählt ein Ereignis (siehe DAILY_TASKS) für die Tagesaufgabe. Gibt die Belohnung zurück.
|
||||
func daily_event(kind: String, n: int = 1) -> int:
|
||||
var task := daily_task()
|
||||
if daily.done or task.kind != kind:
|
||||
return 0
|
||||
daily.progress += n
|
||||
if daily.progress >= task.goal:
|
||||
daily.done = true
|
||||
coins += task.reward
|
||||
notices.append("Tagesaufgabe erfüllt: %s (+%d Münzen)" % [task.text, task.reward])
|
||||
return task.reward
|
||||
return 0
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
class_name AchievementsScreen
|
||||
extends Control
|
||||
|
||||
var app
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var p: Profile = app.profile
|
||||
var col := Style.column(self, 900, 20, 5)
|
||||
Style.clabel(col, "Erfolge", 36, Style.GOLD, "center", true)
|
||||
var st: Dictionary = p.stats
|
||||
Style.clabel(col, "Kämpfe %d · Siege %d · Booster %d · Kampagne %d/%d" % [
|
||||
st.games, st.wins, st.boosters, p.campaign_stage, Campaign.STAGES.size()], 16, Style.MUTED)
|
||||
var task := p.daily_task()
|
||||
var daily_text: String = ("Tagesaufgabe erledigt: %s ✓" % task.text) if p.daily.done else \
|
||||
"Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward]
|
||||
Style.clabel(col, daily_text, 16, Style.BLUE)
|
||||
Style.spacer(col, 8)
|
||||
for a in Profile.ACHIEVEMENTS:
|
||||
var done: bool = a.id in p.achievements
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if done else Style.darker(Style.PANEL, 0.8), 6))
|
||||
col.add_child(panel)
|
||||
var r := Style.row(panel, 12)
|
||||
r.custom_minimum_size = Vector2(0, 42)
|
||||
Style.clabel(r, "✓" if done else "·", 22, Style.GREEN if done else Style.MUTED, "center", true, 40)
|
||||
Style.clabel(r, a.name, 18, Style.TEXT if done else Style.MUTED, "left", true, 250)
|
||||
Style.clabel(r, a.text, 15, Style.MUTED, "left", false, 380)
|
||||
Style.clabel(r, "+%d" % a.reward, 16, Style.GOLD, "right", true, 90)
|
||||
var top := p.top_cards(5)
|
||||
if not top.is_empty():
|
||||
Style.spacer(col, 4)
|
||||
var parts: Array[String] = []
|
||||
for row in top:
|
||||
parts.append("%s %d/%d" % [row.card.name, row.wins, row.games])
|
||||
Style.clabel(col, "Beste Karten (Siege/Partien): " + " · ".join(parts), 14, Style.MUTED)
|
||||
Style.spacer(col, 6)
|
||||
var br := Style.row(col)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
@@ -1 +0,0 @@
|
||||
uid://cw4rtcukefdl8
|
||||
@@ -4,6 +4,8 @@ extends RefCounted
|
||||
|
||||
var screen # BattleScreen
|
||||
var enabled := true
|
||||
var banners: Array = [] # wartende Ansagen [Text, Farbe]
|
||||
var banner_running := false
|
||||
|
||||
|
||||
func _init(s) -> void:
|
||||
@@ -25,45 +27,24 @@ func play_events(evs: Array, centers: Dictionary) -> void:
|
||||
var atk: CreatureView = screen.tiles.get(ev.attacker.uid)
|
||||
lunge(atk, center_of(ev.target, centers))
|
||||
flash_target(ev.target)
|
||||
shake()
|
||||
delay = 0.12
|
||||
"damage":
|
||||
sfx("damage")
|
||||
popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay)
|
||||
"heal":
|
||||
sfx("heal")
|
||||
popup("+%d" % ev.amount, Style.GREEN, center_of(ev.target, centers), delay)
|
||||
"buff":
|
||||
popup(ev.text, Style.GREEN, center_of(ev.target, centers), delay)
|
||||
"blocked":
|
||||
popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay)
|
||||
"freeze":
|
||||
popup("Eis", Color("aaddff"), center_of(ev.target, centers), delay)
|
||||
"combo":
|
||||
sfx("combo")
|
||||
var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid})
|
||||
if where.x < 0:
|
||||
where = screen.target_center({"kind": "player", "index": ev.player})
|
||||
popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1)
|
||||
popup(tr("Regeneration"), Style.GREEN, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
|
||||
"play":
|
||||
sfx("play")
|
||||
"spell":
|
||||
sfx("play")
|
||||
var caster: Game.Player = g.players[ev.player]
|
||||
popup(ev.card.name, Style.GOLD, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
if caster != screen.perspective:
|
||||
screen.show_preview(ev.card, true)
|
||||
screen.preview_timer = 1.6
|
||||
"trap_set":
|
||||
sfx("click")
|
||||
popup("Falle gelegt", Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
"trap":
|
||||
sfx("click" if ev.get("hidden", false) else "play")
|
||||
"reveal":
|
||||
sfx("combo")
|
||||
popup("Falle: %s!" % ev.card.name, Style.ORANGE, screen.target_center({"kind": "player", "index": ev.player}) + Vector2(0, -20), 0.0)
|
||||
screen.show_preview(ev.card, true)
|
||||
screen.preview_timer = 1.8
|
||||
"mulligan":
|
||||
if ev.count > 0:
|
||||
popup("tauscht %d Karten" % ev.count, Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
popup(tr("Aufgedeckt!"), Style.GOLD, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
|
||||
"fuse":
|
||||
sfx("combo")
|
||||
popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||
"draw":
|
||||
sfx("click")
|
||||
|
||||
|
||||
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
|
||||
@@ -73,9 +54,24 @@ func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
|
||||
return c
|
||||
|
||||
|
||||
## Mehrere Meldungen an derselben Stelle rücken nach unten, sonst liegen sie übereinander.
|
||||
func _free_spot(at: Vector2) -> Vector2:
|
||||
for step in 6:
|
||||
var spot := at + Vector2(0, step * 26)
|
||||
var taken := false
|
||||
for l in screen.fx_layer.get_children():
|
||||
if l is Label and l.get_meta("spot", Vector2(-999, -999)).distance_to(spot) < 20:
|
||||
taken = true
|
||||
break
|
||||
if not taken:
|
||||
return spot
|
||||
return at
|
||||
|
||||
|
||||
func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
|
||||
if not enabled or at.x < 0:
|
||||
return
|
||||
at = _free_spot(at)
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", 22)
|
||||
@@ -84,6 +80,7 @@ func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
|
||||
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||
l.add_theme_constant_override("outline_size", 6)
|
||||
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
l.set_meta("spot", at)
|
||||
screen.fx_layer.add_child(l)
|
||||
l.reset_size()
|
||||
l.position = at - l.size / 2
|
||||
@@ -142,14 +139,31 @@ func pop_in(tile: CreatureView) -> void:
|
||||
|
||||
|
||||
func fade_out(tile: CreatureView) -> void:
|
||||
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
fade_view(tile)
|
||||
|
||||
|
||||
## Blendet eine Ansicht aus und entfernt sie (Wesen, die sterben; Handkarten, die verschmelzen).
|
||||
func fade_view(view: Control) -> void:
|
||||
view.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
if not enabled:
|
||||
tile.queue_free()
|
||||
view.queue_free()
|
||||
return
|
||||
var tw := tile.create_tween().set_parallel(true)
|
||||
tw.tween_property(tile, "modulate:a", 0.0, 0.3)
|
||||
tw.tween_property(tile, "scale", Vector2(0.6, 0.6), 0.3)
|
||||
tw.chain().tween_callback(tile.queue_free)
|
||||
var tw := view.create_tween().set_parallel(true)
|
||||
tw.tween_property(view, "modulate:a", 0.0, 0.3)
|
||||
tw.tween_property(view, "scale", Vector2(0.6, 0.6), 0.3)
|
||||
tw.chain().tween_callback(view.queue_free)
|
||||
|
||||
|
||||
## Eine Ansicht gleitet von from an ihren Platz (die neue Handkarte nach dem Verschmelzen).
|
||||
func glide(view: Control, from: Rect2) -> void:
|
||||
if not enabled:
|
||||
return
|
||||
var to := view.position
|
||||
view.position = from.position
|
||||
view.scale = from.size / view.size
|
||||
var tw := view.create_tween().set_parallel(true)
|
||||
tw.tween_property(view, "position", to, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
tw.tween_property(view, "scale", Vector2.ONE, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
|
||||
|
||||
func move_to(tile: CreatureView, pos: Vector2) -> void:
|
||||
@@ -157,3 +171,81 @@ func move_to(tile: CreatureView, pos: Vector2) -> void:
|
||||
screen.create_tween().tween_property(tile, "position", pos, 0.2)
|
||||
else:
|
||||
tile.position = pos
|
||||
|
||||
|
||||
## Ein Wesen fliegt von der Handkachel auf seinen Platz.
|
||||
func fly_in(tile: CreatureView, from: Rect2) -> void:
|
||||
if not enabled:
|
||||
return
|
||||
tile.busy = true
|
||||
tile.position = from.position
|
||||
tile.scale = from.size / tile.size
|
||||
var tw := tile.create_tween().set_parallel(true)
|
||||
tw.tween_property(tile, "position", tile.slot, 0.22).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
tw.tween_property(tile, "scale", Vector2.ONE, 0.22).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||
tw.chain().tween_callback(func():
|
||||
tile.busy = false
|
||||
tile.position = tile.slot)
|
||||
|
||||
|
||||
## Kurzes Zittern des Bretts beim Treffer.
|
||||
func shake() -> void:
|
||||
if not enabled:
|
||||
return
|
||||
var layer: Control = screen.tile_layer
|
||||
var tw: Tween = screen.create_tween()
|
||||
for i in 3:
|
||||
tw.tween_property(layer, "position", Vector2(3 if i % 2 == 0 else -3, 0), 0.03)
|
||||
tw.tween_property(layer, "position", Vector2.ZERO, 0.03)
|
||||
|
||||
|
||||
## Ansage quer über die Mitte: Zugbeginn, Feldwechsel. Mehrere folgen nacheinander.
|
||||
func banner(text: String, color: Color) -> void:
|
||||
if not enabled:
|
||||
return
|
||||
banners.append([text, color])
|
||||
if not banner_running:
|
||||
_next_banner()
|
||||
|
||||
|
||||
func _next_banner() -> void:
|
||||
if banners.is_empty() or not is_instance_valid(screen) or not screen.is_inside_tree():
|
||||
banner_running = false
|
||||
return
|
||||
banner_running = true
|
||||
var next: Array = banners.pop_front()
|
||||
var text: String = next[0]
|
||||
var color: Color = next[1]
|
||||
var delay := 0.0
|
||||
var band := ColorRect.new()
|
||||
band.color = Color(0, 0, 0, 0.55)
|
||||
band.position = Vector2(0, 355)
|
||||
band.size = Vector2(1280, 90)
|
||||
band.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
band.scale.y = 0.0
|
||||
band.pivot_offset = Vector2(640, 45)
|
||||
screen.fx_layer.add_child(band)
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", 40)
|
||||
l.add_theme_font_override("font", Style.FONT_BOLD)
|
||||
l.add_theme_color_override("font_color", color)
|
||||
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||
l.add_theme_constant_override("outline_size", 8)
|
||||
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
screen.fx_layer.add_child(l)
|
||||
l.reset_size()
|
||||
l.position = Vector2(640, 400) - l.size / 2
|
||||
l.pivot_offset = l.size / 2
|
||||
l.scale = Vector2(0.6, 0.6)
|
||||
l.modulate.a = 0.0
|
||||
var tw: Tween = screen.create_tween().set_parallel(true)
|
||||
tw.tween_property(band, "scale:y", 1.0, 0.15).set_delay(delay).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
tw.tween_property(l, "scale", Vector2.ONE, 0.25).set_delay(delay).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||
tw.tween_property(l, "modulate:a", 1.0, 0.12).set_delay(delay)
|
||||
tw.tween_property(l, "modulate:a", 0.0, 0.3).set_delay(delay + 0.8)
|
||||
tw.tween_property(band, "scale:y", 0.0, 0.2).set_delay(delay + 0.9)
|
||||
tw.chain().tween_callback(func():
|
||||
l.queue_free()
|
||||
band.queue_free()
|
||||
_next_banner())
|
||||
|
||||
163
scripts/ui/battle_gamepad.gd
Normal file
163
scripts/ui/battle_gamepad.gd
Normal file
@@ -0,0 +1,163 @@
|
||||
class_name BattleGamepad
|
||||
extends RefCounted
|
||||
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Stapel, Hand, eigene Wesen
|
||||
## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y öffnet das Kartenmenü (im
|
||||
## Gegnerzug: überspringen), X macht rückgängig. Verschmelzen: im Menü „Verschmelzen mit …“,
|
||||
## dann A auf dem Partner.
|
||||
|
||||
var screen # BattleScreen
|
||||
var active := false
|
||||
var zone := "deck"
|
||||
var index := 0
|
||||
var targets: Array = []
|
||||
|
||||
|
||||
func _init(s) -> void:
|
||||
screen = s
|
||||
|
||||
|
||||
func button(button: int) -> void:
|
||||
active = true
|
||||
if screen.game == null:
|
||||
return
|
||||
match button:
|
||||
JOY_BUTTON_START:
|
||||
if screen.my_turn() and not screen.any_overlay():
|
||||
screen.end_turn()
|
||||
JOY_BUTTON_B:
|
||||
if screen.confirm.visible:
|
||||
screen.confirm.visible = false
|
||||
screen.cancel_all()
|
||||
zone = "deck"
|
||||
JOY_BUTTON_Y:
|
||||
if screen.b_skip.visible:
|
||||
screen.skip_ai = true
|
||||
elif screen.menu_layer.visible:
|
||||
screen.close_menu()
|
||||
else:
|
||||
var items := items()
|
||||
if not items.is_empty() and zone in ["hand", "own"]:
|
||||
var item = items[clampi(index, 0, items.size() - 1)]
|
||||
if zone == "hand":
|
||||
screen.open_menu(item.card, Rect2(item.position, item.size), true)
|
||||
else:
|
||||
screen.open_menu(item.creature, Rect2(item.slot, item.size), true)
|
||||
JOY_BUTTON_X:
|
||||
if screen.b_undo.visible:
|
||||
screen.undo()
|
||||
JOY_BUTTON_A:
|
||||
confirm()
|
||||
JOY_BUTTON_DPAD_LEFT:
|
||||
move(-1)
|
||||
JOY_BUTTON_DPAD_RIGHT:
|
||||
move(1)
|
||||
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
|
||||
if zone != "target":
|
||||
_next_zone(1 if button == JOY_BUTTON_DPAD_DOWN else -1)
|
||||
screen.refresh()
|
||||
|
||||
|
||||
## Wechselt den Bereich und überspringt leere (eine leere Hand ist keine Station).
|
||||
## step 0: beim aktuellen Bereich bleiben, falls er etwas enthält.
|
||||
func _next_zone(step: int) -> void:
|
||||
var order := ["deck", "hand", "own"]
|
||||
var at := order.find(zone)
|
||||
index = 0
|
||||
if step == 0 and not items().is_empty():
|
||||
return
|
||||
var dir := -1 if step < 0 else 1
|
||||
for i in order.size():
|
||||
at = posmod(at + dir, order.size())
|
||||
zone = order[at]
|
||||
if not items().is_empty():
|
||||
return
|
||||
|
||||
|
||||
func items() -> Array:
|
||||
match zone:
|
||||
"deck":
|
||||
return [screen.deck] if screen.deck.visible else []
|
||||
"hand":
|
||||
return screen.hand_views
|
||||
"own":
|
||||
return screen.own_views
|
||||
"target":
|
||||
return targets
|
||||
return []
|
||||
|
||||
|
||||
func move(dir: int) -> void:
|
||||
var n := items().size()
|
||||
if n > 0:
|
||||
index = posmod(index + dir, n)
|
||||
|
||||
|
||||
func confirm() -> void:
|
||||
if screen.overlay.visible:
|
||||
screen.leave()
|
||||
return
|
||||
if screen.confirm.visible:
|
||||
return
|
||||
var items := items()
|
||||
if items.is_empty():
|
||||
return
|
||||
index = clampi(index, 0, items.size() - 1)
|
||||
var item = items[index]
|
||||
match zone:
|
||||
"deck":
|
||||
screen.on_click("deck", null)
|
||||
"hand":
|
||||
screen.on_click("hand", item.card)
|
||||
"own":
|
||||
screen.on_click("own", item.creature)
|
||||
if screen.selected != null:
|
||||
targets = []
|
||||
for v in screen.enemy_views:
|
||||
if screen.game.can_attack(screen.selected, v.creature)[0]:
|
||||
targets.append(v)
|
||||
for o in screen.others():
|
||||
if o.alive() and screen.game.can_attack(screen.selected, o)[0]:
|
||||
targets.append(screen.hero_panels[o])
|
||||
zone = "target"
|
||||
index = 0
|
||||
"target":
|
||||
if item is CreatureView:
|
||||
screen.on_tile_click(item.creature)
|
||||
else:
|
||||
for o in screen.hero_panels:
|
||||
if screen.hero_panels[o] == item:
|
||||
screen.on_click("hero", o)
|
||||
zone = "own"
|
||||
_next_zone(0)
|
||||
|
||||
|
||||
## Pulsierender Rahmen als Zeiger.
|
||||
class Highlight extends Control:
|
||||
var t := 0.0
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
t += delta
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
var a := 0.55 + 0.45 * sin(t * 6.0)
|
||||
Style.border(self, Rect2(Vector2.ZERO, size), Color(Style.GOLD, a), 4, 8)
|
||||
|
||||
|
||||
func draw_cursor() -> void:
|
||||
Style.clear(screen.cursor_layer)
|
||||
if not active or screen.game == null or not screen.my_turn():
|
||||
return
|
||||
var items := items()
|
||||
if items.is_empty():
|
||||
return
|
||||
index = clampi(index, 0, items.size() - 1)
|
||||
var item = items[index]
|
||||
var r: Rect2 = Rect2(item.slot, item.size) if item is CreatureView else Rect2(item.position, item.size)
|
||||
var hl := Highlight.new()
|
||||
hl.position = r.position - Vector2(6, 6)
|
||||
hl.size = r.size + Vector2(12, 12)
|
||||
hl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
screen.cursor_layer.add_child(hl)
|
||||
|
||||
|
||||
1
scripts/ui/battle_gamepad.gd.uid
Normal file
1
scripts/ui/battle_gamepad.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://8wth8k7f7uvk
|
||||
@@ -1,40 +1,73 @@
|
||||
class_name BattleLayout
|
||||
## Feste Bildschirmaufteilung des Kampfes (1280x800).
|
||||
## Feste Bildschirmaufteilung des Duells (1280x800). Mitte von oben nach unten: Hand des Gegners
|
||||
## (Rückseiten), sein Brett, eigenes Brett, eigene Hand. Links: Kasten des Gegners, daneben die
|
||||
## Elementzähler beider Seiten je neben ihrem Brett, Zusatzzeile und Meldungen. Rechts von oben:
|
||||
## Stapel und Kasten des Gegners, die Vorschau, eigener Kasten und Stapel. Optionen per Esc.
|
||||
|
||||
const SIZES := {1: [110, 110, 10], 2: [96, 110, 8], 3: [78, 100, 4], 4: [54, 72, 4], 5: [44, 62, 3]}
|
||||
const HAND_W := 130
|
||||
const HAND_H := 185
|
||||
const HAND_GAP := 8
|
||||
const BAR := Rect2(335, 415, 610, 68)
|
||||
const OWN_X := (1280 - 5 * 110 - 4 * 10) / 2
|
||||
|
||||
|
||||
static func compact(n_others: int) -> bool:
|
||||
return n_others >= 4
|
||||
|
||||
|
||||
static func hero_box_rect(n_others: int, i: int) -> Rect2:
|
||||
var col_w := 1280 / n_others
|
||||
var margin := 6 if compact(n_others) else 10
|
||||
return Rect2(i * col_w + margin, 10, col_w - 2 * margin, 64)
|
||||
const B_UNDO := Rect2(20, 10, 210, 32) # links oben: Rückgängig, darunter Überspringen (Gegnerzug)
|
||||
const B_SKIP := Rect2(20, 48, 210, 32)
|
||||
const OPP_DECK := Rect2(1090, 10, 130, 182) # Stapel des Gegners rechts oben
|
||||
const OPP_BOX := Rect2(1050, 200, 210, 70) # Kasten des Gegners darunter
|
||||
const OPP_HAND_Y := 22 # Bogen hebt die äußeren Rückseiten um bis zu 12 Pixel
|
||||
const OPP_ELEMENTS := Rect2(20, 220, 210, 128) # Elementzähler des Gegners, links von seinem Brett
|
||||
const OWN_ELEMENTS := Rect2(20, 440, 210, 128) # eigene Elementzähler, links vom eigenen Brett
|
||||
const INFO := Rect2(20, 580, 210, 60) # Zusatzzeile zur Vorschau
|
||||
const MSG := Rect2(20, 650, 210, 80) # Meldungen
|
||||
const STATUS := Rect2(1050, 526, 210, 70) # eigener Kasten rechts über dem Stapel
|
||||
const DECK := Rect2(1090, 606, 130, 182) # eigener Stapel rechts unten
|
||||
const OPTIONS := Rect2(440, 180, 400, 420) # Optionsmenü (Esc)
|
||||
const OWN_X := 295
|
||||
const HAND_W := 130 # Handkarten so groß wie die Wesen auf dem Feld
|
||||
const HAND_H := 165
|
||||
const HAND_GAP := 6
|
||||
const HAND_Y := 605
|
||||
const HAND_ARC := 3.0 # Grad Neigung und Pixel Absenkung je Platz von der Mitte (leichter Bogen)
|
||||
const LOG_PANEL := Rect2(240, 60, 800, 560)
|
||||
const PREVIEW_SIZE := Vector2(170, 238)
|
||||
const CENTER_X := 240
|
||||
const CENTER_W := 800
|
||||
|
||||
|
||||
static func own_slot(j: int) -> Rect2:
|
||||
return Rect2(OWN_X + j * 120, 250, 110, 140)
|
||||
return Rect2(OWN_X + j * 140, 410, HAND_W, HAND_H)
|
||||
|
||||
|
||||
static func other_slot(n_others: int, i: int, j: int) -> Rect2:
|
||||
var col_w := 1280 / n_others
|
||||
var sz: Array = SIZES[n_others]
|
||||
var bx: int = i * col_w + (col_w - 5 * sz[0] - 4 * sz[2]) / 2
|
||||
return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1])
|
||||
## Brett des Gegners über seiner Elementleiste.
|
||||
static func opp_slot(j: int) -> Rect2:
|
||||
return Rect2(OWN_X + j * 140, 205, HAND_W, HAND_H)
|
||||
|
||||
|
||||
## Hand: alle Karten unten, mittig; passen sie nicht nebeneinander, überlappen sie sich
|
||||
## gleichmäßig. Den Bogen legt hand_arc dazu.
|
||||
static func hand_rect(count: int, i: int) -> Rect2:
|
||||
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
|
||||
var x := (1280 - total) / 2
|
||||
return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H)
|
||||
var step: float = HAND_W + HAND_GAP
|
||||
if count > 1:
|
||||
step = minf(step, float(CENTER_W - HAND_W) / (count - 1))
|
||||
var total: float = HAND_W + (count - 1) * step
|
||||
var x := CENTER_X + (CENTER_W - total) / 2
|
||||
return Rect2(x + i * step, HAND_Y, HAND_W, HAND_H)
|
||||
|
||||
|
||||
static func trap_rect(i: int) -> Rect2:
|
||||
return Rect2(400 + i * 78, 490, 70, 98)
|
||||
## Hand des Gegners oben, gleich groß, nur die Rückseiten.
|
||||
static func opp_hand_rect(count: int, i: int) -> Rect2:
|
||||
var r := hand_rect(count, i)
|
||||
r.position.y = OPP_HAND_Y
|
||||
return r
|
||||
|
||||
|
||||
## Leichter Bogen: Karte i von count dreht sich um ihre Mitte und sinkt zum Rand hin ab
|
||||
## (mirrored: für den Gegner oben spiegelverkehrt). Liefert [Grad, y-Versatz].
|
||||
static func hand_arc(count: int, i: int, mirrored: bool = false) -> Array:
|
||||
var t := i - (count - 1) / 2.0
|
||||
var sign := -1.0 if mirrored else 1.0
|
||||
return [t * HAND_ARC * sign, t * t * HAND_ARC * sign]
|
||||
|
||||
|
||||
## Vorschau der gezeigten Karte: fest rechts zwischen den beiden Kästen.
|
||||
static func preview_rect(_anchor: Rect2) -> Rect2:
|
||||
return Rect2(Vector2(1070, 278), PREVIEW_SIZE)
|
||||
|
||||
|
||||
## Zusatzzeile (Elementbonus, Eigenschaften) links unter den Elementzählern.
|
||||
static func info_rect(_preview: Rect2) -> Rect2:
|
||||
return INFO
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,15 @@ const CARD_W := 200
|
||||
const CARD_H := 280
|
||||
|
||||
var app
|
||||
var cards: Array = []
|
||||
var cards: Array = [] # {"card", "rarity", "upgraded", "dust"} je Karte
|
||||
var revealed := {}
|
||||
var new_ids := {}
|
||||
var coins_label: Label
|
||||
var msg_label: Label
|
||||
var b_buy: Button
|
||||
var b_all: Button
|
||||
var slots: Control
|
||||
var fx_layer: Control
|
||||
var fresh := {} # gerade aufgedeckt: wird umgedreht
|
||||
|
||||
|
||||
class CardBack extends Control:
|
||||
@@ -22,37 +23,44 @@ class CardBack extends Control:
|
||||
size = sz
|
||||
custom_minimum_size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
focus_entered.connect(queue_redraw)
|
||||
focus_exited.connect(queue_redraw)
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit()
|
||||
elif has_focus() and event.is_action_pressed("ui_accept"):
|
||||
accept_event()
|
||||
clicked.emit()
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := Rect2(Vector2.ZERO, size)
|
||||
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 3), rect)
|
||||
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect)
|
||||
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
|
||||
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
|
||||
|
||||
|
||||
func slot_rect(i: int) -> Rect2:
|
||||
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)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true)
|
||||
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
|
||||
Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.",
|
||||
16, Style.MUTED, Rect2(0, 115, 1280, 24), "center")
|
||||
Style.header(self, "Booster", func(): app.show_screen(MenuScreen.new()))
|
||||
coins_label = Style.chip(self, "", Rect2(860, 26, 240, 38))
|
||||
fx_layer = Control.new()
|
||||
fx_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(fx_layer)
|
||||
slots = Control.new()
|
||||
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(slots)
|
||||
msg_label = Style.label(self, "", 18, Style.RED, Rect2(0, 610, 1280, 30), "center")
|
||||
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(360, 720, 260, 48), buy, Color("5a4628"))
|
||||
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 140, 48), reveal_all)
|
||||
Style.button(self, "Zurück", Rect2(800, 720, 120, 48), func(): app.show_screen(MenuScreen.new()))
|
||||
msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 640, 1280, 22), "center")
|
||||
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(370, 690, 330, 52), buy, Color("5a4628"))
|
||||
b_buy.add_theme_font_size_override("font_size", 20)
|
||||
b_all = Style.button(self, "Alle aufdecken", Rect2(720, 690, 200, 52), reveal_all)
|
||||
refresh()
|
||||
|
||||
|
||||
@@ -63,7 +71,6 @@ func _input(event: InputEvent) -> void:
|
||||
|
||||
func buy() -> void:
|
||||
var p: Profile = app.profile
|
||||
var before: Dictionary = p.collection.duplicate()
|
||||
var got: Array = p.open_booster()
|
||||
if got.is_empty():
|
||||
msg_label.text = "Nicht genug Münzen"
|
||||
@@ -73,50 +80,78 @@ func buy() -> void:
|
||||
app.sfx.play("play")
|
||||
cards = got
|
||||
revealed = {}
|
||||
new_ids = {}
|
||||
for c in cards:
|
||||
if before.get(c.id, 0) == 0:
|
||||
new_ids[c.id] = true
|
||||
var notes: Array[String] = p.take_notices()
|
||||
msg_label.text = " · ".join(notes)
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN)
|
||||
msg_label.text = ""
|
||||
refresh()
|
||||
|
||||
|
||||
func reveal(i: int) -> void:
|
||||
revealed[i] = true
|
||||
app.sfx.play("click")
|
||||
fresh = {i: 0.0}
|
||||
app.sfx.play("combo" if cards[i].rarity != "Common" else "click")
|
||||
refresh()
|
||||
|
||||
|
||||
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
|
||||
app.sfx.play("play")
|
||||
refresh()
|
||||
|
||||
|
||||
## Umdrehen: die Karte wächst aus der Mitte; seltene Stufen blitzen in ihrer Farbe auf.
|
||||
func flip(v: CardView, delay: float, rarity: String) -> void:
|
||||
if not Style.animations:
|
||||
return
|
||||
v.scale.x = 0.0
|
||||
var tw := create_tween()
|
||||
tw.tween_interval(delay)
|
||||
tw.tween_property(v, "scale:x", 1.0, 0.18).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||
if rarity == "Common":
|
||||
return
|
||||
var burst := Style.panel(fx_layer, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12)
|
||||
burst.pivot_offset = burst.size / 2
|
||||
burst.modulate.a = 0.0
|
||||
var bt := create_tween()
|
||||
bt.tween_interval(delay + 0.1)
|
||||
bt.tween_property(burst, "modulate:a", 1.0, 0.05)
|
||||
bt.parallel().tween_property(burst, "scale", Vector2(1.25, 1.25), 0.45).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
bt.tween_property(burst, "modulate:a", 0.0, 0.3)
|
||||
bt.tween_callback(burst.queue_free)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
coins_label.text = "Münzen: %d" % p.coins
|
||||
b_buy.disabled = p.coins < Profile.BOOSTER_COST
|
||||
b_all.disabled = cards.is_empty() or revealed.size() >= 5
|
||||
b_all.disabled = cards.is_empty() or revealed.size() >= Profile.BOOSTER_SIZE
|
||||
Style.clear(slots)
|
||||
for i in 5:
|
||||
for i in Profile.BOOSTER_SIZE:
|
||||
var r := slot_rect(i)
|
||||
if cards.is_empty():
|
||||
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
|
||||
elif revealed.has(i):
|
||||
var c: Cards.Card = cards[i]
|
||||
var v := CardView.new(c, r.size)
|
||||
var res: Dictionary = cards[i]
|
||||
var c: Cards.Card = res.card
|
||||
var v := CardView.new(c.leveled(Cards.RARITIES.find(res.rarity)), r.size, false, res.rarity)
|
||||
v.position = r.position
|
||||
slots.add_child(v)
|
||||
Style.label(slots, c.rarity, 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
|
||||
if new_ids.has(c.id):
|
||||
Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
|
||||
if fresh.has(i):
|
||||
flip(v, fresh[i], res.rarity)
|
||||
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:
|
||||
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:
|
||||
var back := CardBack.new(r.size)
|
||||
back.position = r.position
|
||||
back.clicked.connect(reveal.bind(i))
|
||||
slots.add_child(back)
|
||||
fresh = {}
|
||||
|
||||
@@ -1,57 +1,121 @@
|
||||
class_name CampaignScreen
|
||||
extends Control
|
||||
## Kampagne als Pfad: zehn Stationen je Kapitel, darunter die gewählte Stufe im Detail.
|
||||
|
||||
var app
|
||||
var chapter := 1
|
||||
var selected := 0 # gewählte Stufe (Index über alle Kapitel)
|
||||
var perk_box: OptionButton
|
||||
var node_buttons: Array = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var p: Profile = app.profile
|
||||
var col := Style.column(self, 1000, 24, 6)
|
||||
Style.clabel(col, "Kampagne", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Zehn Magier warten. Jede Stufe bringt mehr Gegner an den Tisch, alle nach denselben Regeln.", 16, Style.MUTED)
|
||||
Style.spacer(col, 6)
|
||||
for i in Campaign.STAGES.size():
|
||||
var st: Dictionary = Campaign.STAGES[i]
|
||||
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size())
|
||||
selected = mini(p.campaign_stage, Campaign.total() - 1)
|
||||
build()
|
||||
|
||||
|
||||
func build() -> void:
|
||||
Style.clear(self)
|
||||
node_buttons = []
|
||||
var p: Profile = app.profile
|
||||
Style.header(self, "Kampagne", func(): app.show_screen(MenuScreen.new()))
|
||||
var n := Campaign.STAGES.size()
|
||||
var x := 40
|
||||
for c in Campaign.CHAPTERS.size():
|
||||
var b := Style.button(self, "Kapitel %d" % (c + 1), Rect2(x, 88, 150, 38), set_chapter.bind(c + 1), Style.PRIMARY if chapter == c + 1 else null)
|
||||
b.disabled = p.campaign_stage < c * n
|
||||
x += 160
|
||||
if p.perk_unlocked():
|
||||
Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(760, 96, 130, 24), "right")
|
||||
perk_box = OptionButton.new()
|
||||
perk_box.position = Vector2(900, 88)
|
||||
perk_box.size = Vector2(340, 38)
|
||||
perk_box.add_item("keins")
|
||||
var keys: Array = Game.PERKS.keys()
|
||||
for k in keys:
|
||||
perk_box.add_item(Game.PERKS[k])
|
||||
perk_box.select(keys.find(p.perk) + 1 if p.perk != "" else 0)
|
||||
perk_box.item_selected.connect(func(i: int):
|
||||
p.perk = "" if i == 0 else keys[i - 1]
|
||||
app.save())
|
||||
add_child(perk_box)
|
||||
# Pfad mit Stationen
|
||||
var offset := (chapter - 1) * n
|
||||
var y := 190.0
|
||||
var step := (1280 - 200) / float(n - 1)
|
||||
var centers: Array[Vector2] = []
|
||||
for k in n:
|
||||
centers.append(Vector2(100 + k * step, y))
|
||||
var path := Control.new()
|
||||
path.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
path.draw.connect(func():
|
||||
for k in n - 1:
|
||||
var done: bool = offset + k + 1 <= p.campaign_stage
|
||||
path.draw_line(centers[k], centers[k + 1], Style.GREEN if done else Style.PANEL_LIGHT, 4.0))
|
||||
add_child(path)
|
||||
for k in n:
|
||||
var i := offset + k
|
||||
var done := i < p.campaign_stage
|
||||
var next := i == p.campaign_stage
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6,
|
||||
Style.GOLD if next else null, 2 if next else 0))
|
||||
col.add_child(panel)
|
||||
var r := Style.row(panel, 12)
|
||||
r.custom_minimum_size = Vector2(0, 50)
|
||||
Style.clabel(r, str(i + 1), 20, Style.GOLD if next else Style.MUTED, "center", true, 40)
|
||||
Style.clabel(r, "%s · %s" % [st.name, st.title], 17, Style.TEXT if not done else Style.MUTED, "left", true, 300)
|
||||
var dots := Control.new()
|
||||
dots.custom_minimum_size = Vector2(60, 0)
|
||||
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var elems: Array = st.elements
|
||||
dots.draw.connect(func():
|
||||
for k in elems.size():
|
||||
dots.draw_circle(Vector2(10 + k * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[k]]))
|
||||
r.add_child(dots)
|
||||
var table: String = "Duell" if st.extra == 0 else "%d am Tisch" % (st.extra + 2)
|
||||
Style.clabel(r, "%s · %d Münzen\n★ %s" % [table, st.reward, Game.PERKS[st.perk]], 13, Style.MUTED, "left", false, 300)
|
||||
var b := Button.new()
|
||||
b.text = str(i + 1)
|
||||
b.position = centers[k] - Vector2(24, 24)
|
||||
b.size = Vector2(48, 48)
|
||||
var base: Color = Style.PRIMARY if done else (Color("5a4628") if next else Style.PANEL)
|
||||
var edge: Color = Style.GOLD if i == selected else (Style.GREEN if done else Style.MUTED)
|
||||
b.add_theme_stylebox_override("normal", Style.box(base, 24, edge, 3 if i == selected else 2))
|
||||
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 24, edge, 3))
|
||||
b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 24, edge, 3))
|
||||
b.add_theme_stylebox_override("disabled", Style.box(Style.darker(Style.PANEL, 0.8), 24, Style.PANEL_LIGHT, 2))
|
||||
b.add_theme_stylebox_override("focus", Style.box(Color(0, 0, 0, 0), 24, Style.GOLD, 3))
|
||||
b.disabled = i > p.campaign_stage
|
||||
b.pressed.connect(func():
|
||||
selected = i
|
||||
build())
|
||||
Style.juice(b)
|
||||
add_child(b)
|
||||
node_buttons.append(b)
|
||||
if next and Style.animations:
|
||||
b.pivot_offset = b.size / 2
|
||||
var tw := b.create_tween().set_loops()
|
||||
tw.tween_property(b, "scale", Vector2(1.1, 1.1), 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
tw.tween_property(b, "scale", Vector2.ONE, 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
# Gewählte Stufe
|
||||
var st := Campaign.stage(selected)
|
||||
var done := selected < p.campaign_stage
|
||||
var next := selected == p.campaign_stage
|
||||
Style.panel(self, Rect2(240, 270, 800, 300))
|
||||
Style.label(self, "%s · %s" % [st.name, st.title], 28, Style.TEXT, Rect2(270, 290, 740, 40), "center", true)
|
||||
Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 350, 740, 24), "center")
|
||||
var perks: Array = st.perks
|
||||
var py := 390
|
||||
for k in perks:
|
||||
Style.label(self, "★ " + tr(Game.PERKS[k]), 15, Style.GOLD, Rect2(270, py, 740, 22), "center")
|
||||
py += 24
|
||||
if done:
|
||||
Style.clabel(r, "abgeschlossen", 14, Style.GREEN, "right", false, 110)
|
||||
Style.cbutton(r, "Wiederholen", Vector2(140, 38), _start.bind(i))
|
||||
Style.button(self, "Wiederholen", Rect2(540, 500, 200, 50), _start.bind(selected))
|
||||
elif next:
|
||||
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110)
|
||||
Style.cbutton(r, "Kämpfen", Vector2(140, 38), _start.bind(i), Color("285a3c"))
|
||||
var b := Style.button(self, "Kämpfen", Rect2(540, 500, 200, 50), _start.bind(selected), Style.PRIMARY)
|
||||
b.add_theme_font_size_override("font_size", 20)
|
||||
else:
|
||||
Style.clabel(r, "", 14, Style.GREEN, "right", false, 110)
|
||||
var b := Style.cbutton(r, "Gesperrt", Vector2(140, 38), func(): pass)
|
||||
var b := Style.button(self, "Gesperrt", Rect2(540, 500, 200, 50), func(): pass)
|
||||
b.disabled = true
|
||||
if p.campaign_stage >= Campaign.STAGES.size():
|
||||
Style.clabel(col, "Kampagne abgeschlossen. Der Archimagier ist besiegt!", 18, Style.GREEN, "center", true)
|
||||
Style.spacer(col, 4)
|
||||
var br := Style.row(col)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
if p.campaign_stage >= Campaign.total():
|
||||
Style.label(self, "Alle Kapitel abgeschlossen", 16, Style.GREEN, Rect2(0, 600, 1280, 24), "center", true)
|
||||
|
||||
|
||||
func set_chapter(c: int) -> void:
|
||||
chapter = c
|
||||
var p: Profile = app.profile
|
||||
var first := (c - 1) * Campaign.STAGES.size()
|
||||
selected = p.campaign_stage if Campaign.chapter(mini(p.campaign_stage, Campaign.total() - 1)) == c else first
|
||||
build()
|
||||
|
||||
|
||||
func _start(stage: int) -> void:
|
||||
app.show_screen(BattleScreen.new(0, stage))
|
||||
app.show_screen(BattleScreen.new(stage))
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
class_name CardGrid
|
||||
extends ScrollContainer
|
||||
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte.
|
||||
## Scrollbares Kartenraster.
|
||||
|
||||
signal card_clicked(card: Cards.Card)
|
||||
signal card_hover(card: Cards.Card, entered: bool)
|
||||
|
||||
const COLS := 7
|
||||
const CW := 100
|
||||
const CH := 140
|
||||
const GAP := 12
|
||||
const COLS := 6
|
||||
const CW := 118
|
||||
const CH := 165
|
||||
const GAP := 10
|
||||
|
||||
var grid: GridContainer
|
||||
|
||||
@@ -17,6 +16,7 @@ func _init(rect: Rect2) -> void:
|
||||
position = rect.position
|
||||
size = rect.size
|
||||
horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
follow_focus = true # Gamepad: der fokussierten Karte hinterherscrollen
|
||||
grid = GridContainer.new()
|
||||
grid.columns = COLS
|
||||
grid.add_theme_constant_override("h_separation", GAP)
|
||||
@@ -24,20 +24,16 @@ func _init(rect: Rect2) -> void:
|
||||
add_child(grid)
|
||||
|
||||
|
||||
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool
|
||||
func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
|
||||
## cards: Karten; rarity_fn(card) -> String: die Stufe, die die Karte zeigt (Optik)
|
||||
func fill(cards: Array, rarity_fn: Callable) -> void:
|
||||
Style.clear(grid)
|
||||
for c in cards:
|
||||
var cell := Control.new()
|
||||
cell.custom_minimum_size = Vector2(CW, CH + 16)
|
||||
cell.custom_minimum_size = Vector2(CW, CH)
|
||||
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var v := CardView.new(c, Vector2(CW, CH))
|
||||
v.dim = dim_fn.call(c)
|
||||
var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c))
|
||||
v.clicked.connect(func(_view): card_clicked.emit(c))
|
||||
v.hover.connect(func(card, entered): card_hover.emit(card, entered))
|
||||
cell.add_child(v)
|
||||
var text: String = label_fn.call(c)
|
||||
Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center")
|
||||
grid.add_child(cell)
|
||||
|
||||
|
||||
|
||||
@@ -1,65 +1,109 @@
|
||||
class_name CardView
|
||||
extends Control
|
||||
## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau).
|
||||
## Oben Kosten (links) und Name, in der Mitte das Bild im Kreis, unten Angriff (links) und
|
||||
## Leben (rechts). Die Elemente färben den Hintergrund (verschmolzen: ein Streifen je Element),
|
||||
## das Bild trägt bei Verschmelzungen der stärkere Elternteil; die Seltenheit steckt in Name,
|
||||
## Bild, Werten und Rahmen (siehe Style).
|
||||
|
||||
signal clicked(view: CardView)
|
||||
signal pressed(view: CardView, pos: Vector2) # nur mit drag_enabled: der Kampf macht daraus Klick oder Ziehen
|
||||
signal hover(card: Cards.Card, entered: bool)
|
||||
|
||||
var card: Cards.Card
|
||||
var border = null # Color oder null (dann Seltenheitsfarbe)
|
||||
var border = null # Color oder null (dann Rahmen der Seltenheitsstufe)
|
||||
var rarity := "Common" # nur Optik
|
||||
var dim := false
|
||||
var 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
|
||||
|
||||
|
||||
func _init(c: Cards.Card, sz: Vector2) -> void:
|
||||
## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
|
||||
func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "Common") -> void:
|
||||
card = c
|
||||
rarity = rar
|
||||
set_process(Style.rarity_animated(rarity)) # bewegter Glanz
|
||||
custom_minimum_size = sz
|
||||
size = sz
|
||||
pivot_offset = sz / 2
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
mouse_entered.connect(func(): hover.emit(card, true))
|
||||
mouse_exited.connect(func(): hover.emit(card, false))
|
||||
mouse_entered.connect(func():
|
||||
hover.emit(card, true)
|
||||
_lift(true))
|
||||
mouse_exited.connect(func():
|
||||
hover.emit(card, false)
|
||||
_lift(false))
|
||||
if focusable:
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
focus_entered.connect(func():
|
||||
hover.emit(card, true)
|
||||
queue_redraw())
|
||||
focus_exited.connect(func():
|
||||
hover.emit(card, false)
|
||||
queue_redraw())
|
||||
|
||||
|
||||
func _lift(up: bool) -> void:
|
||||
if not lift or not Style.animations:
|
||||
return
|
||||
z_index = 1 if up else 0
|
||||
create_tween().tween_property(self, "scale", Vector2(1.07, 1.07) if up else Vector2.ONE, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
if drag_enabled:
|
||||
pressed.emit(self, event.global_position)
|
||||
else:
|
||||
clicked.emit(self)
|
||||
elif has_focus() and event.is_action_pressed("ui_accept"):
|
||||
accept_event()
|
||||
clicked.emit(self)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := Rect2(Vector2.ZERO, size)
|
||||
var big := size.x >= 180
|
||||
var mid := size.x >= 115
|
||||
var f_name := 18 if big else (14 if mid else 12)
|
||||
var f_text := 14 if big else (11 if mid else 10)
|
||||
var f_stat := 24 if big else (18 if mid else 15)
|
||||
var seed := hash(card.id)
|
||||
var frame: Color = border if border != null else Style.rarity_frame(rarity, seed)
|
||||
if rarity != "Common":
|
||||
Style.glow(self, rect, Style.rarity_color(rarity, seed), 8, 8 if size.x >= 180 else 6)
|
||||
Style.fill_elements(self, rect, card.elements, 8)
|
||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[card.rarity]
|
||||
Style.border(self, rect, bcol, 3 if big else 2, 8)
|
||||
# Kosten
|
||||
var r := 16 if big else 12
|
||||
var cc := Vector2(r + 4, r + 4)
|
||||
draw_circle(cc, r, Color("14141c"))
|
||||
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true)
|
||||
# Name und Kombo
|
||||
var nm := Style.fit(card.name, true, f_name, size.x - 2 * r - 12)
|
||||
Style.text(self, nm, f_name, Style.TEXT, Vector2(size.x / 2 + r / 2.0, 4), "midtop", true)
|
||||
if mid:
|
||||
var sub: String = (card.combo() + " · " + card.kind) if card.is_combo() else card.kind
|
||||
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop")
|
||||
# Bild
|
||||
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0)
|
||||
var band_bottom: float = size.y * 0.42 - 4
|
||||
if band_bottom - band_top > 14:
|
||||
var gsize: float = minf(band_bottom - band_top, size.x * 0.42)
|
||||
Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize)
|
||||
# Textfeld
|
||||
var box := Rect2(6, size.y * 0.42, size.x - 12, size.y * 0.42)
|
||||
draw_rect(box, Color(0.06, 0.06, 0.09, 0.67))
|
||||
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(f_text)),
|
||||
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, f_text, 6 if big else 4, Style.TEXT)
|
||||
# Werte
|
||||
if card.is_creature():
|
||||
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
|
||||
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
|
||||
var big := size.x >= 180
|
||||
var mid := size.x >= 140
|
||||
var f_name := 17 if big else (14 if mid else 11)
|
||||
var f_stat := 20 if big else (17 if mid else 14)
|
||||
var r_cost := 17.0 if big else (14.0 if mid else 12.0)
|
||||
var r_stat := 18.0 if big else (15.0 if mid else 12.0)
|
||||
var pad := 8.0 if mid else 6.0
|
||||
var shiny := Style.rarity_stats_shiny(rarity)
|
||||
# Oben: Kosten links, daneben der Name; das Namensfeld ist genau so hoch wie der Kristall
|
||||
var top_h := r_cost * 2 + 4
|
||||
draw_rect(Rect2(2, 2, size.x - 4, top_h), Color(0.05, 0.05, 0.08, 0.6))
|
||||
Style.card_name(self, card.title(), f_name, rarity, Rect2(r_cost * 2 + 10, 2, size.x - r_cost * 2 - pad - 10, top_h), seed)
|
||||
# Mitte: das Bild im Kreis, so groß wie der Platz bis zu den Werten erlaubt
|
||||
var pic_top := top_h + 4
|
||||
var pic_bottom := size.y - r_stat - 4
|
||||
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
|
||||
Style.picture(self, card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rarity, seed)
|
||||
# Unten: Angriff links, Leben rechts
|
||||
Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat, Color.WHITE, shiny)
|
||||
Style.stat_circle(self, Vector2(size.x - r_stat + 2, size.y - r_stat + 2), r_stat, str(card.health), Style.LIFE, f_stat, Color.WHITE, shiny)
|
||||
if bonus > 0:
|
||||
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)
|
||||
Style.card_sheen(self, rect, rarity, seed)
|
||||
Style.border(self, rect, frame, 3 if big else 2, 8)
|
||||
Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, card.cost, f_stat, shiny)
|
||||
Style.rarity_sparkle(self, rect, rarity, seed)
|
||||
if dim:
|
||||
draw_rect(rect, Color(0, 0, 0, 0.47))
|
||||
if has_focus():
|
||||
Style.border(self, rect, Style.GOLD, 4, 8)
|
||||
if drop:
|
||||
Style.border(self, rect, Style.GOLD, 5, 8)
|
||||
|
||||
174
scripts/ui/collection_screen.gd
Normal file
174
scripts/ui/collection_screen.gd
Normal 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
scripts/ui/collection_screen.gd.uid
Normal file
1
scripts/ui/collection_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dsqeag3p0ehbq
|
||||
@@ -2,7 +2,8 @@ class_name CreatureView
|
||||
extends Control
|
||||
## Zeichnet ein Wesen auf dem Feld. Bleibt über Zustandsänderungen bestehen (für Animationen).
|
||||
|
||||
signal clicked(view: CreatureView)
|
||||
signal pressed(view: CreatureView, pos: Vector2) # linke Maustaste gedrückt; der Kampf macht daraus Klick oder Ziehen
|
||||
signal double_clicked(view: CreatureView) # Doppelklick: eigenes Wesen greift das Leben des Gegners an
|
||||
signal hover(card: Cards.Card, entered: bool)
|
||||
|
||||
var creature: Game.Creature
|
||||
@@ -11,6 +12,11 @@ var width := 2
|
||||
var flash := false # kurz rot: wurde gerade angegriffen
|
||||
var slot: Vector2 # Zielposition im Raster
|
||||
var busy := false # wird gerade animiert (kein Neupositionieren)
|
||||
var matchup := 0 # 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:
|
||||
@@ -20,73 +26,121 @@ func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
|
||||
custom_minimum_size = sz
|
||||
size = sz
|
||||
pivot_offset = sz / 2
|
||||
set_process(Style.rarity_animated(rarity())) # bewegter Glanz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
mouse_entered.connect(func(): hover.emit(creature.card, true))
|
||||
mouse_exited.connect(func(): hover.emit(creature.card, false))
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit(self)
|
||||
if event.double_click:
|
||||
double_clicked.emit(self)
|
||||
else:
|
||||
pressed.emit(self, event.global_position)
|
||||
|
||||
|
||||
func rarity() -> String:
|
||||
var owner = creature.owner()
|
||||
return creature.card.rarity_in(owner.looks if owner != null else {})
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := Rect2(Vector2.ZERO, size)
|
||||
if secret:
|
||||
_draw_back(rect)
|
||||
_draw_drop()
|
||||
return
|
||||
var small := size.x < 100
|
||||
var mini := size.x < 60
|
||||
var rar := rarity()
|
||||
var seed := hash(creature.card.id) + creature.uid
|
||||
if rar != "Common":
|
||||
Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 6)
|
||||
Style.fill_elements(self, rect, creature.card.elements, 6)
|
||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[creature.card.rarity]
|
||||
var bcol: Color = border if border != null else Style.rarity_frame(rar, seed)
|
||||
if flash:
|
||||
bcol = Style.RED
|
||||
var shiny := Style.rarity_stats_shiny(rar)
|
||||
# Oben: Kosten links, daneben der Name; das Namensfeld ist genau so hoch wie der Kristall
|
||||
var fs := 12 if small else 13
|
||||
var r_cost := 10.0 if small else 11.0
|
||||
var top_h := r_cost * 2 + 4
|
||||
draw_rect(Rect2(2, 2, size.x - 4, top_h), Color(0.05, 0.05, 0.08, 0.6))
|
||||
Style.card_name(self, creature.card.title(), fs, rar, Rect2(r_cost * 2 + 10, 2, size.x - r_cost * 2 - 16, top_h), seed)
|
||||
# Mitte: das Bild im Kreis; unten Angriff links, Leben rechts
|
||||
var r_stat := 12.0 if small else 14.0
|
||||
var pic_top := top_h + 4
|
||||
var pic_bottom := size.y - r_stat - 4
|
||||
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
|
||||
Style.picture(self, creature.card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rar, seed)
|
||||
# Unter dem Namen links die Eigenschaften (in der Farbe des Elements, das sie gibt), 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)
|
||||
if mini:
|
||||
_draw_mini()
|
||||
return
|
||||
var fs := 11 if small else 13
|
||||
var nm := Style.fit(creature.name, true, fs, size.x - 6)
|
||||
Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true)
|
||||
var badges: Array[String] = []
|
||||
if creature.taunt():
|
||||
badges.append("Wächter")
|
||||
if creature.shield:
|
||||
badges.append("Schild")
|
||||
if creature.attacks_per_turn() == 2:
|
||||
badges.append("x2")
|
||||
if creature.frozen:
|
||||
badges.append("Eis")
|
||||
if creature.resonance > 0:
|
||||
badges.append("Resonanz")
|
||||
var y := 18.0 if small else 22.0
|
||||
for b in badges:
|
||||
Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop")
|
||||
y += 12 if small else 15
|
||||
var f := 16 if small else 20
|
||||
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
|
||||
Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true)
|
||||
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
|
||||
Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true)
|
||||
if creature.frozen:
|
||||
draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35))
|
||||
Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, creature.card.cost, 13, shiny)
|
||||
if creature.hidden:
|
||||
draw_rect(rect, Color(0.05, 0.05, 0.1, 0.55))
|
||||
Style.text_outlined(self, tr("verdeckt"), 12, Style.TEXT, Vector2(size.x / 2, size.y / 2), "center", 3)
|
||||
if doomed:
|
||||
draw_rect(Rect2(0, size.y * 0.5 - 12, size.x, 24), Color(0.05, 0.02, 0.02, 0.8))
|
||||
Style.text_outlined(self, tr("stirbt"), 14, Style.RED, Vector2(size.x / 2, size.y * 0.5), "center", 3)
|
||||
Style.rarity_sparkle(self, rect, rar, seed)
|
||||
_draw_drop()
|
||||
|
||||
|
||||
## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe.
|
||||
func _draw_mini() -> void:
|
||||
var letters: Array[String] = []
|
||||
if creature.taunt():
|
||||
letters.append("W")
|
||||
if creature.shield:
|
||||
letters.append("S")
|
||||
if creature.attacks_per_turn() == 2:
|
||||
letters.append("2")
|
||||
if creature.frozen:
|
||||
letters.append("E")
|
||||
if creature.resonance > 0:
|
||||
letters.append("R")
|
||||
Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true)
|
||||
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
|
||||
Style.text(self, str(creature.power()), 13, atk_col, Vector2(4, size.y - 2), "bottomleft", true)
|
||||
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
|
||||
Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true)
|
||||
if creature.frozen:
|
||||
draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35))
|
||||
## Goldener Rahmen, solange eine Karte zum Verschmelzen darüber schwebt.
|
||||
func _draw_drop() -> void:
|
||||
if drop:
|
||||
Style.border(self, Rect2(Vector2.ZERO, size), Style.GOLD, 5, 6)
|
||||
|
||||
|
||||
## Verdecktes fremdes Wesen: ein Kartenrücken.
|
||||
func _draw_back(rect: Rect2) -> void:
|
||||
var bcol: Color = border if border != null else Style.GOLD
|
||||
if flash:
|
||||
bcol = Style.RED
|
||||
draw_style_box(Style.box(Color("3c285a"), 6, bcol, 4 if flash else maxi(width, 2)), rect)
|
||||
Style.border(self, rect.grow(-10), Color("5a3c82"), 2, 6)
|
||||
Style.text(self, "?", 36, Style.GOLD, size / 2, "center", true)
|
||||
|
||||
|
||||
## Angriff und Leben für den Angriff, der gerade vorbereitet wird. Fremdes Wesen bei gewähltem
|
||||
## Angreifer: sein Konter gegen ihn (im Nachteil 1 weniger, im Vorteil 1 mehr, auch bei 0 Angriff),
|
||||
## das Leben bleibt echt. Eigener Angreifer über einem Ziel: sein Angriff mit Elementvorteil.
|
||||
func shown_stats() -> Array:
|
||||
if matchup != 0:
|
||||
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)
|
||||
|
||||
@@ -1,351 +1,101 @@
|
||||
class_name DeckScreen
|
||||
extends Control
|
||||
## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten.
|
||||
|
||||
const SORTS := ["Kosten", "Name", "Element", "Seltenheit"]
|
||||
## 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
|
||||
## 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 slot := 0
|
||||
var deck: Array = []
|
||||
var filter := ""
|
||||
var search := ""
|
||||
var sort_mode := 0
|
||||
var show_missing := false
|
||||
var msg_label: Label
|
||||
var grid: CardGrid
|
||||
var deck_list: Control
|
||||
var deck_label: Label
|
||||
var elems_label: Label
|
||||
var valid_label: Label
|
||||
var active_label: Label
|
||||
var curve: Control
|
||||
var preview: CardView
|
||||
var search_box: LineEdit
|
||||
var sort_box: OptionButton
|
||||
var missing_box: CheckButton
|
||||
var slot_buttons: Array = []
|
||||
var b_activate: Button
|
||||
var b_autofill: Button
|
||||
var name_box: LineEdit
|
||||
|
||||
|
||||
class DeckRow extends Control:
|
||||
signal clicked(card)
|
||||
signal hover(card, entered: bool)
|
||||
var card: Cards.Card
|
||||
var count: int
|
||||
var lit := false
|
||||
|
||||
func _init(c: Cards.Card, n: int, sz: Vector2) -> void:
|
||||
card = c
|
||||
count = n
|
||||
size = sz
|
||||
custom_minimum_size = sz
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
mouse_entered.connect(func():
|
||||
lit = true
|
||||
queue_redraw()
|
||||
hover.emit(card, true))
|
||||
mouse_exited.connect(func():
|
||||
lit = false
|
||||
queue_redraw()
|
||||
hover.emit(card, false))
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
accept_event()
|
||||
clicked.emit(card)
|
||||
|
||||
func _draw() -> void:
|
||||
if lit:
|
||||
draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size))
|
||||
for k in card.elements.size():
|
||||
draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]])
|
||||
Style.text(self, "%dx %s" % [count, card.name], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
|
||||
Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true)
|
||||
|
||||
|
||||
class EnergyCurve extends Control:
|
||||
var cards: Array = []
|
||||
|
||||
func _draw() -> void:
|
||||
Style.text(self, "Energiekurve", 12, Style.MUTED, Vector2(0, 0))
|
||||
var counts := {}
|
||||
for c in cards:
|
||||
var k: int = mini(c.cost, 8)
|
||||
counts[k] = counts.get(k, 0) + 1
|
||||
for i in range(1, 9):
|
||||
var n: int = counts.get(i, 0)
|
||||
var x := (i - 1) * 48
|
||||
draw_rect(Rect2(x, 104 - n * 5, 36, n * 5), Style.BLUE)
|
||||
if n > 0:
|
||||
Style.text(self, str(n), 11, Style.TEXT, Vector2(x + 18, 102 - n * 5), "midbottom")
|
||||
Style.text(self, str(i) + ("+" if i == 8 else ""), 12, Style.MUTED, Vector2(x + 18, 108), "midtop")
|
||||
var count_label: Label
|
||||
var strength_label: Label
|
||||
var msg_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
slot = app.profile.active_deck
|
||||
deck = app.profile.decks[slot].cards.duplicate()
|
||||
Style.label(self, "Deck bauen", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
|
||||
Style.label(self, "Klick: Karte ins Deck. Klick in der Liste: Karte entfernen. Mausrad: scrollen.",
|
||||
14, Style.MUTED, Rect2(300, 18, 700, 24))
|
||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
||||
for i in Cards.ELEMENTS.size():
|
||||
var e: String = Cards.ELEMENTS[i]
|
||||
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
||||
search_box = LineEdit.new()
|
||||
search_box.position = Vector2(400, 55)
|
||||
search_box.size = Vector2(160, 34)
|
||||
search_box.placeholder_text = "Suche"
|
||||
search_box.text_changed.connect(func(t: String):
|
||||
search = t
|
||||
refresh())
|
||||
add_child(search_box)
|
||||
sort_box = OptionButton.new()
|
||||
sort_box.position = Vector2(570, 55)
|
||||
sort_box.size = Vector2(130, 34)
|
||||
for s in SORTS:
|
||||
sort_box.add_item(s)
|
||||
sort_box.item_selected.connect(func(i: int):
|
||||
sort_mode = i
|
||||
refresh())
|
||||
add_child(sort_box)
|
||||
missing_box = CheckButton.new()
|
||||
missing_box.position = Vector2(710, 58)
|
||||
missing_box.text = "Fehlende"
|
||||
missing_box.toggled.connect(func(on: bool):
|
||||
show_missing = on
|
||||
refresh())
|
||||
add_child(missing_box)
|
||||
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
|
||||
grid.card_clicked.connect(add)
|
||||
grid.card_hover.connect(show_preview)
|
||||
Style.header(self, "Deck", func(): app.show_screen(MenuScreen.new()))
|
||||
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)
|
||||
grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
|
||||
grid.card_clicked.connect(toggle)
|
||||
add_child(grid)
|
||||
# Rechte Seite: Slots, Deckliste, Kurve
|
||||
Style.panel(self, Rect2(845, 50, 420, 680))
|
||||
for i in Profile.DECK_SLOTS:
|
||||
slot_buttons.append(Style.button(self, str(i + 1), Rect2(860 + i * 46, 58, 40, 32), select_slot.bind(i)))
|
||||
b_activate = Style.button(self, "Aktiv setzen", Rect2(1100, 58, 150, 32), activate)
|
||||
name_box = LineEdit.new()
|
||||
name_box.position = Vector2(860, 98)
|
||||
name_box.size = Vector2(200, 30)
|
||||
name_box.max_length = 20
|
||||
name_box.text_submitted.connect(func(t: String): rename(t))
|
||||
name_box.focus_exited.connect(func(): rename(name_box.text))
|
||||
add_child(name_box)
|
||||
deck_label = Style.label(self, "", 22, Style.TEXT, Rect2(1070, 98, 120, 30), "left", true)
|
||||
active_label = Style.label(self, "", 14, Style.GREEN, Rect2(1180, 104, 75, 24), "right")
|
||||
b_autofill = Style.button(self, "Auto-Ausfüllen", Rect2(1100, 154, 150, 26), autofill)
|
||||
b_autofill.add_theme_font_size_override("font_size", 13)
|
||||
elems_label = Style.label(self, "", 16, Style.MUTED, Rect2(860, 130, 390, 24))
|
||||
valid_label = Style.label(self, "", 14, Style.GREEN, Rect2(860, 156, 235, 22))
|
||||
deck_list = Control.new()
|
||||
deck_list.position = Vector2(860, 182)
|
||||
deck_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(deck_list)
|
||||
curve = EnergyCurve.new()
|
||||
curve.position = Vector2(860, 604)
|
||||
curve.size = Vector2(400, 125)
|
||||
curve.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(curve)
|
||||
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
|
||||
Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c"))
|
||||
Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck)
|
||||
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
preview = CardView.new(Cards.all()[0], Vector2(200, 280))
|
||||
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview.visible = false
|
||||
add_child(preview)
|
||||
Style.panel(self, Rect2(820, 86, 420, 696))
|
||||
count_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
|
||||
strength_label = Style.chip(self, "", Rect2(860, 144, 340, 36), Style.GOLD)
|
||||
var hint := Style.label(self, "", 15, Style.MUTED, Rect2(840, 188, 380, 66), "center")
|
||||
hint.autowrap_mode = TextServer.AUTOWRAP_WORD # vor dem Text, sonst wächst die Breite auf eine Zeile
|
||||
hint.text = "Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die wertvollsten Karten des Filters."
|
||||
Style.button(self, "Auffüllen", Rect2(840, 262, 380, 46), fill_up)
|
||||
Style.button(self, "Leeren", Rect2(840, 318, 380, 46), clear_deck, Color("5a3c28"))
|
||||
msg_label = Style.label(self, "", 15, Style.RED, Rect2(840, 382, 380, 48), "center")
|
||||
refresh()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus() and not name_box.has_focus():
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if preview.visible:
|
||||
var pos := get_local_mouse_position() + Vector2(16, 16)
|
||||
pos.x = clampf(pos.x, 0, 1280 - preview.size.x)
|
||||
pos.y = clampf(pos.y, 0, 800 - preview.size.y)
|
||||
preview.position = pos
|
||||
|
||||
|
||||
func set_filter(e: String) -> void:
|
||||
filter = e
|
||||
refresh()
|
||||
|
||||
|
||||
func select_slot(i: int) -> void:
|
||||
slot = i
|
||||
deck = app.profile.decks[slot].cards.duplicate()
|
||||
say("", true)
|
||||
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)
|
||||
func toggle(card: Cards.Card) -> void:
|
||||
var err: String = app.profile.toggle_deck(card.id)
|
||||
msg_label.text = 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.text().to_lower() or q in c.combo().to_lower())
|
||||
match sort_mode:
|
||||
0:
|
||||
out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
|
||||
1:
|
||||
out.sort_custom(func(a, b): return a.name < b.name)
|
||||
3:
|
||||
out.sort_custom(func(a, b):
|
||||
var ra := Cards.RARITIES.find(a.rarity)
|
||||
var rb := Cards.RARITIES.find(b.rarity)
|
||||
return ra > rb or (ra == rb and a.cost < b.cost))
|
||||
return out
|
||||
|
||||
|
||||
func say(m: String, ok: bool) -> void:
|
||||
msg_label.text = m
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
|
||||
|
||||
|
||||
func add(card: Cards.Card) -> void:
|
||||
var owned: int = app.profile.collection.get(card.id, 0)
|
||||
var have := deck.count(card.id)
|
||||
if owned == 0:
|
||||
say("%s fehlt in deiner Sammlung (Werkstatt)" % card.name, false)
|
||||
elif deck.size() >= Game.DECK_SIZE:
|
||||
say("Das Deck ist voll (%d)" % Game.DECK_SIZE, false)
|
||||
elif have >= mini(owned, Game.MAX_COPIES):
|
||||
say("Nicht mehr Kopien möglich (max. %d)" % mini(owned, Game.MAX_COPIES), false)
|
||||
else:
|
||||
var elems := Game.deck_elements(deck_cards())
|
||||
for e in card.elements:
|
||||
if not (e in elems):
|
||||
elems.append(e)
|
||||
if elems.size() > Game.MAX_DECK_ELEMENTS:
|
||||
say("Höchstens %d Elemente im Deck" % Game.MAX_DECK_ELEMENTS, false)
|
||||
else:
|
||||
deck.append(card.id)
|
||||
app.sfx.play("click")
|
||||
say("", true)
|
||||
app.save()
|
||||
refresh()
|
||||
|
||||
|
||||
func remove(card: Cards.Card) -> void:
|
||||
deck.erase(card.id)
|
||||
say("", true)
|
||||
## Füllt mit den wertvollsten eigenen Karten des gewählten Filters bis zum Höchstwert auf.
|
||||
func fill_up() -> void:
|
||||
var p: Profile = app.profile
|
||||
var pool: Array = p.collection.keys()
|
||||
if filter != "":
|
||||
pool = pool.filter(func(id): return filter in Cards.by_id(id).elements)
|
||||
p.deck = DeckGen.fill(p.deck, pool, Game.DECK_MAX, p.collection)
|
||||
app.save()
|
||||
refresh()
|
||||
|
||||
|
||||
func clear_deck() -> void:
|
||||
deck = []
|
||||
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.profile.deck = []
|
||||
app.save()
|
||||
say("%s gespeichert" % app.profile.deck_name(slot), true)
|
||||
else:
|
||||
say(ok[1], false)
|
||||
refresh()
|
||||
|
||||
|
||||
func deck_cards() -> Array:
|
||||
var out := []
|
||||
for cid in deck:
|
||||
out.append(Cards.by_id(cid))
|
||||
## Eigene Karten, die im Stapel zuerst, dann höchste Stufen vorn, dann nach Kosten.
|
||||
func cards() -> Array:
|
||||
var p: Profile = app.profile
|
||||
var out: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
|
||||
if filter != "":
|
||||
out = out.filter(func(c): return filter in c.elements)
|
||||
out.sort_custom(func(a, b):
|
||||
var ia: bool = a.id in p.deck
|
||||
var ib: bool = b.id in p.deck
|
||||
if ia != ib:
|
||||
return ia
|
||||
if p.level(a.id) != p.level(b.id):
|
||||
return p.level(a.id) > p.level(b.id)
|
||||
return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
|
||||
return out
|
||||
|
||||
|
||||
func show_preview(card: Cards.Card, entered: bool) -> void:
|
||||
if entered:
|
||||
preview.card = card
|
||||
preview.visible = true
|
||||
preview.queue_redraw()
|
||||
elif preview.card == card:
|
||||
preview.visible = false
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
grid.fill(cards(),
|
||||
func(c):
|
||||
var owned: int = p.collection.get(c.id, 0)
|
||||
return "fehlt" if owned == 0 else "%d/%d im Deck" % [deck.count(c.id), owned],
|
||||
func(c):
|
||||
var owned: int = p.collection.get(c.id, 0)
|
||||
return owned == 0 or deck.count(c.id) >= mini(owned, Game.MAX_COPIES))
|
||||
for i in slot_buttons.size():
|
||||
var b: Button = slot_buttons[i]
|
||||
var col: Color = Style.GOLD if i == slot else (Style.GREEN if p.deck_valid(i)[0] else Style.MUTED)
|
||||
b.add_theme_stylebox_override("normal", Style.box(Style.PANEL_LIGHT if i != slot else Style.darker(Style.GOLD, 0.45), 6, col, 2))
|
||||
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Style.PANEL_LIGHT), 6, col, 2))
|
||||
active_label.text = "aktiv" if slot == p.active_deck else ""
|
||||
# Deckliste
|
||||
Style.clear(deck_list)
|
||||
var counts := {}
|
||||
for cid in deck:
|
||||
counts[cid] = counts.get(cid, 0) + 1
|
||||
var listed: Array = counts.keys().map(func(cid): return Cards.by_id(cid))
|
||||
listed.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
|
||||
for i in listed.size():
|
||||
var c: Cards.Card = listed[i]
|
||||
var row := DeckRow.new(c, counts[c.id], Vector2(400, 20))
|
||||
row.position = Vector2(0, i * 21)
|
||||
row.clicked.connect(remove)
|
||||
row.hover.connect(show_preview)
|
||||
deck_list.add_child(row)
|
||||
var dc := deck_cards()
|
||||
if not name_box.has_focus():
|
||||
name_box.text = p.deck_name(slot)
|
||||
deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE]
|
||||
deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT)
|
||||
var elems := Game.deck_elements(dc)
|
||||
elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b))
|
||||
elems_label.text = "Elemente: " + (", ".join(elems) if not elems.is_empty() else "–")
|
||||
var ok: Array = Game.validate_deck(dc, p.collection)
|
||||
valid_label.text = "spielbar" if ok[0] else ok[1]
|
||||
valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE)
|
||||
curve.cards = dc
|
||||
curve.queue_redraw()
|
||||
count_label.text = tr("Stapel: %d Karten (%d bis %d)") % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX]
|
||||
count_label.add_theme_color_override("font_color", Style.RED if p.deck.size() < Game.DECK_MIN else Style.BLUE)
|
||||
strength_label.text = tr("Stärke %d") % DeckGen.strength(p.deck, p.collection)
|
||||
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 (shown[i].id in p.deck)
|
||||
|
||||
@@ -1 +1 @@
|
||||
uid://yg8pbj56mib5
|
||||
uid://ctt36od26uyea
|
||||
|
||||
77
scripts/ui/element_view.gd
Normal file
77
scripts/ui/element_view.gd
Normal 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
scripts/ui/element_view.gd.uid
Normal file
1
scripts/ui/element_view.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c2tr1bg65rhk6
|
||||
@@ -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 +0,0 @@
|
||||
uid://crlne4y0uiygl
|
||||
@@ -1,112 +0,0 @@
|
||||
class_name LobbyScreen
|
||||
extends Control
|
||||
## Netzwerkspiel im lokalen Netz: Spiel eröffnen oder beitreten.
|
||||
|
||||
var app
|
||||
var net: Net
|
||||
var status_label: Label
|
||||
var list_label: Label
|
||||
var ip_box: LineEdit
|
||||
var b_host: Button
|
||||
var b_join: Button
|
||||
var b_start: Button
|
||||
var ai_buttons: Array = []
|
||||
var n_ai := 0
|
||||
var addr_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
net = app.net
|
||||
net.my_name = "Spieler"
|
||||
net.my_deck = app.profile.deck.duplicate()
|
||||
var col := Style.column(self, 760, 40, 10)
|
||||
Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Im selben Netz: einer eröffnet, die anderen treten mit seiner Adresse bei. Jeder gegen jeden, ohne Münzen.", 16, Style.MUTED)
|
||||
Style.spacer(col, 10)
|
||||
var hr := Style.row(col, 12)
|
||||
hr.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c"))
|
||||
ip_box = LineEdit.new()
|
||||
ip_box.custom_minimum_size = Vector2(220, 46)
|
||||
ip_box.placeholder_text = "Adresse des Hosts"
|
||||
ip_box.text = "127.0.0.1"
|
||||
hr.add_child(ip_box)
|
||||
b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join)
|
||||
addr_label = Style.clabel(col, "", 14, Style.MUTED)
|
||||
status_label = Style.clabel(col, "", 16, Style.BLUE)
|
||||
Style.spacer(col, 6)
|
||||
Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true)
|
||||
list_label = Style.clabel(col, "", 16, Style.TEXT)
|
||||
Style.spacer(col, 6)
|
||||
Style.clabel(col, "KI-Gegner (nur Host)", 16, Style.MUTED)
|
||||
var ar := Style.row(col, 10)
|
||||
ar.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
for n in range(0, 5):
|
||||
ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 40), func(): _set_ai(n)))
|
||||
Style.spacer(col, 10)
|
||||
var br := Style.row(col, 20)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c"))
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 48), _back)
|
||||
net.lobby_changed.connect(refresh)
|
||||
net.started.connect(_started)
|
||||
net.ended.connect(func(_reason): refresh())
|
||||
refresh()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if net.lobby_changed.is_connected(refresh):
|
||||
net.lobby_changed.disconnect(refresh)
|
||||
if net.started.is_connected(_started):
|
||||
net.started.disconnect(_started)
|
||||
|
||||
|
||||
func _host() -> void:
|
||||
net.host()
|
||||
refresh()
|
||||
|
||||
|
||||
func _join() -> void:
|
||||
net.join(ip_box.text.strip_edges())
|
||||
refresh()
|
||||
|
||||
|
||||
func _set_ai(n: int) -> void:
|
||||
n_ai = n
|
||||
refresh()
|
||||
|
||||
|
||||
func _start() -> void:
|
||||
net.start_battle(n_ai)
|
||||
|
||||
|
||||
func _started(seat: int) -> void:
|
||||
app.show_screen(BattleScreen.online(seat))
|
||||
|
||||
|
||||
func _back() -> void:
|
||||
net.leave()
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
status_label.text = net.status
|
||||
var lines: Array[String] = []
|
||||
var ids: Array = net.names.keys()
|
||||
ids.sort()
|
||||
for id in ids:
|
||||
lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""])
|
||||
list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
|
||||
b_host.disabled = net.connected
|
||||
b_join.disabled = net.connected
|
||||
ip_box.editable = not net.connected
|
||||
var humans := ids.size()
|
||||
b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1)
|
||||
for i in ai_buttons.size():
|
||||
ai_buttons[i].disabled = not net.is_host or i == n_ai or humans + i > Game.MAX_OPPONENTS + 1
|
||||
addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else ""
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not ip_box.has_focus():
|
||||
_back()
|
||||
@@ -1 +0,0 @@
|
||||
uid://bhnnxjh6rsxnf
|
||||
@@ -5,10 +5,10 @@ var profile: Profile
|
||||
var screen: Control
|
||||
var save_path: String = Profile.SAVE_PATH
|
||||
var sfx: Sfx
|
||||
var net: Net
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_map_gamepad()
|
||||
theme = Style.make_theme()
|
||||
var bg := Style.Backdrop.new()
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
@@ -16,37 +16,114 @@ func _ready() -> void:
|
||||
add_child(bg)
|
||||
sfx = Sfx.new()
|
||||
add_child(sfx)
|
||||
# Fester Pfad /root/Net: Netzwerkaufrufe werden über den Knotenpfad zugestellt,
|
||||
# der bei Host und Mitspielern gleich sein muss.
|
||||
net = Net.new()
|
||||
net.name = "Net"
|
||||
get_tree().root.add_child.call_deferred(net)
|
||||
profile = Profile.load_or_new(save_path)
|
||||
profile.refresh_daily()
|
||||
apply_settings()
|
||||
show_screen(MenuScreen.new() if profile.started() else StartDeckScreen.new())
|
||||
|
||||
|
||||
func apply_settings() -> void:
|
||||
sfx.set_volume(profile.settings.volume)
|
||||
sfx.set_music_volume(profile.settings.music)
|
||||
set_language(profile.settings.language)
|
||||
show_screen(MenuScreen.new())
|
||||
Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0
|
||||
Style.animations = bool(profile.settings.get("animations", true))
|
||||
theme = Style.make_theme()
|
||||
|
||||
|
||||
## Neues Profil: Sammlung und Fortschritt werden verworfen, das Startdeck wird neu gewählt.
|
||||
func reset_profile() -> void:
|
||||
profile = Profile.starter()
|
||||
save()
|
||||
apply_settings()
|
||||
show_screen(StartDeckScreen.new())
|
||||
|
||||
|
||||
func set_language(code: String) -> void:
|
||||
TranslationServer.set_locale(code)
|
||||
|
||||
|
||||
func show_screen(s: Control) -> void:
|
||||
var kept: Array = [] # vorige Bildschirme, zu denen back() zurückführt (Einstellungen aus dem Kampf)
|
||||
|
||||
|
||||
## keep: der aktuelle Bildschirm bleibt erhalten und kommt mit back() zurück.
|
||||
func show_screen(s: Control, keep: bool = false) -> void:
|
||||
if screen != null:
|
||||
if keep:
|
||||
remove_child(screen)
|
||||
kept.append(screen)
|
||||
else:
|
||||
screen.queue_free()
|
||||
for k in kept:
|
||||
k.queue_free()
|
||||
kept.clear()
|
||||
screen = s
|
||||
s.app = self
|
||||
s.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(s)
|
||||
if Style.animations:
|
||||
s.modulate.a = 0.0
|
||||
create_tween().tween_property(s, "modulate:a", 1.0, 0.15)
|
||||
if pad_mode:
|
||||
focus_first_button.call_deferred()
|
||||
|
||||
|
||||
var pad_mode := false # zuletzt Gamepad benutzt: Fokusrahmen sichtbar, Steuerkreuz wechselt Knöpfe
|
||||
|
||||
|
||||
## Gamepad in Menüs: erster Tastendruck setzt den Fokus, Maus nimmt ihn wieder weg.
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventJoypadButton or (event is InputEventJoypadMotion and absf(event.axis_value) > 0.5):
|
||||
if not pad_mode:
|
||||
pad_mode = true
|
||||
if get_viewport().gui_get_focus_owner() == null:
|
||||
focus_first_button()
|
||||
elif event is InputEventMouseMotion and pad_mode:
|
||||
pad_mode = false
|
||||
get_viewport().gui_release_focus()
|
||||
|
||||
|
||||
## Gamepad-Tasten für die Menünavigation: A bestätigt, B bricht ab (falls nicht schon zugeordnet).
|
||||
func _map_gamepad() -> void:
|
||||
for pair in [["ui_accept", JOY_BUTTON_A], ["ui_cancel", JOY_BUTTON_B]]:
|
||||
var action: String = pair[0]
|
||||
if not InputMap.has_action(action):
|
||||
InputMap.add_action(action)
|
||||
if not InputMap.action_get_events(action).any(func(e): return e is InputEventJoypadButton):
|
||||
var ev := InputEventJoypadButton.new()
|
||||
ev.button_index = pair[1]
|
||||
ev.device = -1 # jedes Gamepad
|
||||
InputMap.action_add_event(action, ev)
|
||||
|
||||
|
||||
func focus_first_button() -> void:
|
||||
if screen == null or screen is BattleScreen:
|
||||
return
|
||||
var b := _first_button(screen)
|
||||
if b != null:
|
||||
b.grab_focus()
|
||||
|
||||
|
||||
func _first_button(node: Node) -> Button:
|
||||
for c in node.get_children():
|
||||
if c is Button and not c.disabled and c.is_visible_in_tree():
|
||||
return c
|
||||
var found := _first_button(c)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
## Zurück: zum gemerkten Bildschirm, sonst ins Hauptmenü.
|
||||
func back() -> void:
|
||||
if kept.is_empty():
|
||||
show_screen(MenuScreen.new())
|
||||
return
|
||||
var prev: Control = kept.pop_back()
|
||||
screen.queue_free()
|
||||
screen = prev
|
||||
add_child(prev)
|
||||
|
||||
|
||||
func save() -> void:
|
||||
profile.save(save_path)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if net != null and net.is_inside_tree():
|
||||
net.leave()
|
||||
net.queue_free()
|
||||
|
||||
@@ -1,83 +1,62 @@
|
||||
class_name MenuScreen
|
||||
extends Control
|
||||
## Hauptmenü: links Titel und Spielarten, rechts drei Karten der eigenen Sammlung als Schaufenster.
|
||||
|
||||
var app
|
||||
var b_single: Button
|
||||
var b_campaign: Button
|
||||
var b_hotseat: Button
|
||||
var b_online: Button
|
||||
var b_tutorial: Button
|
||||
var coins_label: Label
|
||||
var coll_label: Label
|
||||
var deck_label: Label
|
||||
var daily_label: Label
|
||||
var warn_label: Label
|
||||
var notice_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var col := Style.column(self, 640, 24, 5)
|
||||
Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED)
|
||||
Style.spacer(col, 8)
|
||||
coins_label = Style.clabel(col, "", 20, Style.GOLD)
|
||||
coll_label = Style.clabel(col, "", 16, Style.MUTED)
|
||||
deck_label = Style.clabel(col, "", 16, Style.MUTED)
|
||||
daily_label = Style.clabel(col, "", 16, Style.BLUE)
|
||||
Style.spacer(col, 8)
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 2
|
||||
grid.add_theme_constant_override("h_separation", 12)
|
||||
grid.add_theme_constant_override("v_separation", 8)
|
||||
var center := CenterContainer.new()
|
||||
center.add_child(grid)
|
||||
col.add_child(center)
|
||||
var sz := Vector2(270, 44)
|
||||
var p0: Profile = app.profile
|
||||
b_tutorial = Style.cbutton(grid, "Anleitung", sz, func(): app.show_screen(BattleScreen.guided()),
|
||||
Color("5a4628") if not p0.tutorial_done else null)
|
||||
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
|
||||
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
|
||||
b_hotseat = Style.cbutton(grid, "Lokal zu mehreren", sz, func(): app.show_screen(HotseatScreen.new()), Color("285a3c"))
|
||||
b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
|
||||
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
|
||||
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new()))
|
||||
Style.cbutton(grid, "Werkstatt", sz, func(): app.show_screen(WorkshopScreen.new()))
|
||||
Style.cbutton(grid, "Erfolge", sz, func(): app.show_screen(AchievementsScreen.new()))
|
||||
Style.cbutton(grid, "Regeln", sz, func(): app.show_screen(RulesScreen.new()))
|
||||
Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new()))
|
||||
Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit())
|
||||
Style.spacer(col, 10)
|
||||
warn_label = Style.clabel(col, "", 16, Style.RED)
|
||||
notice_label = Style.clabel(col, "", 16, Style.GREEN)
|
||||
var p: Profile = app.profile
|
||||
Style.label(self, "Dinger", 76, Style.GOLD, Rect2(80, 64, 600, 90), "left", true)
|
||||
Style.label(self, "Ein Kartenspiel: Duell um das Feld", 22, Style.MUTED, Rect2(84, 156, 600, 30), "left")
|
||||
coins_label = Style.chip(self, "", Rect2(930, 20, 310, 38))
|
||||
coll_label = Style.label(self, "", 15, Style.MUTED, Rect2(700, 64, 540, 24), "right")
|
||||
var y := 230
|
||||
for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())],
|
||||
["Kampagne", func(): app.show_screen(CampaignScreen.new())],
|
||||
["Deck", func(): app.show_screen(DeckScreen.new())]]:
|
||||
var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY)
|
||||
b.add_theme_font_size_override("font_size", 24)
|
||||
y += 74
|
||||
y += 18
|
||||
var x := 80
|
||||
for entry in [["Booster öffnen", func(): app.show_screen(BoosterScreen.new())],
|
||||
["Sammlung", func(): app.show_screen(CollectionScreen.new())],
|
||||
["Einstellungen", func(): app.show_screen(SettingsScreen.new())],
|
||||
["Beenden", func(): get_tree().quit()]]:
|
||||
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
|
||||
x += 176
|
||||
notice_label = Style.label(self, "", 16, Style.GREEN, Rect2(80, y + 70, 760, 60), "left")
|
||||
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||
_showcase(p)
|
||||
refresh()
|
||||
|
||||
|
||||
## Drei eigene Karten, die höchsten Stufen zuerst, leicht gefächert.
|
||||
func _showcase(p: Profile) -> void:
|
||||
var cards: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
|
||||
cards.sort_custom(func(a, b):
|
||||
if p.level(a.id) != p.level(b.id):
|
||||
return p.level(a.id) > p.level(b.id)
|
||||
return hash(a.id) % 97 < hash(b.id) % 97)
|
||||
var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]]
|
||||
for i in 3:
|
||||
var c: Cards.Card = cards[i]
|
||||
var v := CardView.new(c.leveled(p.level(c.id)), Vector2(180, 252), false, p.rarity(c.id))
|
||||
v.position = spots[i][0]
|
||||
v.rotation = spots[i][1]
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(v)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
var ok: Array = p.deck_valid()
|
||||
b_single.disabled = not ok[0]
|
||||
b_campaign.disabled = not ok[0]
|
||||
b_tutorial.disabled = not ok[0]
|
||||
b_online.disabled = not ok[0]
|
||||
var total := 0
|
||||
for cid in p.collection:
|
||||
total += p.collection[cid]
|
||||
coins_label.text = "Münzen: %d · Staub: %d" % [p.coins, p.dust]
|
||||
coll_label.text = "Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d" % [
|
||||
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.STAGES.size()]
|
||||
var elems := Game.deck_elements(p.deck_cards())
|
||||
deck_label.text = "Aktives Deck: %s (%s)" % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else "leer"]
|
||||
var task := p.daily_task()
|
||||
if p.daily.done:
|
||||
daily_label.text = "Tagesaufgabe erledigt: %s ✓" % task.text
|
||||
else:
|
||||
daily_label.text = "Tagesaufgabe: %s (%d/%d) · +%d Münzen" % [task.text, p.daily.progress, task.goal, task.reward]
|
||||
warn_label.text = "" if ok[0] else "Aktives Deck nicht spielbar: " + ok[1]
|
||||
if ok[0] and not p.tutorial_done:
|
||||
warn_label.text = "Neu hier? Die Anleitung erklärt Energie, Angriffe, Resonanz und Feldkombos."
|
||||
warn_label.add_theme_color_override("font_color", Style.GOLD)
|
||||
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
|
||||
coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d") % [
|
||||
p.collection.size(), Cards.all().size(), p.campaign_stage, Campaign.total()]
|
||||
var notes := p.take_notices()
|
||||
notice_label.text = " · ".join(notes)
|
||||
if not notes.is_empty():
|
||||
|
||||
@@ -1,29 +1,54 @@
|
||||
class_name OpponentSelectScreen
|
||||
extends Control
|
||||
## Einzelspieler: Auswahl, gegen wie viele Gegner gekämpft wird.
|
||||
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf
|
||||
## vom gemeinsamen Stapel (siehe Game.start).
|
||||
|
||||
var app
|
||||
var diff_buttons := {}
|
||||
var info_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var col := Style.column(self, 900, 50, 14)
|
||||
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true)
|
||||
Style.clabel(col, "Wie viele Gegner? Jeder kämpft gegen jeden, alle nach denselben Regeln mit %d Leben." % Game.START_LIFE, 18, Style.MUTED)
|
||||
Style.clabel(col, "Du gewinnst, wenn alle anderen besiegt sind. Mehr Gegner, mehr Chaos, mehr Münzen.", 16, Style.MUTED)
|
||||
Style.spacer(col, 10)
|
||||
for i in range(1, Game.MAX_OPPONENTS + 1):
|
||||
var n := i
|
||||
var r := Style.row(col, 20)
|
||||
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(r, "%d Gegner" % n, Vector2(200, 56), func(): app.show_screen(BattleScreen.new(n)), Color("285a3c"))
|
||||
var info := ("Duell" if n == 1 else "%d Spieler am Tisch" % (n + 1)) + " · Sieg: %d Münzen" % Profile.REWARD_WIN[n]
|
||||
Style.clabel(r, info, 16, Style.MUTED, "left", false, 420)
|
||||
Style.spacer(col, 20)
|
||||
var br := Style.row(col)
|
||||
Style.header(self, "Einzelspieler", func(): app.show_screen(MenuScreen.new()))
|
||||
var col := Style.column(self, 1000, 170, 12)
|
||||
Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true)
|
||||
var dr := Style.row(col, 10)
|
||||
dr.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
for key in Profile.DIFFICULTIES:
|
||||
var k: String = key
|
||||
diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k))
|
||||
info_label = Style.clabel(col, "", 15, Style.MUTED)
|
||||
Style.spacer(col, 24)
|
||||
var br := Style.row(col, 16)
|
||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(br, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
var b := Style.cbutton(br, "Starten", Vector2(260, 56), start, Style.PRIMARY)
|
||||
b.add_theme_font_size_override("font_size", 22)
|
||||
refresh()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func set_difficulty(key: String) -> void:
|
||||
app.profile.settings.difficulty = key
|
||||
app.save()
|
||||
refresh()
|
||||
|
||||
|
||||
func start() -> void:
|
||||
app.show_screen(BattleScreen.new())
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
for key in diff_buttons:
|
||||
_mark(diff_buttons[key], key == p.settings.difficulty)
|
||||
var d := p.difficulty()
|
||||
info_label.text = tr("Gegner mit %d Leben · %d Münzen bei Sieg") % [int(d.life), int(round(Profile.REWARD_WIN * float(d.reward)))]
|
||||
|
||||
|
||||
func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void:
|
||||
var base: Color = col if active else Style.PANEL_LIGHT
|
||||
b.add_theme_stylebox_override("normal", Style.box(base, 8, Style.GOLD if active else Style.MUTED, 2))
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
uid://bkemrytc2pjem
|
||||
@@ -7,6 +7,8 @@ var music: HSlider
|
||||
var speed: HSlider
|
||||
var language: OptionButton
|
||||
var music_label: Label
|
||||
var big_font: CheckButton
|
||||
var confirm: Control
|
||||
var anim: CheckButton
|
||||
var volume_label: Label
|
||||
var speed_label: Label
|
||||
@@ -14,9 +16,8 @@ var speed_label: Label
|
||||
|
||||
func _ready() -> void:
|
||||
var s: Dictionary = app.profile.settings
|
||||
var col := Style.column(self, 700, 50, 24)
|
||||
Style.clabel(col, "Einstellungen", 36, Style.GOLD, "center", true)
|
||||
Style.spacer(col, 30)
|
||||
Style.header(self, "Einstellungen", func(): app.back())
|
||||
var col := Style.column(self, 700, 110, 24)
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 3
|
||||
grid.add_theme_constant_override("h_separation", 24)
|
||||
@@ -58,24 +59,42 @@ func _ready() -> void:
|
||||
anim.toggled.connect(_on_anim)
|
||||
grid.add_child(anim)
|
||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.clabel(grid, "Große Schrift", 20, Style.TEXT, "left", false, 200)
|
||||
big_font = CheckButton.new()
|
||||
big_font.button_pressed = bool(s.get("big_font", false))
|
||||
big_font.toggled.connect(_on_big_font)
|
||||
grid.add_child(big_font)
|
||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.clabel(grid, "Sprache", 20, Style.TEXT, "left", false, 200)
|
||||
language = OptionButton.new()
|
||||
language.custom_minimum_size = Vector2(280, 36)
|
||||
language.add_item("Deutsch", 0)
|
||||
language.add_item("English (Oberfläche)", 1)
|
||||
language.add_item("English", 1)
|
||||
language.select(1 if s.language == "en" else 0)
|
||||
language.item_selected.connect(_on_language)
|
||||
grid.add_child(language)
|
||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||
Style.spacer(col, 20)
|
||||
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
|
||||
Style.spacer(col, 60)
|
||||
var r := Style.row(col)
|
||||
Style.spacer(col, 30)
|
||||
var r := Style.row(col, 16)
|
||||
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
|
||||
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.",
|
||||
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
|
||||
_labels()
|
||||
|
||||
|
||||
func _reset() -> void:
|
||||
app.reset_profile()
|
||||
|
||||
|
||||
func _on_big_font(on: bool) -> void:
|
||||
app.profile.settings.big_font = on
|
||||
app.save()
|
||||
app.apply_settings()
|
||||
app.show_screen(SettingsScreen.new())
|
||||
|
||||
|
||||
func _labels() -> void:
|
||||
volume_label.text = "%d %%" % int(round(volume.value * 100))
|
||||
music_label.text = "%d %%" % int(round(music.value * 100))
|
||||
@@ -113,9 +132,10 @@ func _on_speed(v: float) -> void:
|
||||
|
||||
func _on_anim(on: bool) -> void:
|
||||
app.profile.settings.animations = on
|
||||
Style.animations = on
|
||||
app.save()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
app.show_screen(MenuScreen.new())
|
||||
app.back()
|
||||
|
||||
@@ -2,23 +2,25 @@ class_name Sfx
|
||||
extends Node
|
||||
## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig).
|
||||
|
||||
static var inst: Sfx
|
||||
|
||||
var streams := {}
|
||||
var players: Array[AudioStreamPlayer] = []
|
||||
var volume := 0.8
|
||||
var volume := 0.0 # Ton ist standardmäßig aus
|
||||
var muted := false # Tests: nie etwas abspielen, egal was die Regler sagen
|
||||
var music_player: AudioStreamPlayer
|
||||
var music_volume := 0.5
|
||||
var music_volume := 0.0
|
||||
var music_task := -1 # die Musik wird im Hintergrund berechnet (spart ~150 ms beim Start)
|
||||
var music_box := {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
inst = self
|
||||
streams["click"] = _tone(880, 0.05, "sine")
|
||||
streams["play"] = _tone(440, 0.14, "sine", 660)
|
||||
streams["attack"] = _tone(220, 0.16, "square", 90)
|
||||
streams["damage"] = _tone(140, 0.12, "square")
|
||||
streams["heal"] = _tone(523, 0.22, "sine", 784)
|
||||
streams["combo"] = _tone(660, 0.2, "sine", 1100)
|
||||
streams["buy"] = _tone(1200, 0.08, "sine", 1700)
|
||||
streams["turn"] = _tone(520, 0.3, "sine", 1040)
|
||||
streams["win"] = _tone(400, 0.6, "sine", 1200)
|
||||
streams["lose"] = _tone(320, 0.7, "square", 90)
|
||||
for i in 4:
|
||||
@@ -26,11 +28,25 @@ func _ready() -> void:
|
||||
add_child(p)
|
||||
players.append(p)
|
||||
music_player = AudioStreamPlayer.new()
|
||||
music_player.stream = _music()
|
||||
add_child(music_player)
|
||||
var box := music_box
|
||||
music_task = WorkerThreadPool.add_task(func(): box["wav"] = _music())
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if music_task >= 0 and WorkerThreadPool.is_task_completed(music_task):
|
||||
WorkerThreadPool.wait_for_task_completion(music_task)
|
||||
music_task = -1
|
||||
music_player.stream = music_box.wav
|
||||
set_music_volume(music_volume)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if music_task >= 0:
|
||||
WorkerThreadPool.wait_for_task_completion(music_task)
|
||||
music_task = -1
|
||||
|
||||
|
||||
## Leise Hintergrundmusik: Arpeggio über vier Akkorde, als Schleife erzeugt.
|
||||
func _music() -> AudioStreamWAV:
|
||||
var rate := 22050
|
||||
@@ -63,11 +79,11 @@ func _music() -> AudioStreamWAV:
|
||||
|
||||
func set_music_volume(v: float) -> void:
|
||||
music_volume = clampf(v, 0.0, 1.0)
|
||||
if music_volume <= 0.0:
|
||||
if music_volume <= 0.0 or muted:
|
||||
music_player.stop()
|
||||
else:
|
||||
music_player.volume_db = linear_to_db(music_volume * 0.6)
|
||||
if not music_player.playing:
|
||||
if not music_player.playing and music_player.stream != null:
|
||||
music_player.play()
|
||||
|
||||
|
||||
@@ -76,7 +92,7 @@ func set_volume(v: float) -> void:
|
||||
|
||||
|
||||
func play(kind: String) -> void:
|
||||
if volume <= 0.0 or not streams.has(kind):
|
||||
if volume <= 0.0 or muted or not streams.has(kind):
|
||||
return
|
||||
for p in players:
|
||||
if not p.playing:
|
||||
|
||||
37
scripts/ui/start_deck_screen.gd
Normal file
37
scripts/ui/start_deck_screen.gd
Normal 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
scripts/ui/start_deck_screen.gd.uid
Normal file
1
scripts/ui/start_deck_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bxl6isdrgqb2b
|
||||
@@ -3,6 +3,8 @@ class_name Style
|
||||
|
||||
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
|
||||
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
|
||||
static var font_scale := 1.0 # Einstellung „Große Schrift“
|
||||
static var animations := true # Einstellung „Animationen“: der Glanz seltener Karten bewegt sich
|
||||
|
||||
const BG := Color("1a1c24")
|
||||
const PANEL := Color("282b36")
|
||||
@@ -15,6 +17,17 @@ const RED := Color("e65555")
|
||||
const BLUE := Color("6eaaf0")
|
||||
const YELLOW := Color("ffe65a")
|
||||
const ORANGE := Color("ff963c")
|
||||
const PURPLE := Color("8c78dc")
|
||||
const AMBER := Color("f0b429") # Angriff
|
||||
const LIFE := Color("4a86d8") # Leben
|
||||
const MANA := Color("3a78d2") # Kostenkristall
|
||||
const PRIMARY := Color("285a3c") # Hauptaktion
|
||||
const DANGER := Color("5a2828")
|
||||
|
||||
|
||||
## Schriftgröße mit der Einstellung „Große Schrift“.
|
||||
static func fs(size: int) -> int:
|
||||
return int(round(size * font_scale))
|
||||
|
||||
|
||||
static func darker(c: Color, f: float = 0.65) -> Color:
|
||||
@@ -38,16 +51,16 @@ static func box(bg: Color, radius: int = 8, border_col = null, border_w: int = 0
|
||||
static func make_theme() -> Theme:
|
||||
var t := Theme.new()
|
||||
t.default_font = FONT
|
||||
t.default_font_size = 16
|
||||
t.default_font_size = fs(16)
|
||||
t.set_stylebox("normal", "Button", box(PANEL_LIGHT, 8, MUTED, 2))
|
||||
t.set_stylebox("hover", "Button", box(lighter(PANEL_LIGHT), 8, MUTED, 2))
|
||||
t.set_stylebox("pressed", "Button", box(darker(PANEL_LIGHT), 8, MUTED, 2))
|
||||
t.set_stylebox("disabled", "Button", box(darker(PANEL, 0.9), 8, PANEL, 2))
|
||||
t.set_stylebox("focus", "Button", StyleBoxEmpty.new())
|
||||
t.set_stylebox("focus", "Button", box(Color(0, 0, 0, 0), 8, GOLD, 3))
|
||||
for name in ["font_color", "font_hover_color", "font_pressed_color", "font_focus_color", "font_hover_pressed_color"]:
|
||||
t.set_color(name, "Button", TEXT)
|
||||
t.set_color("font_disabled_color", "Button", MUTED)
|
||||
t.set_font_size("font_size", "Button", 18)
|
||||
t.set_font_size("font_size", "Button", fs(18))
|
||||
t.set_color("font_color", "Label", TEXT)
|
||||
t.set_stylebox("panel", "Panel", box(PANEL, 8))
|
||||
t.set_stylebox("normal", "RichTextLabel", StyleBoxEmpty.new())
|
||||
@@ -73,7 +86,11 @@ static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8,
|
||||
var p := Panel.new()
|
||||
p.position = rect.position
|
||||
p.size = rect.size
|
||||
p.add_theme_stylebox_override("panel", box(bg, radius, border_col, border_w))
|
||||
var sb := box(bg, radius, border_col, border_w)
|
||||
sb.shadow_color = Color(0, 0, 0, 0.35)
|
||||
sb.shadow_size = 6
|
||||
sb.shadow_offset = Vector2(0, 3)
|
||||
p.add_theme_stylebox_override("panel", sb)
|
||||
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
parent.add_child(p)
|
||||
return p
|
||||
@@ -84,7 +101,7 @@ static func label(parent: Node, text: String, size: int, color: Color, rect: Rec
|
||||
l.text = text
|
||||
l.position = rect.position
|
||||
l.size = rect.size
|
||||
l.add_theme_font_size_override("font_size", size)
|
||||
l.add_theme_font_size_override("font_size", fs(size))
|
||||
l.add_theme_color_override("font_color", color)
|
||||
if bold:
|
||||
l.add_theme_font_override("font", FONT_BOLD)
|
||||
@@ -108,10 +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("hover", box(lighter(color), 8, MUTED, 2))
|
||||
b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2))
|
||||
juice(b)
|
||||
parent.add_child(b)
|
||||
return b
|
||||
|
||||
|
||||
## Knöpfe federn beim Drücken kurz ein.
|
||||
static func juice(b: Button) -> void:
|
||||
b.button_down.connect(func():
|
||||
if animations:
|
||||
b.pivot_offset = b.size / 2
|
||||
b.create_tween().tween_property(b, "scale", Vector2(0.96, 0.96), 0.05))
|
||||
b.button_up.connect(func():
|
||||
if animations:
|
||||
b.pivot_offset = b.size / 2
|
||||
b.create_tween().tween_property(b, "scale", Vector2.ONE, 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT))
|
||||
|
||||
|
||||
## Kopfzeile eines Unterbildschirms: Titel links, Zurück rechts oben.
|
||||
static func header(parent: Node, title: String, on_back: Callable) -> Button:
|
||||
label(parent, title, 34, GOLD, Rect2(40, 22, 700, 46), "left", true)
|
||||
return button(parent, "Zurück", Rect2(1120, 26, 120, 40), on_back)
|
||||
|
||||
|
||||
## Kleine Kachel mit Text, etwa für Münzen und Staub.
|
||||
static func chip(parent: Node, text: String, rect: Rect2, color: Color = GOLD) -> Label:
|
||||
panel(parent, rect, darker(PANEL, 0.9), rect.size.y / 2, PANEL_LIGHT, 1)
|
||||
var l := label(parent, text, 17, color, rect, "center", true)
|
||||
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
return l
|
||||
|
||||
|
||||
## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme).
|
||||
static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer:
|
||||
var v := VBoxContainer.new()
|
||||
@@ -160,6 +204,31 @@ static func spacer(parent: Node, h: float) -> Control:
|
||||
return c
|
||||
|
||||
|
||||
## Unsichtbare Vollbild-Ebene mit abgedunkeltem Hintergrund, die Klicks schluckt.
|
||||
static func overlay(parent: Node) -> Control:
|
||||
var o := Control.new()
|
||||
o.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
o.visible = false
|
||||
var dark := ColorRect.new()
|
||||
dark.color = Color(0, 0, 0, 0.67)
|
||||
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
dark.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
o.add_child(dark)
|
||||
parent.add_child(o)
|
||||
return o
|
||||
|
||||
|
||||
## Bestätigungsdialog (unsichtbar): Titel, Hinweis, Ja-Knopf mit Aktion und ein Abbrechen-Knopf.
|
||||
static func confirm(parent: Node, title: String, text: String, yes: String, on_yes: Callable, no: String = "Abbrechen", color: Color = MUTED) -> Control:
|
||||
var o := overlay(parent)
|
||||
panel(o, Rect2(340, 290, 600, 200), PANEL, 10, color, 2)
|
||||
label(o, title, 26, TEXT, Rect2(340, 312, 600, 40), "center", true)
|
||||
label(o, text, 16, MUTED, Rect2(340, 360, 600, 24), "center")
|
||||
button(o, yes, Rect2(380, 420, 240, 46), on_yes, Color("5a2828"))
|
||||
button(o, no, Rect2(660, 420, 240, 46), func(): o.visible = false)
|
||||
return o
|
||||
|
||||
|
||||
static func clear(node: Node) -> void:
|
||||
for c in node.get_children():
|
||||
node.remove_child(c)
|
||||
@@ -168,7 +237,8 @@ static func clear(node: Node) -> void:
|
||||
|
||||
# --- Zeichenhilfen für _draw() ---------------------------------------------------
|
||||
|
||||
static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
|
||||
static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "topleft", bold: bool = false) -> void:
|
||||
var size := fs(size_in)
|
||||
var font: Font = FONT_BOLD if bold else FONT
|
||||
var dim := font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
|
||||
var x := pos.x
|
||||
@@ -197,6 +267,33 @@ static func text(ci: CanvasItem, s: String, size: int, color: Color, pos: Vector
|
||||
ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
|
||||
|
||||
|
||||
## Text mit dunklem Rand: bleibt auf jeder Farbe lesbar (Werte, Kosten).
|
||||
static func text_outlined(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "center", outline: int = 4) -> void:
|
||||
var size := fs(size_in)
|
||||
var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
|
||||
var x := pos.x
|
||||
var y := pos.y
|
||||
match anchor:
|
||||
"center":
|
||||
x -= dim.x / 2
|
||||
y -= dim.y / 2
|
||||
"midtop":
|
||||
x -= dim.x / 2
|
||||
"midleft":
|
||||
y -= dim.y / 2
|
||||
var at := Vector2(x, y + FONT_BOLD.get_ascent(size))
|
||||
ci.draw_string_outline(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, outline, Color(0.05, 0.05, 0.08, 0.95))
|
||||
ci.draw_string(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
|
||||
|
||||
|
||||
## Schriftgröße, mit der ein Text in eine Breite passt (bis zur Mindestgröße).
|
||||
static func fit_size(s: String, bold: bool, size: int, max_w: float, min_size: int = 9) -> int:
|
||||
var font: Font = FONT_BOLD if bold else FONT
|
||||
while size > min_size and font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, fs(size)).x > max_w:
|
||||
size -= 1
|
||||
return size
|
||||
|
||||
|
||||
static func fit(s: String, bold: bool, size: int, max_w: float) -> String:
|
||||
var font: Font = FONT_BOLD if bold else FONT
|
||||
while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3:
|
||||
@@ -213,104 +310,326 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius
|
||||
ci.draw_style_box(sb, rect)
|
||||
|
||||
|
||||
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
|
||||
var a: Color = Cards.ELEMENT_COLORS[elements[0]]
|
||||
if elements.size() == 1:
|
||||
ci.draw_style_box(box(a, radius), rect)
|
||||
elif elements[0] == elements[1]:
|
||||
# Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen
|
||||
ci.draw_style_box(box(a, radius), rect)
|
||||
border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3))
|
||||
# --- Seltenheit als Aussehen ------------------------------------------------------
|
||||
# Common matt; Rare silbern glänzender Name; Super Rare Holo-Bild (wandernder Regenbogen im
|
||||
# Bildkreis); Ultra Rare goldener Name und Holo-Bild; Ultimate Rare goldener Name, goldenes
|
||||
# Bild, glänzende Werte, Schimmer über die ganze Karte, Funkeln und schillernder Rahmen.
|
||||
# Rahmen und Schein tragen ab Rare die Stufenfarbe.
|
||||
|
||||
## Ab Rare bewegt sich der Glanz; dann zeichnet die Karte jeden Frame neu.
|
||||
static func rarity_animated(rar: String) -> bool:
|
||||
return animations and Cards.RARITIES.find(rar) >= 1
|
||||
|
||||
|
||||
## Farbe einer Stufe; Ultimate Rare schillert über die Zeit durch alle Farben.
|
||||
static func rarity_color(rar: String, seed: int = 0) -> Color:
|
||||
if rar == "Ultimate Rare" and animations:
|
||||
return Color.from_hsv(fmod(Time.get_ticks_msec() / 1000.0 * 0.12 + (seed % 97) / 97.0, 1.0), 0.55, 1.0)
|
||||
return Cards.RARITY_COLORS[rar]
|
||||
|
||||
|
||||
## Rahmenfarbe einer Stufe: Common bleibt schlicht, alles darüber trägt seine Farbe.
|
||||
static func rarity_frame(rar: String, seed: int = 0) -> Color:
|
||||
return Color("3a3e4c") if rar == "Common" else rarity_color(rar, seed)
|
||||
|
||||
|
||||
## Farbe des Kartennamens: Rare silbern, Ultra und Ultimate Rare golden, sonst weiß.
|
||||
static func rarity_name_color(rar: String) -> Color:
|
||||
match rar:
|
||||
"Rare":
|
||||
return Cards.RARITY_COLORS["Rare"]
|
||||
"Ultra Rare", "Ultimate Rare":
|
||||
return Cards.RARITY_COLORS["Ultra Rare"]
|
||||
return TEXT
|
||||
|
||||
|
||||
## Der Name glänzt bei Rare (silbern), Ultra und Ultimate Rare (golden).
|
||||
static func rarity_name_shiny(rar: String) -> bool:
|
||||
return rar in ["Rare", "Ultra Rare", "Ultimate Rare"]
|
||||
|
||||
|
||||
## Ultimate Rare: auch Kosten, Angriff und Leben glänzen.
|
||||
static func rarity_stats_shiny(rar: String) -> bool:
|
||||
return rar == "Ultimate Rare"
|
||||
|
||||
|
||||
## Ultimate Rare zeigt das Bild als Relief (Licht- und Schattenkante).
|
||||
static func rarity_relief(rar: String) -> bool:
|
||||
return rar == "Ultimate Rare"
|
||||
|
||||
|
||||
## Kartenname links in rect, immer eine Zeile, nie abgeschnitten: die Schrift schrumpft bis
|
||||
## drei Punkte unter die Vorgabe, danach wird der Text schmaler gezeichnet (wie bei langen
|
||||
## Yu-Gi-Oh-Namen).
|
||||
static func card_name(ci: CanvasItem, s: String, size_in: int, rar: String, rect: Rect2, seed: int) -> void:
|
||||
var fsize := fit_size(s, true, size_in, rect.size.x, maxi(8, size_in - 3))
|
||||
var w := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, fs(fsize)).x
|
||||
var squeeze := minf(1.0, rect.size.x / maxf(w, 1.0))
|
||||
var y := rect.position.y + (rect.size.y - FONT_BOLD.get_height(fs(fsize))) / 2
|
||||
ci.draw_set_transform(Vector2(rect.position.x, y), 0.0, Vector2(squeeze, 1.0))
|
||||
if rarity_name_shiny(rar):
|
||||
shiny_text(ci, s, fsize, rarity_name_color(rar), Vector2.ZERO, "topleft", seed)
|
||||
else:
|
||||
var b: Color = Cards.ELEMENT_COLORS[elements[1]]
|
||||
var half := floorf(rect.size.x / 2)
|
||||
var left := StyleBoxFlat.new()
|
||||
left.bg_color = a
|
||||
left.corner_radius_top_left = radius
|
||||
left.corner_radius_bottom_left = radius
|
||||
var right := StyleBoxFlat.new()
|
||||
right.bg_color = b
|
||||
right.corner_radius_top_right = radius
|
||||
right.corner_radius_bottom_right = radius
|
||||
ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y)))
|
||||
ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y)))
|
||||
text(ci, s, fsize, rarity_name_color(rar), Vector2.ZERO, "topleft", true)
|
||||
ci.draw_set_transform(Vector2.ZERO)
|
||||
|
||||
|
||||
static func rules_text() -> String:
|
||||
var lines := [
|
||||
"Ziel: Besiege alle anderen. Du verlierst, wenn dein Leben auf 0 fällt.",
|
||||
"Du spielst gegen 1 bis 5 Magier, jeder gegen jeden, alle nach denselben Regeln:",
|
||||
"%d Leben, pro Zug 1 Energie mehr (höchstens 10) und 1 Karte. Die Zugreihenfolge wird ausgelost." % Game.START_LIFE,
|
||||
"Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug 1 Energie extra.",
|
||||
"Fallen liegen verdeckt (höchstens 2) und lösen aus, wenn dich jemand angreift oder ein Wesen ausspielt.",
|
||||
"",
|
||||
"Karten kosten Energie. Wesen kommen aufs Feld (max. 5) und greifen ab dem nächsten Zug an.",
|
||||
"Zauber wirken sofort. Ein Wesen greift ein gegnerisches Wesen oder direkt den Gegner an;",
|
||||
"beide Seiten verursachen ihren Angriffswert als Schaden.",
|
||||
"",
|
||||
"Elemente: Feuer (Schaden), Wasser (Kontrolle), Erde (Zähigkeit), Luft (Tempo).",
|
||||
"Kombos verbinden zwei Elemente, auch das gleiche doppelt (Feuer+Feuer = Inferno).",
|
||||
"Kombokarten sind seltener und stärker, Dreifach-Kombos sind mythisch. Ein Deck darf höchstens 2 Elemente enthalten.",
|
||||
Cards.RESONANCE_TEXT,
|
||||
"Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):",
|
||||
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an alle gegnerischen Wesen, Blitz 2 Schaden",
|
||||
" an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen Schild.",
|
||||
"",
|
||||
"Schlüsselwörter:",
|
||||
]
|
||||
for k in Cards.KEYWORDS:
|
||||
lines.append(" %s: %s" % [k, Cards.KEYWORD_HELP[k]])
|
||||
lines.append("")
|
||||
lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES])
|
||||
lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.")
|
||||
lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.")
|
||||
lines.append("")
|
||||
lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.")
|
||||
lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.")
|
||||
lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.")
|
||||
lines.append("Kampagnen-Bosse haben ein Merkmal (★), zum Beispiel stärkere Feuerwesen. Netzwerkspiel im lokalen Netz.")
|
||||
lines.append("Steuerung: Linksklick spielt Karten und wählt Ziele, Rechtsklick oder Esc bricht ab, Leertaste beendet den Zug, 1 bis 9 spielen Handkarten.")
|
||||
return "\n".join(lines)
|
||||
## Metallisch glänzender fetter Text: dunkler Schatten, Grundfarbe, darüber Buchstabe für
|
||||
## Buchstabe ein heller Streifen, der mit Animation über den Text läuft.
|
||||
static func shiny_text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String, seed: int) -> void:
|
||||
var size := fs(size_in)
|
||||
var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
|
||||
var x := pos.x
|
||||
var y := pos.y
|
||||
match anchor:
|
||||
"center":
|
||||
x -= dim.x / 2
|
||||
y -= dim.y / 2
|
||||
"midtop":
|
||||
x -= dim.x / 2
|
||||
"midleft":
|
||||
y -= dim.y / 2
|
||||
var phase := (seed % 100) / 100.0
|
||||
if animations:
|
||||
phase = fmod(Time.get_ticks_msec() / 1800.0 + phase, 1.0)
|
||||
var at := Vector2(x, y + FONT_BOLD.get_ascent(size))
|
||||
ci.draw_string(FONT_BOLD, at + Vector2(1, 1), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, Color(0, 0, 0, 0.55))
|
||||
var cx := x
|
||||
for i in s.length():
|
||||
var ch := s[i]
|
||||
var w := FONT_BOLD.get_string_size(ch, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
|
||||
var d := absf(fmod((cx + w / 2 - x) / maxf(dim.x, 1.0) - phase * 1.6 + 0.3 + 2.0, 1.0) - 0.5) * 2.0 # 0 am Streifen
|
||||
var k := clampf(1.0 - d * 2.4, 0.0, 1.0)
|
||||
ci.draw_string(FONT_BOLD, Vector2(cx, at.y), ch, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color.lerp(Color.WHITE, 0.1 + 0.75 * k))
|
||||
cx += w
|
||||
|
||||
|
||||
# --- Kartensymbole (prozedural, je Element und Kartenart) --------------------------
|
||||
## Glanz wie auf einer Folienkarte, auf clip beschnitten. lvl 2 und 3: Holo, ein wandernder
|
||||
## Regenbogenschimmer; lvl 4: goldene Bänder.
|
||||
static func sheen(ci: CanvasItem, clip: PackedVector2Array, bounds: Rect2, seed: int, gold: bool = false) -> void:
|
||||
var phase := (seed % 100) / 100.0
|
||||
if animations:
|
||||
phase = fmod(Time.get_ticks_msec() / 2500.0 + phase, 1.0)
|
||||
var layers := []
|
||||
for i in 4:
|
||||
var col := Color(1.0, 0.86, 0.45, 0.2 - i * 0.03) if gold else Color.from_hsv(fmod(phase + i * 0.17, 1.0), 0.6, 1.0, 0.16)
|
||||
layers.append([1.0 - i * 0.22, col])
|
||||
_sheen_bands(ci, clip, bounds, phase, layers)
|
||||
|
||||
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
|
||||
|
||||
## Ultimate Rare: schwacher schillernder Schimmer über die ganze Karte.
|
||||
static func card_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> void:
|
||||
if rar != "Ultimate Rare":
|
||||
return
|
||||
var phase := (seed % 100) / 100.0
|
||||
if animations:
|
||||
phase = fmod(Time.get_ticks_msec() / 2500.0 + phase, 1.0)
|
||||
var soft := []
|
||||
for i in 3:
|
||||
soft.append([1.0 - i * 0.3, Color.from_hsv(fmod(phase + 0.5 + i * 0.2, 1.0), 0.5, 1.0, 0.07)])
|
||||
_sheen_bands(ci, rect_poly(rect), rect, fmod(phase + 0.5, 1.0), soft)
|
||||
|
||||
|
||||
static func rect_poly(rect: Rect2) -> PackedVector2Array:
|
||||
return PackedVector2Array([rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)])
|
||||
|
||||
|
||||
static func circle_poly(c: Vector2, r: float, steps: int = 40) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
for i in steps:
|
||||
var a := TAU * i / steps
|
||||
pts.append(c + Vector2(cos(a), sin(a)) * r)
|
||||
return pts
|
||||
|
||||
|
||||
## Rechteck mit runden Ecken als Polygon (für Beschnitt).
|
||||
static func rounded_poly(rect: Rect2, radius: float) -> PackedVector2Array:
|
||||
var pts := PackedVector2Array()
|
||||
var r := minf(radius, minf(rect.size.x, rect.size.y) / 2)
|
||||
var corners := [[rect.end - Vector2(r, r), 0.0], [Vector2(rect.position.x + r, rect.end.y - r), PI / 2],
|
||||
[rect.position + Vector2(r, r), PI], [Vector2(rect.end.x - r, rect.position.y + r), PI * 1.5]]
|
||||
for corner in corners:
|
||||
for k in 7:
|
||||
var a: float = corner[1] + PI / 2 * k / 6
|
||||
pts.append(corner[0] + Vector2(cos(a), sin(a)) * r)
|
||||
return pts
|
||||
|
||||
|
||||
## Schräge Streifen über bounds, auf clip beschnitten. layers: [Breitenanteil, Farbe].
|
||||
static func _sheen_bands(ci: CanvasItem, clip: PackedVector2Array, bounds: Rect2, phase: float, layers: Array) -> void:
|
||||
var w := bounds.size.x * 0.34
|
||||
var k := bounds.size.y * 0.75 # Schräge
|
||||
var x0 := bounds.position.x - w + (bounds.size.x + w + k) * phase
|
||||
for layer in layers:
|
||||
var lw: float = w * layer[0]
|
||||
var lx := x0 + (w - lw) / 2
|
||||
var band := PackedVector2Array([Vector2(lx, bounds.position.y), Vector2(lx + lw, bounds.position.y), Vector2(lx + lw - k, bounds.end.y), Vector2(lx - k, bounds.end.y)])
|
||||
for poly in Geometry2D.intersect_polygons(band, clip):
|
||||
if poly.size() >= 3 and _area(poly) > 2.0: # Splitter am Rand lassen sich nicht zeichnen
|
||||
ci.draw_colored_polygon(poly, layer[1])
|
||||
|
||||
|
||||
static func _area(poly: PackedVector2Array) -> float:
|
||||
var a := 0.0
|
||||
for i in poly.size():
|
||||
var p := poly[i]
|
||||
var q := poly[(i + 1) % poly.size()]
|
||||
a += p.x * q.y - q.x * p.y
|
||||
return absf(a) / 2.0
|
||||
|
||||
|
||||
## Funkeln bei Ultimate Rare: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen.
|
||||
static func rarity_sparkle(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> void:
|
||||
if rar != "Ultimate Rare":
|
||||
return
|
||||
var t := Time.get_ticks_msec() / 1000.0
|
||||
for i in 9:
|
||||
var h := absi(hash(Vector2i(seed, i)))
|
||||
var p := rect.position + Vector2(8 + (h % 1000) / 1000.0 * (rect.size.x - 16), 8 + ((h / 1000) % 1000) / 1000.0 * (rect.size.y - 16))
|
||||
var tw := 0.5 + 0.5 * sin(t * 2.5 + i * 1.9 + seed % 7) if animations else 0.35 + 0.65 * ((i * 7 + seed) % 5) / 4.0
|
||||
if tw < 0.15:
|
||||
continue
|
||||
var s := 2.0 + 4.0 * tw
|
||||
var col := Color(1, 1, 1, 0.3 + 0.65 * tw)
|
||||
ci.draw_line(p - Vector2(s, 0), p + Vector2(s, 0), col, 1.4)
|
||||
ci.draw_line(p - Vector2(0, s), p + Vector2(0, s), col, 1.4)
|
||||
ci.draw_circle(p, 1.3, col)
|
||||
|
||||
|
||||
## Weicher Schein um eine Karte höherer Stufe; vor dem Hintergrund zeichnen.
|
||||
static func glow(ci: CanvasItem, rect: Rect2, color: Color, radius: int = 8, spread: int = 8) -> void:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = Color(0, 0, 0, 0)
|
||||
sb.set_corner_radius_all(radius)
|
||||
sb.shadow_color = Color(color, 0.6)
|
||||
sb.shadow_size = spread
|
||||
ci.draw_style_box(sb, rect)
|
||||
|
||||
|
||||
## Hintergrund in den Farben der Elemente: ein Element füllt die Karte, verschmolzene Wesen
|
||||
## tragen je Element einen senkrechten Streifen.
|
||||
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
|
||||
ci.draw_style_box(box(Cards.ELEMENT_COLORS[elements[0]], radius), rect)
|
||||
if elements.size() < 2:
|
||||
return
|
||||
var clip := rounded_poly(rect, radius)
|
||||
var w := rect.size.x / elements.size()
|
||||
for i in range(1, elements.size()):
|
||||
var strip := Rect2(rect.position.x + w * i, rect.position.y, w, rect.size.y)
|
||||
for poly in Geometry2D.intersect_polygons(rect_poly(strip), clip):
|
||||
if poly.size() >= 3:
|
||||
ci.draw_colored_polygon(poly, Cards.ELEMENT_COLORS[elements[i]])
|
||||
ci.draw_line(strip.position + Vector2(0, 2), Vector2(strip.position.x, strip.end.y - 2), Color(0.05, 0.05, 0.08, 0.5), 1.5)
|
||||
|
||||
|
||||
# --- Kartenteile ------------------------------------------------------
|
||||
|
||||
## Bild der Karte: dunkler Kreis mit Ring in der Elementfarbe, darin das Symbol der Bildkarte
|
||||
## (bei Verschmelzungen die des stärkeren Elternteils). Ab Super Rare glänzt der Kreis (Holo),
|
||||
## bei Ultimate Rare golden und als Relief.
|
||||
static func picture(ci: CanvasItem, card, c: Vector2, r: float, rar: String, seed: int) -> void:
|
||||
var src = card.art_card()
|
||||
var ring := lighter(Cards.ELEMENT_COLORS[src.elements[0]], 0.3)
|
||||
ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.8))
|
||||
ci.draw_circle(c, r, Color(0.09, 0.09, 0.13, 0.9))
|
||||
glyph(ci, src, c, r * 1.3, 0.95, rarity_relief(rar))
|
||||
var lvl := Cards.RARITIES.find(rar)
|
||||
if lvl >= 2:
|
||||
sheen(ci, circle_poly(c, r), Rect2(c - Vector2(r, r), Vector2(r * 2, r * 2)), seed, lvl >= 4)
|
||||
ci.draw_arc(c, r + 1.5, 0, TAU, 48, Color(1.0, 0.86, 0.45) if lvl >= 4 else ring, 3.0, true)
|
||||
|
||||
|
||||
## Kostenkristall: sechseckig, blau, Zahl in der Mitte; shiny: goldene, glänzende Zahl.
|
||||
static func cost_crystal(ci: CanvasItem, c: Vector2, r: float, cost: int, font: int, shiny: bool = false) -> void:
|
||||
var pts := PackedVector2Array()
|
||||
var outer := PackedVector2Array()
|
||||
for i in 6:
|
||||
var a := -PI / 2 + i * PI / 3
|
||||
pts.append(c + Vector2(cos(a), sin(a)) * r)
|
||||
outer.append(c + Vector2(cos(a), sin(a)) * (r + 2))
|
||||
ci.draw_colored_polygon(outer, Color(1.0, 0.86, 0.45) if shiny else Color("14141c"))
|
||||
ci.draw_colored_polygon(pts, MANA)
|
||||
var shine := PackedVector2Array([pts[5], pts[0], c + Vector2(0, -r * 0.1), c + Vector2(-r * 0.55, r * 0.15)])
|
||||
ci.draw_colored_polygon(shine, Color(1, 1, 1, 0.22))
|
||||
_stat_text(ci, str(cost), font, Color.WHITE, c, shiny)
|
||||
|
||||
|
||||
## Wertkreis (Angriff gelb, Leben blau) mit Zahl; shiny: goldener Ring, glänzende Zahl.
|
||||
static func stat_circle(ci: CanvasItem, c: Vector2, r: float, value: String, color: Color, font: int, text_col: Color = Color.WHITE, shiny: bool = false) -> void:
|
||||
ci.draw_circle(c, r + 2, Color(1.0, 0.86, 0.45) if shiny else Color("14141c"))
|
||||
ci.draw_circle(c, r, color)
|
||||
ci.draw_circle(c + Vector2(-r * 0.25, -r * 0.3), r * 0.45, Color(1, 1, 1, 0.16))
|
||||
_stat_text(ci, value, font, text_col, c, shiny)
|
||||
|
||||
|
||||
static func _stat_text(ci: CanvasItem, s: String, font: int, col: Color, c: Vector2, shiny: bool) -> void:
|
||||
if shiny and col == Color.WHITE:
|
||||
var size := fs(font)
|
||||
var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
|
||||
ci.draw_string_outline(FONT_BOLD, c - dim / 2 + Vector2(0, FONT_BOLD.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, 3, Color(0.05, 0.05, 0.08, 0.95))
|
||||
shiny_text(ci, s, font, Color(1.0, 0.86, 0.45), c, "center", int(c.x + c.y))
|
||||
else:
|
||||
text_outlined(ci, s, font, col, c, "center", 3)
|
||||
|
||||
|
||||
## Eigenschaft als Symbol auf dunklem Kreis: Spott Ausrufezeichen, Flink Doppelpfeil,
|
||||
## Regeneration Kreuz, Doppelschlag gekreuzte Klingen.
|
||||
static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: Color = Color("f2f2f6")) -> void:
|
||||
ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.7))
|
||||
var w := maxf(1.5, r * 0.22)
|
||||
match kw:
|
||||
"Spott":
|
||||
ci.draw_line(c + Vector2(0, -r * 0.7), c + Vector2(0, r * 0.2), col, w * 1.6)
|
||||
ci.draw_circle(c + Vector2(0, r * 0.6), w * 0.95, col)
|
||||
"Regeneration":
|
||||
ci.draw_line(c + Vector2(0, -r * 0.7), c + Vector2(0, r * 0.7), col, w * 1.5)
|
||||
ci.draw_line(c + Vector2(-r * 0.7, 0), c + Vector2(r * 0.7, 0), col, w * 1.5)
|
||||
"Flink":
|
||||
for i in 2:
|
||||
var x := c.x - r * 0.55 + i * r * 0.55
|
||||
ci.draw_polyline(PackedVector2Array([Vector2(x, c.y - r * 0.6), Vector2(x + r * 0.5, c.y), Vector2(x, c.y + r * 0.6)]), col, w)
|
||||
"Doppelschlag":
|
||||
ci.draw_line(c + Vector2(-r * 0.65, r * 0.65), c + Vector2(r * 0.6, -r * 0.6), col, w)
|
||||
ci.draw_line(c + Vector2(r * 0.65, r * 0.65), c + Vector2(-r * 0.6, -r * 0.6), col, w)
|
||||
ci.draw_line(c + Vector2(-r * 0.7, r * 0.35), c + Vector2(-r * 0.35, r * 0.7), col, w)
|
||||
ci.draw_line(c + Vector2(r * 0.7, r * 0.35), c + Vector2(r * 0.35, r * 0.7), col, w)
|
||||
|
||||
|
||||
# --- Kartensymbole (prozedural, je Element) --------------------------
|
||||
|
||||
## relief: Schatten- und Lichtkante unter dem Symbol (Ultimate Rare).
|
||||
static func glyph(ci: CanvasItem, card, center: Vector2, size: float, alpha: float = 0.95, relief: bool = false) -> void:
|
||||
if relief:
|
||||
ci.draw_set_transform(Vector2(2, 2))
|
||||
_glyph_plain(ci, card, center, size, Color(0, 0, 0, alpha * 0.6))
|
||||
ci.draw_set_transform(Vector2(-1, -1))
|
||||
_glyph_plain(ci, card, center, size, Color(1, 1, 1, alpha * 0.5))
|
||||
ci.draw_set_transform(Vector2.ZERO)
|
||||
_glyph_plain(ci, card, center, size, Color(0, 0, 0, 0), alpha)
|
||||
|
||||
|
||||
## Symbol in einer festen Farbe (Relief) oder, bei durchsichtigem tint, in Elementfarben.
|
||||
static func _glyph_plain(ci: CanvasItem, card, center: Vector2, size: float, tint: Color, alpha: float = 0.95) -> void:
|
||||
var variant := absi(hash(card.id)) % 3
|
||||
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
|
||||
col.a = 0.95
|
||||
if card.is_trap():
|
||||
# Falle: Kreuz im Kreis
|
||||
ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08))
|
||||
var d := size * 0.28
|
||||
ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1))
|
||||
ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1))
|
||||
return
|
||||
if card.kind == "Zauber":
|
||||
_star(ci, center, size * 0.55, Color(col, 0.35))
|
||||
col.a = alpha
|
||||
if tint.a > 0:
|
||||
col = tint
|
||||
var elems: Array = card.elements
|
||||
if elems.size() >= 2 and elems[0] != elems[1]:
|
||||
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)
|
||||
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)
|
||||
else:
|
||||
_element_glyph(ci, elems[0], center, size, variant, col)
|
||||
if elems.size() >= 2:
|
||||
ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2)
|
||||
if elems.size() >= 3:
|
||||
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
|
||||
|
||||
|
||||
static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void:
|
||||
var pts := PackedVector2Array()
|
||||
for i in 8:
|
||||
var a := TAU * i / 8 - PI / 2
|
||||
var rr := r if i % 2 == 0 else r * 0.45
|
||||
pts.append(c + Vector2(cos(a), sin(a)) * rr)
|
||||
ci.draw_colored_polygon(pts, col)
|
||||
|
||||
|
||||
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
|
||||
var h := size / 2
|
||||
match element:
|
||||
@@ -338,6 +657,11 @@ static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: fl
|
||||
for i in 2 + variant:
|
||||
var r := h * (1.0 - i * 0.28)
|
||||
ci.draw_arc(c + Vector2(i * size * 0.06, 0), r, PI * 0.2 + i * 0.4, PI * 1.5 + i * 0.4, 20, col, maxf(2, size * 0.09))
|
||||
_: # Neutral: ein schlichter Schild
|
||||
var w := h * 0.8
|
||||
var pts := PackedVector2Array([c + Vector2(-w, -h * 0.8), c + Vector2(w, -h * 0.8), c + Vector2(w, h * 0.1), c + Vector2(0, h), c + Vector2(-w, h * 0.1)])
|
||||
ci.draw_polyline(pts + PackedVector2Array([pts[0]]), col, maxf(2, size * 0.09))
|
||||
ci.draw_line(c + Vector2(0, -h * 0.5), c + Vector2(0, h * 0.6), col, maxf(2, size * 0.07))
|
||||
|
||||
|
||||
static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
|
||||
@@ -348,7 +672,7 @@ static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
|
||||
ci.draw_colored_polygon(pts, col)
|
||||
var inner := PackedVector2Array([
|
||||
c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)])
|
||||
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75))
|
||||
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75 * col.a))
|
||||
|
||||
|
||||
## Dezenter Hintergrund: Farbverlauf und schräge Linien.
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
class_name WorkshopScreen
|
||||
extends Control
|
||||
## Werkstatt: Karten zerlegen (Staub) und herstellen.
|
||||
|
||||
var app
|
||||
var filter := ""
|
||||
var search := ""
|
||||
var selected: Cards.Card = null
|
||||
var grid: CardGrid
|
||||
var search_box: LineEdit
|
||||
var dust_label: Label
|
||||
var msg_label: Label
|
||||
var preview_holder: Control
|
||||
var info_label: Label
|
||||
var b_dismantle: Button
|
||||
var b_craft: Button
|
||||
var b_extras: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Style.label(self, "Werkstatt", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
|
||||
Style.label(self, "Karte anklicken, dann zerlegen oder herstellen. Überschuss: alles über %d Kopien." % Game.MAX_COPIES,
|
||||
14, Style.MUTED, Rect2(240, 18, 600, 24))
|
||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
||||
for i in Cards.ELEMENTS.size():
|
||||
var e: String = Cards.ELEMENTS[i]
|
||||
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
||||
search_box = LineEdit.new()
|
||||
search_box.position = Vector2(400, 55)
|
||||
search_box.size = Vector2(200, 34)
|
||||
search_box.placeholder_text = "Suche"
|
||||
search_box.text_changed.connect(func(t: String):
|
||||
search = t
|
||||
refresh())
|
||||
add_child(search_box)
|
||||
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
|
||||
grid.card_clicked.connect(select)
|
||||
add_child(grid)
|
||||
Style.panel(self, Rect2(845, 50, 420, 680))
|
||||
dust_label = Style.label(self, "", 22, Style.BLUE, Rect2(860, 58, 390, 30), "left", true)
|
||||
preview_holder = Control.new()
|
||||
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(preview_holder)
|
||||
info_label = Style.label(self, "", 14, Style.MUTED, Rect2(860, 396, 390, 66), "center")
|
||||
b_dismantle = Style.button(self, "", Rect2(860, 470, 390, 44), dismantle, Color("5a3c28"))
|
||||
b_craft = Style.button(self, "", Rect2(860, 524, 390, 44), craft, Color("285a3c"))
|
||||
b_extras = Style.button(self, "", Rect2(860, 600, 390, 44), dismantle_extras)
|
||||
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 660, 390, 48), "center")
|
||||
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
|
||||
refresh()
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus():
|
||||
app.show_screen(MenuScreen.new())
|
||||
|
||||
|
||||
func set_filter(e: String) -> void:
|
||||
filter = e
|
||||
refresh()
|
||||
|
||||
|
||||
func cards() -> Array:
|
||||
var out: Array = Cards.all().duplicate()
|
||||
if filter != "":
|
||||
out = out.filter(func(c): return filter in c.elements)
|
||||
if search != "":
|
||||
var q := search.to_lower()
|
||||
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
|
||||
return out
|
||||
|
||||
|
||||
func select(card: Cards.Card) -> void:
|
||||
selected = card
|
||||
msg_label.text = ""
|
||||
refresh()
|
||||
|
||||
|
||||
func say(m: String, ok: bool) -> void:
|
||||
msg_label.text = m
|
||||
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
|
||||
|
||||
|
||||
func dismantle() -> void:
|
||||
if selected == null:
|
||||
return
|
||||
var err: String = app.profile.dismantle(selected.id)
|
||||
if err == "":
|
||||
app.sfx.play("click")
|
||||
app.save()
|
||||
say("%s zerlegt (+%d Staub)" % [selected.name, Profile.DUST_DISMANTLE[selected.rarity]], true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func craft() -> void:
|
||||
if selected == null:
|
||||
return
|
||||
var err: String = app.profile.craft(selected.id)
|
||||
if err == "":
|
||||
app.sfx.play("play")
|
||||
app.save()
|
||||
var notes: Array[String] = app.profile.take_notices()
|
||||
say("%s hergestellt" % selected.name + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
|
||||
else:
|
||||
say(err, false)
|
||||
refresh()
|
||||
|
||||
|
||||
func dismantle_extras() -> void:
|
||||
var got: int = app.profile.dismantle_extras()
|
||||
if got > 0:
|
||||
app.save()
|
||||
say("Überschuss zerlegt: +%d Staub" % got, true)
|
||||
else:
|
||||
say("Kein Überschuss vorhanden", false)
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
var p: Profile = app.profile
|
||||
dust_label.text = "Staub: %d" % p.dust
|
||||
grid.fill(cards(),
|
||||
func(c): return "%dx" % p.collection.get(c.id, 0) if p.collection.get(c.id, 0) > 0 else "fehlt",
|
||||
func(c): return p.collection.get(c.id, 0) == 0)
|
||||
Style.clear(preview_holder)
|
||||
if selected == null:
|
||||
info_label.text = "Keine Karte gewählt"
|
||||
b_dismantle.text = "Zerlegen"
|
||||
b_craft.text = "Herstellen"
|
||||
b_dismantle.disabled = true
|
||||
b_craft.disabled = true
|
||||
else:
|
||||
var v := CardView.new(selected, Vector2(200, 280))
|
||||
v.position = Vector2(955, 100)
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
preview_holder.add_child(v)
|
||||
var owned: int = p.collection.get(selected.id, 0)
|
||||
var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0})
|
||||
info_label.text = "%s · %s\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [
|
||||
selected.combo(), selected.rarity, owned, st.played, st.games, st.wins]
|
||||
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity]
|
||||
b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity]
|
||||
b_dismantle.disabled = owned == 0
|
||||
b_craft.disabled = p.dust < Profile.DUST_CRAFT[selected.rarity]
|
||||
var extras := 0
|
||||
for cid in p.collection:
|
||||
extras += maxi(0, p.collection[cid] - Game.MAX_COPIES)
|
||||
b_extras.text = "Überschuss zerlegen (%d Karten)" % extras
|
||||
b_extras.disabled = extras == 0
|
||||
@@ -1 +0,0 @@
|
||||
uid://cf42h6bsh8lvg
|
||||
9
start.bat
Normal file
9
start.bat
Normal 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 . %*
|
||||
10
start.sh
Executable file
10
start.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Startet Dinger. Beim ersten Start nach dem Klonen importiert Godot zuerst die Ressourcen
|
||||
# (Schriften, Übersetzungen, Klassenliste), sonst lässt sich das Projekt nicht laden.
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
GODOT="${GODOT:-godot}"
|
||||
if [ ! -f .godot/global_script_class_cache.cfg ]; then
|
||||
echo "Erster Start: Ressourcen werden importiert ..."
|
||||
"$GODOT" --headless --path . --import > /dev/null 2>&1
|
||||
fi
|
||||
exec "$GODOT" --path . "$@"
|
||||
49
tests/frame_probe.gd
Normal file
49
tests/frame_probe.gd
Normal file
@@ -0,0 +1,49 @@
|
||||
extends Node
|
||||
## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen.
|
||||
|
||||
var main: Node
|
||||
var max_dt := 0.0
|
||||
var frames_n := 0
|
||||
var measuring := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Game.test_seed = 4242
|
||||
main = load("res://scenes/main.tscn").instantiate()
|
||||
main.save_path = "user://_probe.json"
|
||||
add_child(main)
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var scr: BattleScreen = BattleScreen.new()
|
||||
main.show_screen(scr)
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
var h: Game.Player = scr.game.human
|
||||
h.energy = 10
|
||||
h.hand.pop_back()
|
||||
if scr.game.current() == h:
|
||||
scr.game.draw(h)
|
||||
var t0 := Time.get_ticks_usec()
|
||||
for i in 30:
|
||||
scr.refresh()
|
||||
print("refresh() mit 5 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
|
||||
scr.ai_delay = 0.2
|
||||
measuring = true
|
||||
var start := Time.get_ticks_msec()
|
||||
var actions := 0
|
||||
var last_turn := scr.game.turn
|
||||
while Time.get_ticks_msec() - start < 12000 and scr.game.winner == "":
|
||||
if scr.game.current().is_human and scr.game.current() == h:
|
||||
scr.game.end_turn()
|
||||
scr.after_action()
|
||||
await get_tree().process_frame
|
||||
measuring = false
|
||||
print("längste Bildzeit in 12 s KI-Spiel: %.0f ms (%d Bilder, Zug %d)" % [max_dt * 1000.0, frames_n, scr.game.turn])
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path("user://_probe.json"))
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if measuring:
|
||||
frames_n += 1
|
||||
max_dt = maxf(max_dt, delta)
|
||||
1
tests/frame_probe.gd.uid
Normal file
1
tests/frame_probe.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://botawgwpha3ai
|
||||
6
tests/frame_probe.tscn
Normal file
6
tests/frame_probe.tscn
Normal file
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/frame_probe.gd" id="1"]
|
||||
|
||||
[node name="FrameProbe" type="Node"]
|
||||
script = ExtResource("1")
|
||||
29
tests/lint.gd
Normal file
29
tests/lint.gd
Normal file
@@ -0,0 +1,29 @@
|
||||
extends SceneTree
|
||||
## Statische Prüfung: lädt jedes Skript; Parse- oder Kompilierfehler lassen den Lauf scheitern.
|
||||
## godot --headless --path . -s tests/lint.gd
|
||||
|
||||
|
||||
func scan(dir: String, out: Array) -> void:
|
||||
var d := DirAccess.open(dir)
|
||||
if d == null:
|
||||
return
|
||||
for sub in d.get_directories():
|
||||
if not sub.begins_with("."):
|
||||
scan(dir.path_join(sub), out)
|
||||
for f in d.get_files():
|
||||
if f.ends_with(".gd"):
|
||||
out.append(dir.path_join(f))
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var files := []
|
||||
for dir in ["res://scripts", "res://tests"]:
|
||||
scan(dir, files)
|
||||
var bad := 0
|
||||
for path in files:
|
||||
var script = load(path)
|
||||
if script == null or not (script is GDScript):
|
||||
printerr("FEHLER: %s lädt nicht" % path)
|
||||
bad += 1
|
||||
print("%d Skripte geprüft, %d fehlerhaft" % [files.size(), bad])
|
||||
quit(1 if bad > 0 else 0)
|
||||
1
tests/lint.gd.uid
Normal file
1
tests/lint.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c65qyffqln67m
|
||||
@@ -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 +0,0 @@
|
||||
uid://bu5hjcfuinhy8
|
||||
@@ -1,65 +0,0 @@
|
||||
extends Node
|
||||
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge.
|
||||
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
|
||||
|
||||
var net: Net
|
||||
var seat := -1
|
||||
var game: Game
|
||||
var t := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var ip := "127.0.0.1"
|
||||
var bot_name := "Bot"
|
||||
for a in OS.get_cmdline_user_args():
|
||||
if a.begins_with("--ip="):
|
||||
ip = a.substr(5)
|
||||
if a.begins_with("--name="):
|
||||
bot_name = a.substr(7)
|
||||
net = Net.new()
|
||||
net.name = "Net"
|
||||
get_tree().root.add_child.call_deferred(net)
|
||||
net.my_name = bot_name
|
||||
net.my_deck = Profile.starter().deck.duplicate()
|
||||
net.started.connect(func(s): seat = s)
|
||||
net.snapshot_received.connect(_on_snapshot)
|
||||
net.ended.connect(func(_r): get_tree().quit())
|
||||
await get_tree().process_frame
|
||||
net.join(ip)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
t += delta
|
||||
if t > 180.0 or FileAccess.file_exists("user://bot_quit"):
|
||||
net.leave()
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
func _on_snapshot(snap: Dictionary) -> void:
|
||||
game = Game.from_snapshot(snap)
|
||||
if game.winner != "":
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
get_tree().quit()
|
||||
return
|
||||
if game.current_idx != seat:
|
||||
return
|
||||
var me: Game.Player = game.players[seat]
|
||||
if not me.mulligan_done and me.turns == 1:
|
||||
net.send_action({"type": "mulligan", "hand": []})
|
||||
return
|
||||
for i in me.hand.size():
|
||||
var card: Cards.Card = me.hand[i]
|
||||
if game.can_play(me, card)[0] and card.target_type() == "":
|
||||
net.send_action({"type": "play", "hand": i, "target": null})
|
||||
return
|
||||
for cr in me.board:
|
||||
if cr.can_attack():
|
||||
for e in game.enemies_of(me):
|
||||
if game.can_attack(cr, e)[0]:
|
||||
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "player", "index": game.players.find(e)}})
|
||||
return
|
||||
for c in e.board:
|
||||
if game.can_attack(cr, c)[0]:
|
||||
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}})
|
||||
return
|
||||
net.send_action({"type": "end"})
|
||||
@@ -1 +0,0 @@
|
||||
uid://dcbqt8ybdkhrb
|
||||
@@ -1,6 +0,0 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/net_bot.gd" id="1"]
|
||||
|
||||
[node name="NetBot" type="Node"]
|
||||
script = ExtResource("1")
|
||||
42
tests/perf_probe.gd
Normal file
42
tests/perf_probe.gd
Normal file
@@ -0,0 +1,42 @@
|
||||
extends SceneTree
|
||||
## Laufzeitmessung: Start, KI-Entscheidungen, Deckerzeugung, Vorschau, Musik.
|
||||
|
||||
|
||||
func ms(f: Callable, n: int = 1) -> float:
|
||||
var t0 := Time.get_ticks_usec()
|
||||
for i in n:
|
||||
f.call()
|
||||
return (Time.get_ticks_usec() - t0) / 1000.0 / n
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var t := ms(func(): Cards.all())
|
||||
print("Kartenpool laden: %.1f ms" % t)
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 3
|
||||
print("Stapel würfeln: %.2f ms" % ms(func(): DeckGen.random_deck(r), 20))
|
||||
print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20))
|
||||
for n in [1]:
|
||||
var g := Game.new_battle(r)
|
||||
# ein volles Brett für alle, damit die Entscheidung teuer wird
|
||||
var uid := 5000
|
||||
for p in g.players:
|
||||
for k in 4:
|
||||
var c := Game.Creature.new(Cards.by_id("f_3_2"), p, uid)
|
||||
uid += 1
|
||||
c.ready = true
|
||||
p.board.append(c)
|
||||
var h: Game.Player = g.players[0]
|
||||
h.energy = 10
|
||||
h.max_energy = 10
|
||||
for k in 6:
|
||||
h.hand.append(Cards.by_id("f_4_3"))
|
||||
AI.time_budget_ms = 100000
|
||||
var t_full := ms(func(): AI.choose_action(g, h))
|
||||
AI.time_budget_ms = 150
|
||||
var t_budget := ms(func(): AI.choose_action(g, h))
|
||||
var cands := AI.candidate_actions(g, h).size()
|
||||
print("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget])
|
||||
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(r).clone(), 20))
|
||||
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
|
||||
quit()
|
||||
1
tests/perf_probe.gd.uid
Normal file
1
tests/perf_probe.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://xrm3o6vloxh0
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Führt Import und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
|
||||
# Führt Import, statische Prüfung und alle Headless-Prüfungen aus. Aufruf: tests/run_headless.sh [godot-binary]
|
||||
set -euo pipefail
|
||||
GODOT="${1:-godot}"
|
||||
cd "$(dirname "$0")/.."
|
||||
"$GODOT" --headless --path . --import > /dev/null 2>&1 || true
|
||||
"$GODOT" --headless --path . -s tests/lint.gd 2>&1 | grep -E "FEHLER|Skripte" || true
|
||||
"$GODOT" --headless --path . -s tests/lint.gd > /dev/null 2>&1
|
||||
"$GODOT" --headless --path . -s tests/test_runner.gd 2>&1 | tee /tmp/dinger_tests.log | grep -E "FEHLER|Prüfungen|SCRIPT ERROR" || true
|
||||
grep -q " 0 fehlgeschlagen" /tmp/dinger_tests.log
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
extends SceneTree
|
||||
## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln
|
||||
## ist etwa 1/(Gegner+1) Siege zu erwarten:
|
||||
## sind etwa 50 % Siege zu erwarten:
|
||||
## godot --headless --path . -s tests/simulate.gd
|
||||
|
||||
|
||||
@@ -8,31 +8,38 @@ func _initialize() -> void:
|
||||
var games := 100
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 0
|
||||
var starter := Profile.starter().deck_cards()
|
||||
var random_deck := func(rr: RandomNumberGenerator) -> Array:
|
||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
||||
Game.shuffle(pool, rr)
|
||||
return Game.build_opponent_deck(rr, [pool[0], pool[1]])
|
||||
var t0 := Time.get_ticks_msec()
|
||||
print("Alle gierig (Erwartung etwa 1/(Gegner+1)):")
|
||||
for n in range(1, Game.MAX_OPPONENTS + 1):
|
||||
_run("Zufallsdeck", random_deck, n, games, r, true, true)
|
||||
print("Beide gierig (Erwartung etwa 50 %):")
|
||||
_run(games, r, true, true)
|
||||
print("Du mit Vorausschau, Gegner gierig:")
|
||||
for n in [1, 2, 3]:
|
||||
_run("Zufallsdeck", random_deck, n, games / 2, r, false, true)
|
||||
print("Alle mit Vorausschau:")
|
||||
_run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false)
|
||||
_run("Zufallsdeck", random_deck, 2, games / 4, r, false, false)
|
||||
_run(games / 2, r, false, true)
|
||||
print("Beide mit Vorausschau:")
|
||||
_run(games / 2, r, false, false)
|
||||
print("Kampagne (du mit Vorausschau), je Stufe:")
|
||||
for stage in [0, 4, 9, 14, 19, 24, 29]:
|
||||
_campaign(stage, games / 4, r)
|
||||
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
|
||||
quit()
|
||||
|
||||
|
||||
func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNumberGenerator,
|
||||
human_greedy: bool, opp_greedy: bool) -> void:
|
||||
func _campaign(stage: int, games: int, r: RandomNumberGenerator) -> void:
|
||||
var wins := 0
|
||||
for i in games:
|
||||
var g := Game.new_battle_with(Campaign.opponent(stage), r)
|
||||
while g.winner == "":
|
||||
AI.take_turn(g, g.current(), false)
|
||||
assert(g.turn < 300, "Spiel endet nicht")
|
||||
if g.winner == "human":
|
||||
wins += 1
|
||||
var st := Campaign.stage(stage)
|
||||
print("Stufe %2d %-12s %2d Leben, %d Merkmale: Siege %3d%%" % [stage + 1, st.name, int(st.life), st.perks.size(), wins * 100 / games])
|
||||
|
||||
|
||||
func _run(games: int, r: RandomNumberGenerator, human_greedy: bool, opp_greedy: bool) -> void:
|
||||
var wins := 0
|
||||
var turns := 0
|
||||
for i in games:
|
||||
var g := Game.new_battle(deck_fn.call(r), n_opp, r)
|
||||
var g := Game.new_battle(r)
|
||||
while g.winner == "":
|
||||
var p := g.current()
|
||||
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy)
|
||||
@@ -40,4 +47,4 @@ func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNum
|
||||
if g.winner == "human":
|
||||
wins += 1
|
||||
turns += g.turn
|
||||
print("%-26s vs %d: Siege %3d%% Ø Züge %.1f" % [label, n_opp, wins * 100 / games, float(turns) / games])
|
||||
print("Siege %3d%% Ø Züge %.1f" % [wins * 100 / games, float(turns) / games])
|
||||
|
||||
1639
tests/test_runner.gd
1639
tests/test_runner.gd
File diff suppressed because it is too large
Load Diff
969
tests/ui_test.gd
969
tests/ui_test.gd
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,26 @@
|
||||
keys,en
|
||||
Dinger,Dinger
|
||||
Ein Kartenspiel: jeder gegen jeden,A card game: free-for-all
|
||||
Ein Kartenspiel: Duell um das Feld,A card game: duel for the field
|
||||
Einzelspieler,Single player
|
||||
Kampagne,Campaign
|
||||
Lokal zu mehreren,Local multiplayer
|
||||
Netzwerkspiel,Network game
|
||||
Deck,Deck
|
||||
Booster öffnen,Open boosters
|
||||
Deck bauen,Build deck
|
||||
Werkstatt,Workshop
|
||||
Erfolge,Achievements
|
||||
Regeln,Rules
|
||||
Einstellungen,Settings
|
||||
Anleitung,Tutorial
|
||||
Beenden,Quit
|
||||
Zurück,Back
|
||||
Weiter,Continue
|
||||
Start,Start
|
||||
Speichern,Save
|
||||
Leeren,Clear
|
||||
Suche,Search
|
||||
Fehlende,Missing
|
||||
Alle,All
|
||||
Aktiv setzen,Set active
|
||||
Auto-Ausfüllen,Auto-fill
|
||||
Umbenennen,Rename
|
||||
Zug beenden,End turn
|
||||
Aufgeben,Surrender
|
||||
Weiterspielen,Keep playing
|
||||
"Ja, aufgeben","Yes, surrender"
|
||||
Wirklich aufgeben?,Really surrender?
|
||||
Der Kampf endet ohne Münzen.,The battle ends without coins.
|
||||
Zurück zum Menü,Back to menu
|
||||
Lupe,Zoom
|
||||
Schließen,Close
|
||||
Held als Ziel,Target hero
|
||||
Verlauf,Log
|
||||
Karte überfahren für Details,Hover a card for details
|
||||
Lautstärke,Volume
|
||||
Musik,Music
|
||||
KI-Tempo,AI speed
|
||||
@@ -45,15 +30,148 @@ Booster,Boosters
|
||||
Booster kaufen (100 Münzen),Buy booster (100 coins)
|
||||
Alle aufdecken,Reveal all
|
||||
Zerlegen,Dismantle
|
||||
Herstellen,Craft
|
||||
Kämpfen,Fight
|
||||
Wiederholen,Replay
|
||||
Gesperrt,Locked
|
||||
Spiel eröffnen,Host game
|
||||
Beitreten,Join
|
||||
Behalten,Keep
|
||||
Tauschen,Swap
|
||||
Hand tauschen?,Swap cards?
|
||||
Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game.
|
||||
Fallen,Traps
|
||||
Du bist dran,Your turn
|
||||
Rückgängig,Undo
|
||||
Kapitel 1,Chapter 1
|
||||
Kapitel 2,Chapter 2
|
||||
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
|
||||
Wähle ein Angriffsziel,Choose an attack target
|
||||
Abgebrochen,Cancelled
|
||||
%s kann nicht angreifen,%s cannot attack
|
||||
Nicht dein Zug,Not your turn
|
||||
Nicht auf der Hand,Not in your hand
|
||||
Dein Feld ist voll,Your board is full
|
||||
Kein gültiges Ziel,No valid target
|
||||
Ungültiges Ziel,Invalid target
|
||||
Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first
|
||||
Sieg!,Victory!
|
||||
Niederlage,Defeat
|
||||
+%d Münzen,+%d coins
|
||||
Schwierigkeit,Difficulty
|
||||
Leicht,Easy
|
||||
Normal,Normal
|
||||
Schwer,Hard
|
||||
Profil zurücksetzen,Reset profile
|
||||
Große Schrift,Large text
|
||||
%d Münzen,%d coins
|
||||
Abbrechen,Cancel
|
||||
"Ja, zurücksetzen","Yes, reset"
|
||||
Flink,Swift
|
||||
Doppelschlag,Double Strike
|
||||
Common,Common
|
||||
Rare,Rare
|
||||
Super Rare,Super Rare
|
||||
Ultra Rare,Ultra Rare
|
||||
Ultimate Rare,Ultimate Rare
|
||||
Feuer,Fire
|
||||
Wasser,Water
|
||||
Erde,Earth
|
||||
Luft,Air
|
||||
%s zerlegt (+%d Staub),%s dismantled (+%d dust)
|
||||
Kapitel %d,Chapter %d
|
||||
Zuschauen,Watch
|
||||
Zuschauer,Spectator
|
||||
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden"
|
||||
Starten,Start
|
||||
Sammlung,Collection
|
||||
Verschmelzen,Fuse
|
||||
Nur eigene Wesen,Only your own creatures
|
||||
Nur eigene Karten,Only your own cards
|
||||
Sammlung: %d von %d Karten · Kampagne: %d/%d,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
|
||||
Tiefsee,Deep Sea
|
||||
Berg,Mountain
|
||||
Orkan,Hurricane
|
||||
Dampf,Steam
|
||||
Lava,Lava
|
||||
Blitz,Lightning
|
||||
Schlamm,Mud
|
||||
Sturm,Storm
|
||||
Sand,Sand
|
||||
Feuerwesen haben +1 Angriff,Fire creatures have +1 attack
|
||||
Wesen kommen mit +1 Leben,Creatures enter with +1 health
|
||||
Bekommt jeden Zug 1 Mana mehr,Gets 1 extra mana each turn
|
||||
Ziehen kostet kein Mana,Drawing costs no mana
|
||||
Spott,Taunt
|
||||
Regeneration,Regeneration
|
||||
Muss zuerst angegriffen werden,Must be attacked first
|
||||
Greift sofort an,Attacks right away
|
||||
Heilt sich am Zugende ganz,Heals fully at the end of the turn
|
||||
Wesen greifen sofort an,Creatures attack right away
|
||||
Verschmelzen mit …,Fuse with …
|
||||
Ziel wählen,Pick the partner
|
||||
Erst die Wesen mit Spott,Taunting creatures first
|
||||
%s kann erst im nächsten Zug aufgedeckt werden,%s can be revealed next turn
|
||||
%s greift erst im nächsten Zug an,%s attacks next turn
|
||||
greift erst im nächsten Zug an,attacks next turn
|
||||
%s durch das Element,%s from its element
|
||||
Zwei verschiedene Karten wählen,Pick two different cards
|
||||
stirbt,dies
|
||||
Abwerfen +%d ◆,Discard +%d ◆
|
||||
Überspringen,Skip
|
||||
Zug %d,Turn %d
|
||||
Stapel: %d Karten (%d bis %d),Deck: %d cards (%d to %d)
|
||||
Stärke %d,Strength %d
|
||||
Herstellen (%d Staub),Craft (%d dust)
|
||||
Nicht in der Sammlung,Not in the collection
|
||||
%s hergestellt,%s crafted
|
||||
Neu!,New!
|
||||
Auffüllen,Fill up
|
||||
Leeren,Clear
|
||||
Dein Stapel ist leer,Your deck is empty
|
||||
Klick 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.
|
||||
Wähle dein Startdeck,Choose your starting deck
|
||||
"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."
|
||||
%s wählen,Choose %s
|
||||
Optionen,Options
|
||||
Hauptmenü,Main menu
|
||||
Neutral,Neutral
|
||||
Nie im Vorteil oder Nachteil,Never at an advantage or disadvantage
|
||||
Veteran,Veteran
|
||||
Feuerritter,Fire Knight
|
||||
Wasserritter,Water Knight
|
||||
Erdritter,Earth Knight
|
||||
Luftritter,Air Knight
|
||||
|
||||
|
Binary file not shown.
Reference in New Issue
Block a user