Compare commits
11
Commits
8944fdd017
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d563b9eec | ||
|
|
f2c609a08b | ||
|
|
b91f475a6e | ||
|
|
a11cee2e0a | ||
|
|
c245c7af87 | ||
|
|
5bc8569717 | ||
|
|
0a612f91b3 | ||
|
|
5f9442497a | ||
|
|
24a03ed1ee | ||
|
|
f0a8f18de8 | ||
|
|
dfcd3cb713 |
No files matched your search
@@ -72,10 +72,5 @@ jobs:
|
|||||||
chmod +x godot
|
chmod +x godot
|
||||||
- name: Import
|
- name: Import
|
||||||
run: ./godot --headless --path . --import || true
|
run: ./godot --headless --path . --import || true
|
||||||
- name: Archetypen messen
|
- name: Balance messen
|
||||||
run: ./godot --headless --path . -s tests/strength_probe.gd 2>&1 | grep -E "%|geschrieben"
|
run: ./godot --headless --path . -s tests/simulate.gd 2>&1 | grep -E "Siege|Dauer"
|
||||||
- name: Ergebnis sichern
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: archetypen
|
|
||||||
path: data/archetypen.json
|
|
||||||
@@ -1,79 +1,62 @@
|
|||||||
# Dinger
|
# Dinger
|
||||||
|
|
||||||
Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle mit denselben Regeln.
|
Kartenspiel in 2D (Godot 4.7): ein Duell gegen einen KI-Magier, beide mit denselben Regeln.
|
||||||
|
|
||||||
## Starten
|
## Starten
|
||||||
|
|
||||||
|
Projektordner in Godot öffnen und auf „Play" drücken, oder im Terminal:
|
||||||
|
|
||||||
```
|
```
|
||||||
./start.sh # Linux/macOS (start.bat unter Windows); Godot 4.7 muss als `godot` im Pfad liegen
|
./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
|
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
|
beim ersten Start nach; alternativ das Projekt einmal im Godot-Editor öffnen oder
|
||||||
`godot --headless --path . --import` ausführen. Danach genügt `godot --path .`.
|
`godot --headless --path . --import` ausführen.
|
||||||
|
|
||||||
## Start
|
|
||||||
|
|
||||||
Projektordner in Godot öffnen und auf „Play“ drücken, oder im Terminal:
|
|
||||||
|
|
||||||
```
|
|
||||||
godot --path .
|
|
||||||
```
|
|
||||||
|
|
||||||
Der Spielstand liegt unter `user://spielstand.json`
|
Der Spielstand liegt unter `user://spielstand.json`
|
||||||
(Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`).
|
(Linux: `~/.local/share/godot/app_userdata/Dinger/spielstand.json`).
|
||||||
|
|
||||||
## Spielsysteme
|
## Spielsysteme
|
||||||
|
|
||||||
- 100 Karten in `data/karten.json`: 4 Elemente, 10 Zweier-Kombos (auch gleiche Elemente), 4 mythische Dreifach-Kombos, 4 Fallen
|
- 550 Karten in `data/karten.json`: je Element und neutral alle Werte von 0/1 bis 10/10, die ID verrät sie (`f_3_2` = Feuer 3/2, `n_…` neutral), Namen aus Eigenschaft und Wesen; Kombos sind die Namen verschmolzener Wesen. Neutrale Karten sind nie im Vorteil oder Nachteil, bilden kein Paar, haben aber ihren eigenen Zähler (+1/+1 je drei); verschmolzen mit einem Element (Feuerritter …) bleibt dessen Vorteil, die Schwäche fällt weg
|
||||||
- Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus
|
- Karten haben nur Element, Angriff und Leben; die Kosten sind zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts)
|
||||||
- Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht)
|
- Mana: 1 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen und Legen
|
||||||
- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern je Kapitel, Tagesaufgabe
|
- Jeder spielt mit seinem eigenen Stapel aus 30 bis 60 Karten (Menü „Deck“: eigene Karten rein oder raus, mit Element- und Manafilter, Manakurve und Elementanteilen; „Auffüllen“ nimmt die wertvollsten des gewählten Filters, bei gleichem Wert die teuersten; „Stärke“ ist der mittlere Kartenwert: ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100, also 100 für Grundkarten bis 500 für Ultimate Rare). Zu Beginn die obersten fünf Karten des gemischten Stapels. Ziehen bringt eine Karte, die erste im Zug ist frei, dann 1, 2, 3 … Mana; ist der Stapel leer, kostet jeder Zug 1, 2, 3 … Leben (Erschöpfung); eine Handkarte abwerfen bringt 1 Mana (Rechtsklick)
|
||||||
- Booster, fünf Deck-Slots, Sammlung (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen)
|
- Eigenschaften gibt es nur über Elementpaare (siehe Elementboni): Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug). Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink; verletzte Wesen heilen nicht von selbst
|
||||||
|
- Verdeckt legen (mittlere Maustaste auf die Handkarte oder im Kartenmenü): gleich teuer, das Wesen greift nicht an, zählt nicht fürs Elementpaar; aufdecken ist kostenlos, frühestens im nächsten Zug, danach greift es sofort an; ein Angriff deckt es auf
|
||||||
|
- Man startet mit allen 110 neutralen Karten in der Sammlung, der Stapel sind 30 davon nach Manakurve (2 zu 0 Mana, 5 zu 1, 6 zu 2, 6 zu 3, 5 zu 4, 3 zu 5, 2 zu 6, 1 zu 7); die Elemente schaltet die Kampagne frei: mit jedem offenen Kapitel gibt es den Booster seines Elements, die Werkstatt bringt den Rest; Sammlung, Deckfilter und Booster zeigen nur Elemente offener Kapitel. Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare) und ist je Stufe +1/+1 stärker, bei gleichen Kosten; die Stufe zeigt sich auch in der Optik (Glanz, Holo, Relief, Funkeln). Verschmolzene Wesen tragen die höhere Stufe ihrer Eltern
|
||||||
|
- Booster: neutral 100 Münzen, je Element 200 (ab seinem Kapitel), einzeln oder fünf und zehn auf einmal; fünf Karten des Elements mit gewürfelter Stufe (45/25/15/10/5 %, mindestens eine Rare); neue kommen in Sammlung und Stapel, eine höhere Stufe ersetzt die alte Version (die wird zu Staub), der Rest wird zu Staub (50/100/200/500/1000). Werkstatt in der Sammlung: Aufwerten kostet 100/200/500/1000 Staub, Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
|
||||||
|
- Elementpaare, jeder Spieler für sich: zwei eigene offene Wesen eines Elements geben allen eigenen Wesen dieses Elements seinen Effekt (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag); verdeckte zählen nicht. Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte. Die Liste links neben dem jeweiligen Brett zeigt nur Elemente mit offenen Wesen: zwei Symbole, gefüllt je offenem eigenen Wesen, und die Zahl der Wesen
|
||||||
|
- Elementvorteil beim Kampf (Wasser > Feuer > Luft > Erde > Wasser): wer im Vorteil ist, macht doppelten Schaden; wer im Nachteil ist, halben (aufgerundet); beim Angriff wie beim Konter, gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht, mit Neutral verschmolzen ist es nie im Nachteil; das Boss-Merkmal „kein Elementnachteil“ hebt den Nachteil auf. Ist ein Angreifer gewählt, zeigen die Gegnerkarten ihren Konter gegen ihn; über einem Ziel zeigt der Angreifer seinen Angriff mit Vorteil, und wer stirbt, trägt die Marke „stirbt“
|
||||||
|
- Angriffe treffen erst Wesen (Wesen mit Spott zuerst); das Leben ist nur ohne Wesen angreifbar, per Doppelklick auf das eigene Wesen direkt. Nichts schlägt durch. Den Zug beendet die Leertaste oder der Rechtsklick ins Leere; Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden)
|
||||||
|
- Verschmelzen: eine Handkarte oder ein eigenes Wesen auf eine andere ziehen und loslassen, auch gemischt; das Ergebnis erscheint über dem Ziel, darunter „Verschmelzen“ oder „Abbrechen“ (Gamepad: Y, „Verschmelzen mit …“). Es kostet nichts; Wesen verlassen das Brett, die neue Karte kommt unverletzt auf die Hand und wird wie jede andere ausgespielt. Je Wert die aufgerundete Hälfte der Grundwertsumme plus eins, Kosten daraus wie bei jeder Karte, dann die höhere Stufe der Eltern mit ihrem +1/+1; Elemente und Eigenschaften beider; drei oder mehr Elemente ergeben eine Chimäre
|
||||||
|
- Duell mit ausgeloster Zugreihenfolge, beide beginnen mit 1 Mana; keine Grenze für Handkarten, das Feld fasst 5 Wesen
|
||||||
|
- Münzen nach jedem Kampf: 5 je eigenem Zug, 5 je ausgespielter Karte, 5 je Zug unter 15 Sekunden; ein Sieg verdoppelt alles; das Ergebnisbild schlüsselt es auf. In der Kampagne ohne Schwierigkeitsfaktor, der erste Sieg einer Stufe bringt ihre Belohnung obendrauf
|
||||||
|
- Erfahrung und Skilltree (Menü „Skilltree“): jede verdiente Münze zählt als Erfahrung; Level n braucht 50·n·(n+1) EP (100, 300, 600 …), jedes Level bringt einen Skillpunkt, Münzen kaufen keine Punkte. Drei Äste: Sammeln (Packglück: Common −8, die höheren Stufen +3/+2/+2/+1 Prozentpunkte je Stufe, bis 3; Staubsammler: +25 % Staub je Stufe, bis 3), Wirtschaft (Münzsegen: +10 % Münzen je Stufe, bis 3; Händler: Booster 10 % billiger je Stufe, bis 2), Kampf (Freies Ziehen: auch das zweite Ziehen je Zug gratis; Starker Start: 3 statt 1 Mana im ersten Zug; beide wirken wie die gleichnamigen Merkmale). Jede Skillkarte zeigt die Wirkung jetzt und auf der nächsten Stufe in Zahlen
|
||||||
|
- Cheats (kleiner Knopf unten rechts im Hauptmenü): Münzen, Staub, Level, Skills, Kapitel und Sammlung direkt setzen, zum Ausprobieren
|
||||||
|
- Schwierigkeit Leicht/Normal/Schwer (Einzelspieler): der Gegner spielt 30 Karten eines Elements aus den offenen Kapiteln nach Manakurve, seine Karten eine Stufe unter deiner Deckstärke, auf ihr oder eine darüber (Stärke 100 → Common … 500 → Ultimate Rare); Leicht mit einfacher KI, Schwer mit einem zufälligen Merkmal; 30 Leben wie du; Münzen 1/1,5/2-fach
|
||||||
|
- KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread
|
||||||
|
- Kampagne: fünf Kapitel mit je zehn Magiern, der zehnte ist der Boss; Kapitel 1 spielt neutrale Karten, Kapitel 2 bis 5 Feuer, Wasser, Luft, Erde; jeder Gegner spielt 30 Karten seines Elements nach Manakurve, der Kostendeckel steigt je Stufe von 3 auf 10 (Boss), in Kapitel 1 und 2 Common, dann je Kapitel eine Stufe höher (Kapitel 5 Ultra Rare); ein Merkmal je Gegner, ab Kapitel 3 zwei, in Kapitel 5 drei; eigenes Merkmal nach Kapitel 1
|
||||||
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
|
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
|
||||||
- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten
|
- Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`)
|
||||||
- Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand)
|
- Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen; Ton und Musik sind standardmäßig aus
|
||||||
- Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch
|
|
||||||
- Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1
|
|
||||||
- Bestenliste (Serie, schnellster Sieg) im Hintergrund: sie steuert den Schwierigkeitstipp
|
|
||||||
- Zehn Fallenkarten, Netzwerk mit Wiederverbinden (Platz bleibt 60 s frei)
|
|
||||||
- Deckstärke aus Seltenheit, Schlüsselwörtern und Effekten; Gegnerdecks werden auf die eigene Stärke gebaut
|
|
||||||
- Schwierigkeit Leicht/Normal/Schwer: Zielstärke 0,9/1,0/1,2-fach, schwache oder starke Archetypen, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach
|
|
||||||
- Gegnervorschau vor dem Kampf (Element und Stärke, neu würfelbar), Vorschlag zur Schwierigkeit nach drei Siegen oder Niederlagen
|
|
||||||
- Deckstärke mit Kurvenabzug und Gewichtung durch die eigene Kartenstatistik; Booster schlagen passende Karten fürs Deck vor
|
|
||||||
- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben, Gischtläufer für Sturm
|
|
||||||
- Gegnervorschau mit höchstens drei Neuwürfen, Schwierigkeitstipp nach Spielverlauf (deutliche Siege oder drei Niederlagen)
|
|
||||||
- KI mit Zeitbudget je Entscheidung (150 ms), Warnung bei ungespeichertem Deck
|
|
||||||
- Lava trifft nur noch das stärkste gegnerische Wesen; Archetypen-Werte kommen aus `data/archetypen.json` (Messlauf schreibt sie)
|
|
||||||
- KI-Vorausschau in zwei Stufen (erst alle Züge mit den besten Zielen, dann die acht besten vertieft): bei 5 Gegnern 0,1 s statt 2 s, und sie rechnet im Hintergrund-Thread, Musik ebenso
|
|
||||||
- KI-Bewertung mit Bedrohung (gegnerische Angriffskraft ohne eigenen Wächter, tödliche besonders) und Führenden-Bremse; im Duell 62:38 gegen die alte Bewertung
|
|
||||||
- Unterbrochene Einzelspieler-Kämpfe (auch Kampagne) werden laufend gespeichert und lassen sich im Menü fortsetzen
|
|
||||||
- Gamepad im Deckbau, in der Sammlung und beim Booster: Karten, Deckzeilen und Kartenrücken sind fokussierbar, A wählt, B geht zurück
|
|
||||||
- Englisch auch für Kartennamen und Kartentexte (`name_en` in `data/karten.json`, Textbausteine über `tr()`)
|
|
||||||
- Deck-Codes zum Teilen (`DINGER1:id*anzahl,…`) mit Zwischenablage im Deckbau
|
|
||||||
- Kapitel 3 der Kampagne (drei Merkmale, volle Tische), acht neue Karten (je Elementpaar eine, zwei Fallen), Elementbooster für 120 Münzen
|
|
||||||
- Netzwerk: fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung (er bekommt laufend den vollen Spielstand), die anderen verbinden neu; Zuschauer sehen die Partie ohne Hände
|
|
||||||
- Balance in drei Messrunden (je 200 Partien pro Archetyp): Sand gibt +1/+1 statt Schild, Wasser und Feuer zäher, Lava-Karten leicht schwächer; Spanne jetzt 40–64 % statt 36–78 % (Feuer+Erde bleibt mit 64 % die Spitze)
|
|
||||||
- Große Schrift: Kartennamen neben dem Kostenkreis, Kartentext läuft nicht mehr über, breitere Knöpfe in Booster, Deckbau und Sammlung
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`:
|
`tests/run_headless.sh` führt Import, statische Prüfung (`tests/lint.gd`) und Headless-Prüfungen aus. `.github/workflows/tests.yml`:
|
||||||
Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test und Archetypen-Messung nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft).
|
Headless und schneller Fenster-Test bei jedem Push, voller Fenster-Test nachts um 3 Uhr oder von Hand (Xvfb, lokal nicht geprüft).
|
||||||
`DINGER_FAST=1` lässt den Fenster-Test ohne die langen Kämpfe laufen (etwa ein Drittel der Zeit).
|
Beide Suiten brechen beim ersten Fehler ab. Der Fenster-Test spielt eine kurze Partie (einfache KI, Gegner mit 1 Leben) und läuft in wenigen Sekunden.
|
||||||
|
|
||||||
```
|
```
|
||||||
godot --headless --path . --import # einmalig nach dem Klonen: Ressourcen importieren (macht start.sh auch)
|
godot --headless --path . --import # einmalig nach dem Klonen: Ressourcen importieren
|
||||||
godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand
|
godot --headless --path . -s tests/test_runner.gd # Regeln, KI, Booster, Spielstand
|
||||||
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
|
godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen KI
|
||||||
godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar
|
|
||||||
godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH)
|
|
||||||
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
|
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
|
||||||
DINGER_ONLY=test_deckbau godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
|
DINGER_ONLY=test_sammlung godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
|
||||||
DINGER_NETZ=1 godot --path . tests/ui_test.tscn # zusätzlich die langsamen Netzwerktests (Bot-Prozesse)
|
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Stapel, Musik
|
||||||
N=200 godot --headless --path . -s tests/strength_probe.gd # Messlauf mit 200 statt 60 Partien je Archetyp
|
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf
|
||||||
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Deckerzeugung, Musik
|
|
||||||
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Export
|
## Export
|
||||||
@@ -88,12 +71,12 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
|
|||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
|
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
|
||||||
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Fallen, Feldkombos, Resonanz, Deckregeln
|
- `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementboni, Kampf, Verschmelzen
|
||||||
- `scripts/deckgen.gd` – Deckstärke, Gegnerdecks entlang der Energiekurve, Archetypen
|
- `scripts/deckgen.gd` – Stapel: zufällige für Gegner, Standardstapel aus der Sammlung
|
||||||
- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk
|
|
||||||
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
|
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
|
||||||
- `scripts/profile.gd` – Spielstand (versioniert), Booster, Tagesaufgabe, Einstellungen
|
- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Stapel, Booster, Werkstatt, Einstellungen
|
||||||
- `scripts/campaign.gd` – Kampagnenstufen
|
- `scripts/campaign.gd` – Kampagnenstufen
|
||||||
- `scenes/main.tscn` – Startszene
|
- `scenes/main.tscn` – Startszene
|
||||||
- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden
|
- `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `element_view.gd` die Elementpaare, `battle_layout.gd` die Aufteilung des Kampfes
|
||||||
- `translations/ui.csv` – englische Oberflächentexte
|
- `translations/ui.csv` – englische Oberflächentexte
|
||||||
|
- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs
|
||||||
+26
-17
@@ -1,25 +1,34 @@
|
|||||||
# Dinger
|
# Cards
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
## Grundidee
|
## Grundidee
|
||||||
- Kartenspiel wie zum Beispiel Magic
|
- Kartenspiel wie zum Beispiel Magic
|
||||||
- PC-Spiel
|
- PC-Spiel
|
||||||
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
|
- Duell: Spieler gegen einen KI-Gegner, beide mit gleichen Regeln
|
||||||
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
|
- Einzelspieler-Knopf führt zur Wahl der Schwierigkeit: der Gegner spielt ein Element aus den offenen Kapiteln, Karten eine Stufe unter deiner Deckstärke / auf ihr / eine darüber; Leicht einfache KI, Schwer ein zufälliges Merkmal; Münzen ×1/×1,5/×2
|
||||||
- Kampagne gegen benannte Magier und Tagesaufgaben als Fortschritt
|
- Münzen nach jedem Kampf: 5 je eigenem Zug, 5 je ausgespielter Karte, 5 je Zug unter 15 s; ein Sieg verdoppelt; Kampagne ohne Faktor, der erste Sieg einer Stufe bringt ihre Belohnung obendrauf
|
||||||
- Im lokalen Netz zu mehreren spielbar, auch als Zuschauer
|
- Erfahrung = verdiente Münzen; Level n braucht 50·n·(n+1) EP, je Level ein Skillpunkt (nicht kaufbar); Skilltree mit drei Ästen: Sammeln (Packglück, Staubsammler), Wirtschaft (Münzsegen, Händler), Kampf (Freies Ziehen, Starker Start als Merkmale)
|
||||||
- Ein unterbrochener Kampf lässt sich später fortsetzen
|
- Kampagne: fünf Kapitel mit je zehn Magiern, der zehnte ist der Boss; Kapitel 1 neutral, dann Feuer, Wasser, Luft, Erde; Gegner spielen 30 Karten ihres Elements nach Manakurve, Kostendeckel je Stufe 3 bis 10, Kapitel 1 und 2 Common, dann je Kapitel eine Stufe höher (Kapitel 5 Ultra Rare); ein offenes Kapitel schaltet den Booster seines Elements frei (neutral 100 Münzen, Element 200)
|
||||||
- Bestenliste als Langzeitziel
|
|
||||||
|
|
||||||
## Sammeln
|
## Sammeln
|
||||||
- Booster öffnen
|
- Man startet mit allen 110 neutralen Karten in der Sammlung, 30 davon nach Manakurve im Stapel; die Elemente kommen über die Kampagne und ihre Booster; alle Karten sammeln ist das lange Ziel
|
||||||
- Karten haben Seltenheiten
|
- Jede eigene Karte hat eine Seltenheitsstufe (Common, Rare, Super Rare, Ultra Rare, Ultimate Rare); je Stufe +1/+1 bei gleichen Kosten, dazu Optik (Glanz, Holo, Relief, Funkeln); Fusionen tragen die höhere Stufe der Eltern
|
||||||
- Aus der Sammlung werden Decks gebaut (mehrere Slots) und als Code geteilt
|
- Booster öffnen (Chancen 45/25/15/10/5 %, mindestens eine Rare): neue Karten kommen in Sammlung und Stapel, von jeder Karte zählt die seltenste Version, die alte wird zu Staub, der Rest wird zu Staub (50/100/200/500/1000)
|
||||||
- Überschüssige Karten lassen sich zerlegen und neue herstellen
|
- Werkstatt: Staub wertet gezielt auf (100/200/500/1000), Zerlegen bringt den Wert zurück (Common verlässt die Sammlung), Herstellen einer fehlenden Karte kostet 200 Staub
|
||||||
|
- Jeder hat sein Deck: 30 bis 60 eigene Karten, im Menü „Deck“ zusammengestellt (Auffüllen folgt dem Filter, wertvollste zuerst; Stärke = mittlerer Kartenwert ⌊(2·Angriff + Leben)/3⌋ − Kosten + 1, mal 100: 100 bis 500); zu Beginn die obersten fünf Karten daraus; Ziehen bringt eine Karte, die erste im Zug frei, dann 1, 2, 3 … Mana; leerer Stapel: jeder Zug kostet 1, 2, 3 … Leben
|
||||||
|
- Mana für Ziehen und Legen (Kosten = 2/3 Angriff + 1/3 Leben, abgerundet, höchstens 10; 0/2 kostet 0), Abwerfen bringt 1; 1 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; keine Grenze für Handkarten
|
||||||
|
- Karten haben nur Angriff und Leben; Eigenschaften kommen allein über Elementpaare: Spott (muss zuerst angegriffen werden), Flink (greift sofort an), Regeneration (heilt am Zugende ganz), Doppelschlag (greift zweimal je Zug)
|
||||||
|
- Gelegte Wesen greifen erst im nächsten Zug an, außer sie sind flink (auch im ersten Zug); verletzte Wesen heilen nicht von selbst
|
||||||
|
- Wesen lassen sich verdeckt legen (mittlere Maustaste oder Kartenmenü); aufdecken ist frei, frühestens im nächsten Zug, dann greift es sofort an; ein Angriff deckt auf
|
||||||
|
- Leben erst ohne Wesen angreifbar; Wesen mit Spott zuerst; nichts schlägt durch
|
||||||
|
- Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen
|
||||||
|
|
||||||
## Elemente
|
## Elemente
|
||||||
- Karten gehören zu Elementen
|
- Jede Karte gehört zu genau einem Element oder ist neutral; je Element und neutral gibt es alle Werte von 0/1 bis 10/10 (550 Karten). Neutral: nie im Vorteil oder Nachteil, kein Paar; verschmolzen nimmt das Wesen im schlechten Matchup die neutrale Seite
|
||||||
- Elemente sind kombinierbar, daraus entstehen viele Varianten
|
- Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan)
|
||||||
- Gleiche Elemente sind auch eine Kombo (Feuer + Feuer)
|
- Zwei eigene offene Wesen eines Elements: Sondereffekt für alle eigenen Wesen des Elements (Erde Spott, Luft Flink, Wasser Regeneration, Feuer Doppelschlag)
|
||||||
- Jedes Element und jede Kombo hat eine eigene Spielweise
|
- Verschmolzene Wesen zählen für alle ihre Elemente und bekommen alle Effekte; verdeckte zählen nicht fürs Paar
|
||||||
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus
|
- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (wer im Vorteil ist, macht doppelten Schaden, wer im Nachteil ist, halben, aufgerundet; beim Angriff wie beim Konter; gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht; Boss-Merkmal „kein Elementnachteil“)
|
||||||
- Verdeckte Fallen bringen Bluff und Reaktion im gegnerischen Zug
|
- Verschmelzen: eine Handkarte oder ein eigenes Wesen auf eine andere ziehen und loslassen, auch gemischt; das Ergebnis erscheint über dem Ziel und wird bestätigt oder abgebrochen (Gamepad: über das Kartenmenü); kostet kein Mana; Wesen verlassen das Brett, die neue Karte kommt unverletzt auf die Hand; je Wert die aufgerundete Hälfte der Grundwertsumme plus eins, Kosten daraus, dann die höhere Stufe der Eltern (+1/+1 je Stufe); Elemente und Eigenschaften beider
|
||||||
|
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"Erde": 50,
|
|
||||||
"Erde+Luft": 56,
|
|
||||||
"Feuer": 40,
|
|
||||||
"Feuer+Erde": 64,
|
|
||||||
"Feuer+Luft": 42,
|
|
||||||
"Feuer+Wasser": 57,
|
|
||||||
"Luft": 45,
|
|
||||||
"Wasser": 43,
|
|
||||||
"Wasser+Erde": 50,
|
|
||||||
"Wasser+Luft": 41
|
|
||||||
}
|
|
||||||
+5392
-2270
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,155 @@
|
|||||||
|
# Design von Dinger
|
||||||
|
|
||||||
|
Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entwurf.
|
||||||
|
|
||||||
|
## Woran wir uns orientieren
|
||||||
|
|
||||||
|
- **Hearthstone** – Kosten und Werte springen aus dem Kartenrahmen (Kristall oben links, Angriff
|
||||||
|
und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt
|
||||||
|
kurz. Auf dem Brett: eigene Seite unten, Gegner oben, links Elementzähler und Meldungen, rechts Stapel und Kästen beider Seiten, Wesen
|
||||||
|
als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt.
|
||||||
|
- **Teamfight Tactics** – Synergien durch gleiche Elemente: zwei eigene geben den Effekt des
|
||||||
|
Elements, drei auf dem Tisch den Wertebonus; die Elementleiste zeigt den Stand.
|
||||||
|
- **Yu-Gi-Oh** – verdeckt gelegte Monster, Angriffe erst auf Monster, Fusion durch Übereinander-
|
||||||
|
legen (hier: ziehen und halten).
|
||||||
|
- **Pokémon TCG** – Seltenheit ist Optik: Folienglanz, Funkeln, Schillern. Werte und Symbole
|
||||||
|
statt Sätze.
|
||||||
|
- **Allgemeine Regeln für Kartenspiel-Oberflächen** (GDKeys, Medium): klar getrennte Zonen,
|
||||||
|
Lesefluss von links (Vergangenheit) über die Mitte (Spiel) nach rechts (Zukunft), die eigene
|
||||||
|
Seite größer als die der Gegner, Zahlen groß, Text klein, Symbole statt Wörter, und Rückmeldung
|
||||||
|
für jede Aktion (Bewegung, Ton, kurzes Zittern).
|
||||||
|
|
||||||
|
## Karte
|
||||||
|
|
||||||
|
- Oben links der Kostenkristall, daneben der Name (immer eine Zeile, nie abgeschnitten: die
|
||||||
|
Schrift schrumpft etwas, dann wird der Text schmaler gezeichnet; das Feld ist so hoch wie der
|
||||||
|
Kristall), in der Mitte das Bild als Elementsymbol im dunklen Kreis, so groß wie
|
||||||
|
der Platz erlaubt, unten Angriff (gelb, links) und Leben (blau, rechts) als Kreise. Karten
|
||||||
|
haben keinen Text: nur Element, Angriff, Leben. Auf dem Feld stehen unter dem Namen links die
|
||||||
|
Eigenschaften als Symbole (Ausrufezeichen = Spott, Doppelpfeil = Flink, Kreuz = Regeneration,
|
||||||
|
gekreuzte Klingen = Doppelschlag, in der Farbe des Elements, das sie gibt).
|
||||||
|
- Die Hand sind alle Karten in Feldgröße, ab sechs überlappend; Details kommen aus der schwebenden Vorschau.
|
||||||
|
- Seltenheit nach Yu-Gi-Oh: Common matt; Rare silbern glänzender Name; Super Rare Holo-Bild
|
||||||
|
(wandernder Regenbogenschimmer im Bildkreis); Ultra Rare golden glänzender Name plus Holo-Bild;
|
||||||
|
Ultimate Rare goldener Name, goldenes Bild, glänzende Kosten und Werte, Schimmer über die ganze
|
||||||
|
Karte, Funkeln, schillernder Rahmen. Der Rahmen trägt ab Rare die Stufenfarbe.
|
||||||
|
Fusionen tragen die höhere Stufe ihrer Eltern.
|
||||||
|
- Mehrere Elemente: der Hintergrund trägt je Element einen senkrechten Streifen; das Bild kommt
|
||||||
|
vom stärkeren Elternteil (Angriff plus Leben).
|
||||||
|
- Verdeckte fremde Wesen sind ein Kartenrücken mit „?“; eigene verdeckte zeigen die Karte mit
|
||||||
|
dem Vermerk „verdeckt“.
|
||||||
|
|
||||||
|
## Kampfbildschirm (1280×800)
|
||||||
|
|
||||||
|
Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
|
||||||
|
|
||||||
|
- **Hand** als alle Karten in Feldgröße unten, in einem leichten Bogen, die des Gegners
|
||||||
|
gleich groß als Rückseiten oben; der
|
||||||
|
eigener Stapel rechts unten als Kartenstoß mit den Kosten des nächsten Ziehens (das erste im Zug
|
||||||
|
frei), darüber der eigene Kasten; der Stapel des Gegners rechts oben, sein Kasten darunter.
|
||||||
|
- **Verschmelzen** per Ziehen: eine Handkarte oder ein eigenes Wesen auf eine andere ziehen
|
||||||
|
(golden gerahmt) und loslassen, auch gemischt. Die neue Handkarte schwebt über dem Ziel, beide
|
||||||
|
Ausgangskarten bleiben sichtbar; darunter „Verschmelzen“ und „Abbrechen“. Es kostet nichts;
|
||||||
|
Wesen verlassen das Brett, die neue Karte wird wie jede andere ausgespielt. Mit Gamepad über
|
||||||
|
das Y-Menü („Verschmelzen mit …“, dann A auf dem Partner).
|
||||||
|
- **Eigenes Brett** mit fünf sichtbaren Plätzen; links daneben die eigene Leiste (Leben, Mana,
|
||||||
|
Einkommen, Zug), rechts „Rückgängig“, nur wenn es gerade möglich ist.
|
||||||
|
- **Elementpaare** je Spieler als Liste links neben seinem Brett, nur Elemente mit offenen Wesen.
|
||||||
|
Je Element zwei Symbole in seiner Form, gefüllt je offenem eigenen Wesen (beide voll: Sondereffekt),
|
||||||
|
daneben die Zahl der Wesen.
|
||||||
|
- **Mittlere Maustaste** auf eine Handkarte legt sie verdeckt. **Kartenmenü** per Rechtsklick
|
||||||
|
oder Y auf Hand- und Brettkarten: verdeckt legen, aufdecken, Spott, Flink, Regeneration und
|
||||||
|
Doppelschlag kaufen; per Y zusätzlich verschmelzen.
|
||||||
|
- **Gegner** oben in der Mitte mit seinem Brett in voller Größe. Das Spiel ist ein Duell wie
|
||||||
|
Hearthstone: mehr als zwei Bretter passen nicht lesbar auf einen Bildschirm, und ein Gegner
|
||||||
|
macht Ziele, Texte und Balance eindeutig.
|
||||||
|
- **Vorschau** steht fest rechts auf halber Höhe, darunter die Zusatzzeile, mit Resonanz, Feld
|
||||||
|
und Feldkombo darunter.
|
||||||
|
- **Optionen** per Esc als Überlagerung: Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden; der Verlauf
|
||||||
|
öffnet darüber. Zug beenden per Leertaste oder Rechtsklick ins Leere, oben rechts nur Rückgängig.
|
||||||
|
- Beim Angreifen zeigt das Ziel seinen Konter mit Elementvorteil (doppelt oder halb), bei
|
||||||
|
Verdeckten nicht; über dem Ziel zeigt der Angreifer seinen Angriff mit Vorteil.
|
||||||
|
|
||||||
|
## Bewegung und Ton
|
||||||
|
|
||||||
|
- Zugbeginn: Band „Dein Zug“ quer über die Mitte, Ton.
|
||||||
|
- Ausspielen: die Kachel fliegt von der Hand auf ihren Platz und federt ein.
|
||||||
|
- Angriff: Ausfallschritt, roter Rahmen am Ziel, kurzes Zittern des Bretts, Schadenszahl.
|
||||||
|
- Aufdecken: „Aufgedeckt!“ am Wesen; Regeneration am Zugende steigt grün auf.
|
||||||
|
- Karten heben sich beim Zeigen an, Knöpfe federn beim Drücken, Bildschirme blenden ein.
|
||||||
|
- Booster: Karten drehen sich auf, Stufen ab Rare blitzen in ihrer Farbe auf.
|
||||||
|
- Alles hängt an der Einstellung „Animationen“.
|
||||||
|
|
||||||
|
## Menüs
|
||||||
|
|
||||||
|
- Hauptmenü: Titel, Einzelspieler, Kampagne und Deck links, darunter Booster, Sammlung, Skilltree,
|
||||||
|
in der Zeile darunter Einstellungen und Beenden, unten rechts der kleine Cheats-Knopf; rechts drei Karten der Sammlung (höchste Stufen zuerst) als Schaufenster; Münzen und
|
||||||
|
Staub oben rechts.
|
||||||
|
- Unterbildschirme: Titel links oben, „Zurück“ rechts oben, Inhalt darunter.
|
||||||
|
- Kampagne als Pfad mit zehn Stationen je Kapitel (fünf Kapitel: Neutral, Feuer, Wasser, Luft,
|
||||||
|
Erde, das Element steht über dem Pfad), die nächste pulsiert; darunter die gewählte Stufe mit
|
||||||
|
Gegner, Leben, Belohnung und Merkmalen. Die Gegner werden über Leben, Merkmale und die
|
||||||
|
Seltenheit ihrer Karten stärker, nicht über Begleiter.
|
||||||
|
- Booster: je offenem Kapitel eine Pack-Kachel (Symbol, Name, Preis, ×1 ×5 ×10), mittig; gesperrte
|
||||||
|
gibt es nicht zu sehen. Die Karten fliegen aus dem Pack ins Raster (ein Pack fünf große, mehrere
|
||||||
|
zehn kleine je Reihe, beste Stufe zuerst) und liegen verdeckt; Aufdecken gestaffelt, seltene
|
||||||
|
blitzen auf, ab Ultra Rare mit Pause. Danach das Fazit „n neu · n aufgewertet · +n Staub“, das
|
||||||
|
gekaufte Pack pulsiert.
|
||||||
|
- Skilltree: oben Level, Erfahrung bis zum nächsten Level und freie Punkte, darunter drei Spalten
|
||||||
|
(Sammeln, Wirtschaft, Kampf) mit Skillkarten: Name, Wirkung, „Jetzt“ und „Nächste Stufe“ in
|
||||||
|
Zahlen, Stufe x / y und „Lernen“.
|
||||||
|
- Cheats (kleiner Knopf unten rechts im Hauptmenü): Stand oben, darunter Knopfreihen für Währung,
|
||||||
|
Erfahrung, Kampagne und Sammlung; jeder Knopf speichert sofort.
|
||||||
|
- Sammlung: sechs Spalten, nur Elemente offener Kapitel, eigene Karten zuerst (höchste Stufen vorn), fehlende abgedunkelt, rechts
|
||||||
|
die große Vorschau mit Aufwerten, Zerlegen oder Herstellen. Deck: gleiches Raster, Filter nach Element (Symbolknöpfe)
|
||||||
|
und Mana 0 bis 10, Stapelkarten zuerst und höchste Stufen vorn, Stapelkarten mit grünem Haken, die
|
||||||
|
anderen abgedunkelt; Klick wechselt. Rechts Kartenzahl, Stärke (mittlerer Kartenwert, 100 für Grundkarten bis 500 für
|
||||||
|
Ultimate Rare), Manakurve, Elementanteile und die Vorschau der gezeigten Karte.
|
||||||
|
|
||||||
|
## Grafik
|
||||||
|
|
||||||
|
Ziel
|
||||||
|
- Wie ein normales Godot/Unity-Spiel, nicht primitiv
|
||||||
|
- Keine Kunstwerke
|
||||||
|
|
||||||
|
Stil
|
||||||
|
- Dunkler Tisch, Elemente leuchten
|
||||||
|
- Rahmen wie Hearthstone, Glanz wie Pokémon TCG
|
||||||
|
- Eine Palette, ein Font, eine Formensprache
|
||||||
|
- Palette aus style.gd erweitern, nicht ersetzen
|
||||||
|
- Font DejaVu Sans
|
||||||
|
|
||||||
|
Regeln
|
||||||
|
- Zahlen groß, Kontrast hoch
|
||||||
|
- Zustände sichtbar: Hover, Pressed, Disabled, Fokus
|
||||||
|
- Icons als SVG, ein Strichstil
|
||||||
|
|
||||||
|
Verbote
|
||||||
|
- Flache graue Panels
|
||||||
|
- Lila Verläufe
|
||||||
|
- Effekte ohne Zweck
|
||||||
|
|
||||||
|
Entschieden
|
||||||
|
- Richtung Stein: warm, Holz und Bronze, Bevel-Rahmen, Vignette
|
||||||
|
- Werte und Kristall bleiben im Kartenrahmen
|
||||||
|
- Rundungen und Kanten glatt, keine Treppchen
|
||||||
|
|
||||||
|
Offen
|
||||||
|
- Szene wählen: Taverne, Arena, Schrein (preview/out/3-*.png)
|
||||||
|
- Radius, Ränder, Schatten im Spiel nachziehen
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
|
||||||
|
- Bilder statt Elementsymbolen, wenn es einmal Kartenkunst gibt.
|
||||||
|
- Hero-Porträts für Kampagnenbosse.
|
||||||
|
- Ein Tisch mit mehreren Spielern ließe sich als TFT-Rotation obendrauf setzen: jede Runde ein
|
||||||
|
anderer Gegner, das Brett bleibt.
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
- https://hearthstone.wiki.gg/wiki/Battlefield
|
||||||
|
- https://hearthstone.fandom.com/wiki/Card
|
||||||
|
- https://moirao.me/home/tft-ui
|
||||||
|
- https://gdkeys.com/the-card-games-ui-design-of-fairtravel-battle/
|
||||||
|
- https://medium.com/@acbassettone/5-ux-ui-lessons-from-designing-a-card-game
|
||||||
|
- https://hackread.com/the-juice-factor-designing-game-feel/
|
||||||
+157
-193
@@ -1,25 +1,19 @@
|
|||||||
class_name AI
|
class_name AI
|
||||||
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels
|
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels ausgeführt und die
|
||||||
## ausgeführt und die Stellung danach bewertet; die besten Erstschritte werden um
|
## Stellung danach bewertet; die besten Erstschritte werden um einen Folgeschritt vertieft.
|
||||||
## einen Folgeschritt vertieft. Die gierige Variante (choose_action_greedy) dient
|
## Verdeckte gegnerische Wesen kennt die KI nicht (Game.masked_clone). Die gierige Variante
|
||||||
## als Vergleich und für die Schwierigkeit Leicht. Alles hier ist zustandslos und
|
## (choose_action_greedy) dient als Vergleich und für die Schwierigkeit Leicht. Alles hier ist
|
||||||
## darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
|
## zustandslos und darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
|
||||||
|
|
||||||
|
|
||||||
# --- Bewertung -------------------------------------------------------------------
|
# --- Bewertung -------------------------------------------------------------------
|
||||||
|
|
||||||
static func creature_value(c: Game.Creature) -> float:
|
static func creature_value(c: Game.Creature) -> float:
|
||||||
var v := float(c.power() + c.health)
|
var v := float(c.power() + c.health)
|
||||||
if c.taunt():
|
|
||||||
v += 2.0
|
|
||||||
if c.shield:
|
|
||||||
v += 1.5
|
|
||||||
if c.attacks_per_turn() == 2:
|
if c.attacks_per_turn() == 2:
|
||||||
v += c.power()
|
v += c.power() * 0.5
|
||||||
if c.frozen:
|
if c.has("Regeneration"):
|
||||||
v -= c.power() # setzt den nächsten Angriff aus: der Angriffswert zählt nicht
|
v += 1.0
|
||||||
if not c.ready:
|
|
||||||
v -= c.power() * 0.25
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
@@ -30,16 +24,22 @@ static func board_value(p: Game.Player) -> float:
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, die der Gegner
|
## Karten auf der Hand zählen nach ihren Werten: ein Drache ist mehr wert als ein Kobold.
|
||||||
## negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden bremsen).
|
static func hand_value(p: Game.Player) -> float:
|
||||||
## Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders.
|
var v := 0.0
|
||||||
|
for c in p.hand:
|
||||||
|
v += 0.15 * (c.attack + c.health)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
## Stellung aus Sicht von p: eigenes Leben, Brett und Hand zählen positiv, die des Gegners
|
||||||
|
## negativ. Ohne eigene Wesen zielt gegnerische Angriffskraft aufs Leben, tödliche besonders.
|
||||||
static func evaluate(g: Game, p: Game.Player) -> float:
|
static func evaluate(g: Game, p: Game.Player) -> float:
|
||||||
if not p.alive():
|
if not p.alive():
|
||||||
return -10000.0
|
return -10000.0
|
||||||
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
|
var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.01
|
||||||
var alive_enemies := 0
|
var alive_enemies := 0
|
||||||
var threat := 0.0
|
var threat := 0.0
|
||||||
var leader := 0.0
|
|
||||||
for e in g.players:
|
for e in g.players:
|
||||||
if e == p:
|
if e == p:
|
||||||
continue
|
continue
|
||||||
@@ -47,54 +47,33 @@ static func evaluate(g: Game, p: Game.Player) -> float:
|
|||||||
score += 40.0
|
score += 40.0
|
||||||
continue
|
continue
|
||||||
alive_enemies += 1
|
alive_enemies += 1
|
||||||
var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
|
score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
|
||||||
score -= ev
|
|
||||||
leader = maxf(leader, ev)
|
|
||||||
if e.life <= 6:
|
if e.life <= 6:
|
||||||
score += 3.0 # fast besiegt: dranbleiben
|
score += 3.0 # fast besiegt: dranbleiben
|
||||||
for c in e.board:
|
for c in e.board:
|
||||||
if not c.frozen:
|
if not c.hidden:
|
||||||
threat += c.power() * c.attacks_per_turn()
|
threat += c.power() * c.attacks_per_turn()
|
||||||
if alive_enemies == 0:
|
if alive_enemies == 0:
|
||||||
return 10000.0
|
return 10000.0
|
||||||
score -= leader * 0.25
|
if p.board.is_empty():
|
||||||
var guarded := false
|
if threat >= p.life:
|
||||||
for c in p.board:
|
score -= 25.0
|
||||||
if c.taunt():
|
else:
|
||||||
guarded = true
|
score -= threat * 0.3
|
||||||
break
|
|
||||||
if guarded:
|
|
||||||
score -= threat * 0.1
|
|
||||||
elif threat >= p.life:
|
|
||||||
score -= 25.0
|
|
||||||
else:
|
else:
|
||||||
score -= threat * 0.3
|
score -= threat * 0.12 # auch hinter Wesen tut gegnerische Angriffskraft weh
|
||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
# --- Vorausschau ---------------------------------------------------------------
|
# --- Vorausschau ---------------------------------------------------------------
|
||||||
|
|
||||||
const MAX_TARGETS := 4 # je Karte bzw. Angreifer nur die aussichtsreichsten Ziele prüfen
|
const MAX_TARGETS := 4 # je Angreifer nur die aussichtsreichsten Ziele prüfen
|
||||||
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
|
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
|
||||||
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
|
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
|
||||||
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
|
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
|
||||||
|
|
||||||
|
|
||||||
## Grobe Rangfolge eines Ziels für einen Karteneffekt (nur zum Vorsortieren).
|
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann der Rest.
|
||||||
static func _target_rank(kind: String, amount: int, t) -> float:
|
|
||||||
if t is Game.Player:
|
|
||||||
return 50.0 - t.life if kind == "damage" else 0.0
|
|
||||||
match kind:
|
|
||||||
"damage":
|
|
||||||
return 100.0 + _value(t) if not t.shield and t.health <= amount else float(_value(t))
|
|
||||||
"freeze", "bounce":
|
|
||||||
return t.card.cost * 10.0 + t.power()
|
|
||||||
"buff", "shield":
|
|
||||||
return (100.0 if t.can_attack() else 0.0) + t.power()
|
|
||||||
return float(_value(t))
|
|
||||||
|
|
||||||
|
|
||||||
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann Helden, dann der Rest.
|
|
||||||
static func _attack_rank(cr: Game.Creature, t) -> float:
|
static func _attack_rank(cr: Game.Creature, t) -> float:
|
||||||
if t is Game.Player:
|
if t is Game.Player:
|
||||||
return 50.0 - t.life
|
return 50.0 - t.life
|
||||||
@@ -113,45 +92,26 @@ static func _top(list: Array, rank: Callable, n: int) -> Array:
|
|||||||
return ranked.slice(0, n).map(func(pair): return pair[1])
|
return ranked.slice(0, n).map(func(pair): return pair[1])
|
||||||
|
|
||||||
|
|
||||||
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Karte und Angreifer.
|
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Angreifer.
|
||||||
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
|
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
|
||||||
var out := []
|
var out := []
|
||||||
var valid_cache := {}
|
|
||||||
for card in p.hand:
|
for card in p.hand:
|
||||||
if not game.can_play(p, card)[0]:
|
if game.can_play(p, card)[0]:
|
||||||
continue
|
out.append({"type": "play", "card": card, "face_down": false})
|
||||||
var tt: String = card.target_type()
|
for cr in p.board:
|
||||||
if tt == "":
|
if cr.hidden and game.can_reveal(p, cr)[0]:
|
||||||
out.append({"type": "play", "card": card, "target": null})
|
out.append({"type": "reveal", "creature": cr})
|
||||||
continue
|
for pr in fusion_pairs(game, p, prune):
|
||||||
if not valid_cache.has(tt):
|
out.append({"type": "fuse", "creature": pr[0], "other": pr[1]})
|
||||||
valid_cache[tt] = game.valid_targets(p, tt)
|
|
||||||
var valid: Array = valid_cache[tt]
|
|
||||||
if valid.is_empty():
|
|
||||||
out.append({"type": "play", "card": card, "target": null})
|
|
||||||
continue
|
|
||||||
if prune:
|
|
||||||
var fx: Cards.Effect = null
|
|
||||||
for f in card.effects:
|
|
||||||
if f.target == tt:
|
|
||||||
fx = f
|
|
||||||
break
|
|
||||||
var kind: String = fx.kind if fx != null else ""
|
|
||||||
var amount: int = fx.amount if fx != null else 0
|
|
||||||
var key := "%s/%s/%d" % [tt, kind, amount]
|
|
||||||
if not valid_cache.has(key):
|
|
||||||
valid_cache[key] = _top(valid, func(t): return _target_rank(kind, amount, t), MAX_TARGETS)
|
|
||||||
valid = valid_cache[key]
|
|
||||||
for t in valid:
|
|
||||||
out.append({"type": "play", "card": card, "target": t})
|
|
||||||
for cr in p.board:
|
for cr in p.board:
|
||||||
if not cr.can_attack():
|
if not cr.can_attack():
|
||||||
continue
|
continue
|
||||||
var targets := []
|
var targets := []
|
||||||
for e in game.enemies_of(p):
|
for e in game.enemies_of(p):
|
||||||
for t in e.board + [e]:
|
targets.append_array(e.board)
|
||||||
if game.can_attack(cr, t)[0]:
|
if e.board.is_empty():
|
||||||
targets.append(t)
|
targets.append(e)
|
||||||
|
targets = targets.filter(func(t): return game.can_attack(cr, t)[0])
|
||||||
if prune:
|
if prune:
|
||||||
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
|
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
|
||||||
for t in targets:
|
for t in targets:
|
||||||
@@ -159,16 +119,51 @@ static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Verschmelzungen, die sich lohnen könnten: Paare eigener Wesen (mit prune die schwächsten, die
|
||||||
|
## Plätze frei machen), jedes angeschlagene Wesen mit der billigsten Handkarte, die zwei
|
||||||
|
## billigsten Handkarten. Das Ergebnis kommt auf die Hand, die Vorausschau legt es im Folgeschritt.
|
||||||
|
static func fusion_pairs(game: Game, p: Game.Player, prune: bool) -> Array:
|
||||||
|
var pairs := []
|
||||||
|
for i in p.board.size():
|
||||||
|
for j in range(i + 1, p.board.size()):
|
||||||
|
pairs.append([p.board[i], p.board[j]])
|
||||||
|
if prune and pairs.size() > MAX_TARGETS:
|
||||||
|
pairs = _top(pairs, func(pr): return -(_value(pr[0]) + _value(pr[1])), MAX_TARGETS)
|
||||||
|
var cheap := _cheapest(p)
|
||||||
|
if not cheap.is_empty():
|
||||||
|
for cr in p.board:
|
||||||
|
if cr.health < cr.max_health:
|
||||||
|
pairs.append([cr, cheap[0]])
|
||||||
|
if cheap.size() >= 2:
|
||||||
|
pairs.append([cheap[0], cheap[1]])
|
||||||
|
return pairs.filter(func(pr): return game.can_fuse(p, pr[0], pr[1])[0])
|
||||||
|
|
||||||
|
|
||||||
|
## Handkarten nach Kosten aufsteigend.
|
||||||
|
static func _cheapest(p: Game.Player) -> Array:
|
||||||
|
var cheap: Array = p.hand.duplicate()
|
||||||
|
cheap.sort_custom(func(a, b): return a.cost < b.cost)
|
||||||
|
return cheap
|
||||||
|
|
||||||
|
|
||||||
|
## Teil einer Verschmelzung in einem anderen Spiel: Handkarten sind in Kopien dieselben Objekte,
|
||||||
|
## Wesen werden über ihre uid gefunden.
|
||||||
|
static func _part(g: Game, x):
|
||||||
|
return x if x is Cards.Card else g.find_creature(x.uid)
|
||||||
|
|
||||||
|
|
||||||
## Führt eine Aktion des Originals auf der Kopie aus.
|
## Führt eine Aktion des Originals auf der Kopie aus.
|
||||||
static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
|
static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
|
||||||
var p2: Game.Player = g2.players[game.current_idx]
|
var p2: Game.Player = g2.players[game.current_idx]
|
||||||
var target = null
|
match action.type:
|
||||||
if action.get("target") != null:
|
"play":
|
||||||
target = map_target(g2, game, action.target)
|
g2.play_card(p2, action.card, bool(action.get("face_down", false)))
|
||||||
if action.type == "play":
|
"reveal":
|
||||||
g2.play_card(p2, action.card, target)
|
g2.reveal(p2, g2.find_creature(action.creature.uid))
|
||||||
else:
|
"fuse":
|
||||||
g2.attack(g2.find_creature(action.creature.uid), target)
|
g2.fuse(p2, _part(g2, action.creature), _part(g2, action.other))
|
||||||
|
_:
|
||||||
|
g2.attack(g2.find_creature(action.creature.uid), map_target(g2, game, action.target))
|
||||||
|
|
||||||
|
|
||||||
static func map_target(g2: Game, game: Game, t):
|
static func map_target(g2: Game, game: Game, t):
|
||||||
@@ -181,34 +176,30 @@ static func map_target(g2: Game, game: Game, t):
|
|||||||
static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary:
|
static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary:
|
||||||
var out := action.duplicate()
|
var out := action.duplicate()
|
||||||
if action.has("creature"):
|
if action.has("creature"):
|
||||||
out["creature"] = game.find_creature(action.creature.uid)
|
out["creature"] = _part(game, action.creature)
|
||||||
|
if action.has("other"):
|
||||||
|
out["other"] = _part(game, action.other)
|
||||||
if action.get("target") != null:
|
if action.get("target") != null:
|
||||||
out["target"] = map_target(game, copy, action.target)
|
out["target"] = map_target(game, copy, action.target)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
|
## Beste Aktion nach einer Vorausschau von zwei Schritten auf einer Kopie, die verdeckte
|
||||||
static func mulligan_cards(p: Game.Player) -> Array:
|
## gegnerische Wesen nicht kennt. Bei Zeitmangel gilt der beste bisherige Zug.
|
||||||
return p.hand.filter(func(c): return c.cost >= 5)
|
|
||||||
|
|
||||||
|
|
||||||
## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer
|
|
||||||
## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte
|
|
||||||
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug.
|
|
||||||
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
||||||
if game.winner != "" or game.enemies_of(p).is_empty():
|
if game.winner != "" or game.enemies_of(p).is_empty():
|
||||||
return {"type": "end"}
|
return {"type": "end"}
|
||||||
if not p.mulligan_done and p.turns == 1 and p == game.current():
|
var view := game.masked_clone(p)
|
||||||
return {"type": "mulligan", "cards": mulligan_cards(p)}
|
var me: Game.Player = view.players[game.players.find(p)]
|
||||||
var t0 := Time.get_ticks_msec()
|
var t0 := Time.get_ticks_msec()
|
||||||
var base := evaluate(game, p) + 0.01
|
var base := evaluate(view, me) + 0.01
|
||||||
var firsts := [] # {action, game, score}
|
var firsts := [] # {action, game, score}
|
||||||
for action in candidate_actions(game, p, true):
|
for action in candidate_actions(view, me, true):
|
||||||
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
|
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||||
break
|
break
|
||||||
var g2 := game.clone_for(p)
|
var g2 := view.clone()
|
||||||
apply_on_clone(g2, game, action)
|
apply_on_clone(g2, view, action)
|
||||||
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])})
|
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[view.current_idx])})
|
||||||
firsts.sort_custom(func(a, b): return a.score > b.score)
|
firsts.sort_custom(func(a, b): return a.score > b.score)
|
||||||
var best := {"type": "end"}
|
var best := {"type": "end"}
|
||||||
var best_score := base
|
var best_score := base
|
||||||
@@ -217,15 +208,15 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
|||||||
var g2: Game = f.game
|
var g2: Game = f.game
|
||||||
var score: float = f.score
|
var score: float = f.score
|
||||||
if g2.winner == "":
|
if g2.winner == "":
|
||||||
var p2: Game.Player = g2.players[game.current_idx]
|
var p2: Game.Player = g2.players[view.current_idx]
|
||||||
var count := 0
|
var count := 0
|
||||||
for nxt in candidate_actions(g2, p2, true):
|
for nxt in candidate_actions(g2, p2, true):
|
||||||
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
|
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||||
break
|
break
|
||||||
count += 1
|
count += 1
|
||||||
var g3 := g2.clone_for(p2)
|
var g3 := g2.clone()
|
||||||
apply_on_clone(g3, g2, nxt)
|
apply_on_clone(g3, g2, nxt)
|
||||||
score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001)
|
score = maxf(score, evaluate(g3, g3.players[view.current_idx]) - 0.001)
|
||||||
if score > best_score:
|
if score > best_score:
|
||||||
best_score = score
|
best_score = score
|
||||||
best = f.action
|
best = f.action
|
||||||
@@ -234,50 +225,49 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
|
|||||||
if firsts[i].score > best_score:
|
if firsts[i].score > best_score:
|
||||||
best_score = firsts[i].score
|
best_score = firsts[i].score
|
||||||
best = firsts[i].action
|
best = firsts[i].action
|
||||||
return best
|
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:
|
static func _kills(attacker, defender) -> bool:
|
||||||
return not defender.shield and defender.health <= attacker.power()
|
if defender is Game.Player:
|
||||||
|
return attacker.power() >= defender.life
|
||||||
|
return attacker.power() + Game.matchup_bonus(attacker, defender) >= defender.health
|
||||||
|
|
||||||
|
|
||||||
static func _value(c) -> int:
|
static func _value(c) -> int:
|
||||||
return c.power() + c.health + (2 if c.taunt() else 0)
|
if c is Game.Player:
|
||||||
|
return 1000
|
||||||
|
return c.power() + c.health
|
||||||
|
|
||||||
|
|
||||||
static func _best(arr: Array, key: Callable):
|
static func _best(list: Array, key: Callable):
|
||||||
var best = null
|
var best = null
|
||||||
var best_v := 0
|
var best_v := -INF
|
||||||
for x in arr:
|
for t in list:
|
||||||
var v: int = key.call(x)
|
var v := float(key.call(t))
|
||||||
if best == null or v > best_v:
|
if v > best_v:
|
||||||
best = x
|
|
||||||
best_v = v
|
best_v = v
|
||||||
return best
|
best = t
|
||||||
|
|
||||||
|
|
||||||
static func _threat_score(h) -> int:
|
|
||||||
var board_attack := 0
|
|
||||||
for c in h.board:
|
|
||||||
board_attack += c.power()
|
|
||||||
return h.life - board_attack
|
|
||||||
|
|
||||||
|
|
||||||
static func _weakest(heroes: Array):
|
|
||||||
var best = null
|
|
||||||
for h in heroes:
|
|
||||||
if best == null or _threat_score(h) < _threat_score(best):
|
|
||||||
best = h
|
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
|
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
|
||||||
if game.winner != "" or game.enemies_of(p).is_empty():
|
if game.winner != "" or game.enemies_of(p).is_empty():
|
||||||
return {"type": "end"}
|
return {"type": "end"}
|
||||||
if not p.mulligan_done and p.turns == 1 and p == game.current():
|
# Gierig: ein Wesen mit höchstens halbem Leben heilen (mit der billigsten Handkarte
|
||||||
return {"type": "mulligan", "cards": mulligan_cards(p)}
|
# verschmelzen, wenn das Ergebnis bezahlbar bleibt), dann das teuerste Spielbare legen (bei
|
||||||
|
# vollem Feld die zwei schwächsten Wesen verschmelzen), dann aufdecken und angreifen, dann ziehen.
|
||||||
|
var cheap := _cheapest(p)
|
||||||
|
if not cheap.is_empty():
|
||||||
|
for cr in p.board:
|
||||||
|
if cr.health * 2 <= cr.max_health and game.can_fuse(p, cr, cheap[0])[0] and Game.fusion_result(p, cr, cheap[0]).cost <= p.energy:
|
||||||
|
return {"type": "fuse", "creature": cr, "other": cheap[0]}
|
||||||
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
|
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
|
||||||
if not playable.is_empty():
|
if not playable.is_empty():
|
||||||
Game.shuffle(playable, game.rng)
|
Game.shuffle(playable, game.rng)
|
||||||
@@ -285,71 +275,40 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
|
|||||||
for c in playable:
|
for c in playable:
|
||||||
if c.cost > card.cost:
|
if c.cost > card.cost:
|
||||||
card = c
|
card = c
|
||||||
return {"type": "play", "card": card, "target": pick_target(game, p, card)}
|
return {"type": "play", "card": card, "face_down": false}
|
||||||
|
if p.board.size() >= Game.MAX_BOARD and p.hand.any(func(c): return c.cost <= p.energy):
|
||||||
|
var weakest: Array = p.board.duplicate()
|
||||||
|
weakest.sort_custom(func(a, b): return _value(a) < _value(b))
|
||||||
|
if game.can_fuse(p, weakest[0], weakest[1])[0]:
|
||||||
|
return {"type": "fuse", "creature": weakest[0], "other": weakest[1]}
|
||||||
|
for cr in p.board:
|
||||||
|
if cr.hidden and game.can_reveal(p, cr)[0]:
|
||||||
|
return {"type": "reveal", "creature": cr}
|
||||||
for cr in p.board:
|
for cr in p.board:
|
||||||
if cr.can_attack():
|
if cr.can_attack():
|
||||||
var t = pick_attack_target(game, p, cr)
|
var t = pick_attack_target(game, p, cr)
|
||||||
if t != null:
|
if t != null:
|
||||||
return {"type": "attack", "creature": cr, "target": t}
|
return {"type": "attack", "creature": cr, "target": t}
|
||||||
|
if game.can_draw(p)[0]:
|
||||||
|
return {"type": "draw"}
|
||||||
return {"type": "end"}
|
return {"type": "end"}
|
||||||
|
|
||||||
|
|
||||||
static func pick_target(game: Game, p: Game.Player, card: Cards.Card):
|
## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
|
||||||
var tt := card.target_type()
|
## der Held nur, wenn kein Wesen mehr steht.
|
||||||
if tt == "":
|
|
||||||
return null
|
|
||||||
var valid := game.valid_targets(p, tt)
|
|
||||||
if valid.is_empty():
|
|
||||||
return null
|
|
||||||
var fx: Cards.Effect = null
|
|
||||||
for f in card.effects:
|
|
||||||
if f.target == tt:
|
|
||||||
fx = f
|
|
||||||
break
|
|
||||||
var creatures := valid.filter(func(t): return t is Game.Creature)
|
|
||||||
var heroes := valid.filter(func(t): return t is Game.Player)
|
|
||||||
match fx.kind:
|
|
||||||
"damage":
|
|
||||||
var killable := creatures.filter(func(c): return not c.shield and c.health <= fx.amount)
|
|
||||||
if not killable.is_empty():
|
|
||||||
return _best(killable, Callable(AI, "_value"))
|
|
||||||
if not heroes.is_empty():
|
|
||||||
return _weakest(heroes)
|
|
||||||
return _best(creatures, Callable(AI, "_value"))
|
|
||||||
"freeze", "bounce":
|
|
||||||
return _best(creatures, func(c): return c.card.cost * 100 + c.power())
|
|
||||||
"buff", "shield":
|
|
||||||
return _best(creatures, func(c): return (100 if c.can_attack() else 0) + c.power())
|
|
||||||
return valid[0]
|
|
||||||
|
|
||||||
|
|
||||||
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
|
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
|
||||||
var enemies := game.enemies_of(p)
|
for e in game.enemies_of(p):
|
||||||
var total := 0
|
if e.board.is_empty():
|
||||||
for c in p.board:
|
return e if game.can_attack(cr, e)[0] else null
|
||||||
if c.can_attack():
|
var creatures: Array = e.board.filter(func(c): return game.can_attack(cr, c)[0])
|
||||||
total += c.power() * c.attacks_left
|
if creatures.is_empty():
|
||||||
var options := []
|
continue
|
||||||
for e in enemies:
|
var good: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and not _kills(c, cr))
|
||||||
var taunts: Array = e.board.filter(func(c): return c.taunt())
|
if not good.is_empty():
|
||||||
if not taunts.is_empty():
|
return _best(good, Callable(AI, "_value"))
|
||||||
options.append_array(taunts)
|
var trade: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and _value(c) > _value(cr))
|
||||||
else:
|
if not trade.is_empty():
|
||||||
if total >= e.life:
|
return _best(trade, Callable(AI, "_value"))
|
||||||
return e
|
|
||||||
options.append_array(e.board)
|
|
||||||
options.append(e)
|
|
||||||
var creatures := options.filter(func(t): return t is Game.Creature)
|
|
||||||
var heroes := options.filter(func(t): return t is Game.Player)
|
|
||||||
var good := creatures.filter(func(c): return _kills(cr, c) and not _kills(c, cr))
|
|
||||||
if not good.is_empty():
|
|
||||||
return _best(good, Callable(AI, "_value"))
|
|
||||||
var trade := creatures.filter(func(c): return _kills(cr, c) and _value(c) > _value(cr))
|
|
||||||
if not trade.is_empty():
|
|
||||||
return _best(trade, Callable(AI, "_value"))
|
|
||||||
if not heroes.is_empty():
|
|
||||||
return _weakest(heroes)
|
|
||||||
if not creatures.is_empty():
|
|
||||||
return _best(creatures, func(c): return 1000 - c.health)
|
return _best(creatures, func(c): return 1000 - c.health)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
@@ -359,11 +318,15 @@ static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
|
|||||||
static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
|
static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
|
||||||
match action.type:
|
match action.type:
|
||||||
"play":
|
"play":
|
||||||
game.play_card(p, action.card, action.target)
|
game.play_card(p, action.card, bool(action.get("face_down", false)))
|
||||||
|
"draw":
|
||||||
|
game.draw(p)
|
||||||
|
"reveal":
|
||||||
|
game.reveal(p, action.creature)
|
||||||
"attack":
|
"attack":
|
||||||
game.attack(action.creature, action.target)
|
game.attack(action.creature, action.target)
|
||||||
"mulligan":
|
"fuse":
|
||||||
game.mulligan(p, action.cards)
|
game.fuse(p, action.creature, action.other)
|
||||||
_:
|
_:
|
||||||
game.end_turn()
|
game.end_turn()
|
||||||
|
|
||||||
@@ -375,3 +338,4 @@ static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: i
|
|||||||
apply_action(game, p, action)
|
apply_action(game, p, action)
|
||||||
if action.type == "end" or game.winner != "":
|
if action.type == "end" or game.winner != "":
|
||||||
return
|
return
|
||||||
|
game.end_turn()
|
||||||
+57
-74
@@ -1,89 +1,72 @@
|
|||||||
class_name Campaign
|
class_name Campaign
|
||||||
## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Gegnern am Tisch.
|
## Kampagne: fünf Kapitel mit je zehn benannten Magiern, der zehnte ist der Boss. Kapitel 1 spielt
|
||||||
|
## neutrale Karten, Kapitel 2 bis 5 je ein Element; jeder Gegner spielt 30 Karten seines Elements
|
||||||
|
## nach Manakurve (DeckGen.curve_deck) mit steigendem Kostendeckel (COST_CAP), in der Stufe seines Kapitels (Kapitel 1 und 2 Common, dann je Kapitel eine höher, Kapitel 5
|
||||||
|
## Ultra Rare). Mit jedem offenen
|
||||||
|
## Kapitel gibt es den Booster seines Elements zu kaufen (Profile.booster_unlocked).
|
||||||
|
|
||||||
const STAGES := [
|
const STAGES_PER_CHAPTER := 10
|
||||||
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"},
|
const CHAPTER_ELEMENTS: Array[String] = [Cards.NEUTRAL, "Feuer", "Wasser", "Luft", "Erde"]
|
||||||
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "reward": 100, "perk": "leben_35"},
|
const NAMES: Array[String] = ["Fenn", "Mara", "Borin", "Liska", "Vulkar", "Nyx", "Ignis", "Thalassa", "Zephyra", "Archimagier"]
|
||||||
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "reward": 150, "perk": "waechter_leben"},
|
const TITLES := {
|
||||||
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "reward": 150, "perk": "flink_angriff"},
|
"Neutral": ["Kobold-Zähmer", "Wanderhexe", "Steinfaust", "Pfadfinderin", "Söldnerfürst", "Schattenruferin", "Grauer Priester", "Händlerkönigin", "Stahlkaiserin", "Meister der Lehre"],
|
||||||
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "zauber_schaden"},
|
"Feuer": ["Flammenzähmer", "Glutweberin", "Schmiedefaust", "Funkenläuferin", "Herr der Lava", "Aschenruferin", "Inferno-Priester", "Glutkönigin", "Flammenkaiserin", "Meister des Feuers"],
|
||||||
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_karten"},
|
"Wasser": ["Gezeitenzähmer", "Gezeitenhexe", "Eisfaust", "Nebelläuferin", "Herr der Brandung", "Regenruferin", "Tiefsee-Priester", "Tiefseekönigin", "Flutkaiserin", "Meister des Wassers"],
|
||||||
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "reward": 250, "perk": "resonanz_doppelt"},
|
"Luft": ["Windzähmer", "Wolkenhexe", "Sturmfaust", "Windläuferin", "Herr der Böen", "Sturmruferin", "Himmelspriester", "Wolkenkönigin", "Blitzkaiserin", "Meister der Lüfte"],
|
||||||
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "reward": 250, "perk": "schild_wesen"},
|
"Erde": ["Wurzelzähmer", "Moorhexe", "Felsenfaust", "Hügelläuferin", "Herr der Schluchten", "Bebenruferin", "Erzpriester", "Kristallkönigin", "Bergkaiserin", "Meister der Erde"],
|
||||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 300, "perk": "alle_flink"},
|
}
|
||||||
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 500, "perk": "start_energie"},
|
const LIFE := [22, 22, 24, 24, 24, 26, 26, 26, 28, 28]
|
||||||
]
|
## Höchste Kartenkosten im Stapel je Stufe eines Kapitels.
|
||||||
|
const COST_CAP: Array[int] = [3, 4, 4, 5, 5, 6, 7, 8, 9, 10]
|
||||||
|
## Merkmale je Stufe: das erste nach dieser Liste, weitere je drei Plätze dahinter.
|
||||||
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück.
|
const PERK_ORDER: Array[String] = ["feuer_angriff", "leben_35", "wesen_angriff", "ziehen_gratis", "wesen_leben", "extra_mana", "kein_nachteil", "alle_flink", "alle_flink", "start_energie"]
|
||||||
const STAGES2 := [
|
|
||||||
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
|
|
||||||
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "reward": 300, "perks": ["leben_35", "extra_karten"]},
|
|
||||||
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]},
|
|
||||||
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "extra": 2, "reward": 350, "perks": ["flink_angriff", "alle_flink"]},
|
|
||||||
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "reward": 400, "perks": ["zauber_schaden", "feuer_angriff"]},
|
|
||||||
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_karten", "flink_angriff"]},
|
|
||||||
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]},
|
|
||||||
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
|
|
||||||
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 500, "perks": ["alle_flink", "start_energie"]},
|
|
||||||
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
## Kapitel 3: drei Merkmale, volle Tische, die stärksten Decks.
|
|
||||||
const STAGES3 := [
|
|
||||||
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "zauber_schaden"]},
|
|
||||||
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_karten", "schild_wesen"]},
|
|
||||||
{"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "extra": 3, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]},
|
|
||||||
{"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "extra": 3, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_karten"]},
|
|
||||||
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["zauber_schaden", "feuer_angriff", "waechter_leben"]},
|
|
||||||
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_karten", "flink_angriff", "resonanz_doppelt"]},
|
|
||||||
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 5, "reward": 900, "perks": ["resonanz_doppelt", "zauber_schaden", "start_energie"]},
|
|
||||||
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 5, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]},
|
|
||||||
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 1000, "perks": ["alle_flink", "start_energie", "zauber_schaden"]},
|
|
||||||
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 5, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_karten", "leben_35"]},
|
|
||||||
]
|
|
||||||
|
|
||||||
const CHAPTERS := [STAGES, STAGES2, STAGES3]
|
|
||||||
|
|
||||||
# Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks.
|
|
||||||
const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6]
|
|
||||||
const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4]
|
|
||||||
const MIN_RATING3 := [3.2, 3.3, 3.5, 3.6, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4]
|
|
||||||
const MIN_RATINGS := [MIN_RATING, MIN_RATING2, MIN_RATING3]
|
|
||||||
|
|
||||||
|
|
||||||
static func target_rating(stage_index: int, player_rating: float) -> float:
|
|
||||||
var base: float = MIN_RATINGS[chapter(stage_index) - 1][stage_index % STAGES.size()]
|
|
||||||
return maxf(base, player_rating)
|
|
||||||
|
|
||||||
|
|
||||||
static func total() -> int:
|
static func total() -> int:
|
||||||
return STAGES.size() * CHAPTERS.size()
|
return STAGES_PER_CHAPTER * CHAPTER_ELEMENTS.size()
|
||||||
|
|
||||||
|
|
||||||
static func chapter(stage: int) -> int:
|
static func chapter(stage: int) -> int:
|
||||||
return stage / STAGES.size() + 1
|
return stage / STAGES_PER_CHAPTER + 1
|
||||||
|
|
||||||
|
|
||||||
## Stufe mit einheitlichem Feld "perks".
|
static func element_of(chapter_no: int) -> String:
|
||||||
|
return CHAPTER_ELEMENTS[chapter_no - 1]
|
||||||
|
|
||||||
|
|
||||||
|
## Merkmale je Gegner: eins, ab Kapitel 3 zwei, in Kapitel 5 drei.
|
||||||
|
static func perk_count(chapter_no: int) -> int:
|
||||||
|
return (chapter_no + 1) / 2
|
||||||
|
|
||||||
|
|
||||||
|
## Stufe i: Name, Titel, Leben, Belohnung (Kapitel 1: 100 bis 300, Boss 500; je Kapitel 250 mehr),
|
||||||
|
## Merkmale und Element.
|
||||||
static func stage(i: int) -> Dictionary:
|
static func stage(i: int) -> Dictionary:
|
||||||
if i < STAGES.size():
|
var c := chapter(i)
|
||||||
var st: Dictionary = STAGES[i].duplicate()
|
var k := i % STAGES_PER_CHAPTER
|
||||||
st["perks"] = [st.perk]
|
var perks: Array = []
|
||||||
return st
|
for n in perk_count(c):
|
||||||
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
|
perks.append(PERK_ORDER[(k + 3 * n) % STAGES_PER_CHAPTER])
|
||||||
|
var base := 100 + 250 * (c - 1)
|
||||||
|
return {"name": NAMES[k], "title": TITLES[element_of(c)][k], "life": LIFE[k],
|
||||||
|
"reward": base + (400 if k == STAGES_PER_CHAPTER - 1 else 50 * (k / 2)),
|
||||||
|
"perks": perks, "element": element_of(c), "max_cost": COST_CAP[k]}
|
||||||
|
|
||||||
|
|
||||||
## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist).
|
## Gegner einer Stufe: Leben und Merkmale der Stufe, 30 Karten seines Elements nach Manakurve bis
|
||||||
static func opponents(stage_index: int, r: RandomNumberGenerator, player_rating: float = 0.0) -> Array:
|
## max_cost, in der Stufe des Kapitels (rarity_of).
|
||||||
|
static func opponent(stage_index: int, r: RandomNumberGenerator = null) -> Game.Player:
|
||||||
var st := stage(stage_index)
|
var st := stage(stage_index)
|
||||||
var target := target_rating(stage_index, player_rating)
|
var boss := Game.Player.new(st.name, false, int(st.life), st.title)
|
||||||
var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE
|
|
||||||
var boss := Game.Player.new(st.name, Game.build_opponent_deck_rated(r, st.elements, target), false, life, st.title)
|
|
||||||
boss.perks = Array(st.perks)
|
boss.perks = Array(st.perks)
|
||||||
var out := [boss]
|
if r == null:
|
||||||
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
|
r = Game.make_rng()
|
||||||
for k in extra:
|
boss.deck = DeckGen.curve_deck(st.element, r, int(st.max_cost))
|
||||||
out.append(Game.random_opponent(r, k + 1, Game.START_LIFE, target))
|
for id in boss.deck:
|
||||||
return out
|
boss.looks[id] = rarity_of(chapter(stage_index))
|
||||||
|
return boss
|
||||||
|
|
||||||
|
|
||||||
|
## Seltenheitsstufe der Gegnerkarten: Kapitel 1 und 2 Common, dann je Kapitel eine höher.
|
||||||
|
static func rarity_of(chapter_no: int) -> int:
|
||||||
|
return maxi(0, chapter_no - 2)
|
||||||
+179
-195
@@ -1,15 +1,25 @@
|
|||||||
class_name Cards
|
class_name Cards
|
||||||
## Elemente, Kombos und der Kartenpool.
|
## Elemente, Kombonamen, Schlüsselwörter und der Kartenpool.
|
||||||
|
|
||||||
const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"]
|
const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"]
|
||||||
|
# Neutral ist kein Element: nie im Vorteil oder Nachteil, zählt nicht für Paare und Zähler.
|
||||||
|
# Verschmolzen mit einem Element nimmt das Wesen im schlechten Matchup die neutrale Seite.
|
||||||
|
const NEUTRAL := "Neutral"
|
||||||
|
const NEUTRAL_TEXT := "Nie im Vorteil oder Nachteil"
|
||||||
const ELEMENT_COLORS := {
|
const ELEMENT_COLORS := {
|
||||||
"Feuer": Color("be3c23"),
|
"Feuer": Color("be3c23"),
|
||||||
"Wasser": Color("2d64be"),
|
"Wasser": Color("2d64be"),
|
||||||
"Erde": Color("73552d"),
|
"Erde": Color("73552d"),
|
||||||
"Luft": Color("78aac8"),
|
"Luft": Color("78aac8"),
|
||||||
|
"Neutral": Color("6e7382"),
|
||||||
}
|
}
|
||||||
# Jede Paarung ist eine Kombo, auch gleiche Elemente.
|
# Jede Karte hat genau ein Element (oder ist neutral). Kombos sind die Namen verschmolzener Wesen.
|
||||||
const COMBOS := {
|
const COMBOS := {
|
||||||
|
"Neutral+Neutral": "Veteran",
|
||||||
|
"Feuer+Neutral": "Feuerritter",
|
||||||
|
"Wasser+Neutral": "Wasserritter",
|
||||||
|
"Erde+Neutral": "Erdritter",
|
||||||
|
"Luft+Neutral": "Luftritter",
|
||||||
"Feuer+Feuer": "Inferno",
|
"Feuer+Feuer": "Inferno",
|
||||||
"Wasser+Wasser": "Tiefsee",
|
"Wasser+Wasser": "Tiefsee",
|
||||||
"Erde+Erde": "Berg",
|
"Erde+Erde": "Berg",
|
||||||
@@ -20,166 +30,139 @@ const COMBOS := {
|
|||||||
"Wasser+Erde": "Schlamm",
|
"Wasser+Erde": "Schlamm",
|
||||||
"Wasser+Luft": "Sturm",
|
"Wasser+Luft": "Sturm",
|
||||||
"Erde+Luft": "Sand",
|
"Erde+Luft": "Sand",
|
||||||
# Dreifach-Kombos: die mythische Stufe
|
|
||||||
"Feuer+Feuer+Feuer": "Sonne",
|
|
||||||
"Wasser+Wasser+Wasser": "Ozean",
|
|
||||||
"Erde+Erde+Erde": "Gebirge",
|
|
||||||
"Luft+Luft+Luft": "Himmel",
|
|
||||||
}
|
}
|
||||||
# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird.
|
# Elementkreis: Wasser löscht Feuer, Feuer verbrennt Luft, Luft trägt Erde ab, Erde saugt Wasser auf.
|
||||||
const FIELD_COMBO_TEXT := {
|
const BEATS := {"Wasser": "Feuer", "Feuer": "Luft", "Luft": "Erde", "Erde": "Wasser"}
|
||||||
"Dampf": "Friere das stärkste gegnerische Wesen ein",
|
# Seltenheit ist reine Optik: jede Karte gibt es in jeder Stufe, die Werte bleiben gleich.
|
||||||
"Lava": "1 Schaden an das stärkste gegnerische Wesen",
|
# Stufen wie bei Yu-Gi-Oh: Common grau, Rare silber, Super Rare blau, Ultra Rare gold,
|
||||||
"Blitz": "2 Schaden an den schwächsten Gegner",
|
# Ultimate Rare schillert durch alle Farben.
|
||||||
"Schlamm": "Heile dich um 3",
|
const RARITIES: Array[String] = ["Common", "Rare", "Super Rare", "Ultra Rare", "Ultimate Rare"]
|
||||||
"Sturm": "Ziehe 1 Karte",
|
|
||||||
"Sand": "Das neue Wesen erhält +1/+1",
|
|
||||||
}
|
|
||||||
const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern."
|
|
||||||
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
|
|
||||||
const RARITY_COLORS := {
|
const RARITY_COLORS := {
|
||||||
"gewöhnlich": Color("aaaaaa"),
|
"Common": Color("aaaaaa"),
|
||||||
"selten": Color("4682e6"),
|
"Rare": Color("c8d0dc"),
|
||||||
"episch": Color("aa46dc"),
|
"Super Rare": Color("4682e6"),
|
||||||
"legendär": Color("f0aa28"),
|
"Ultra Rare": Color("f0aa28"),
|
||||||
"mythisch": Color("ff5a8c"),
|
"Ultimate Rare": Color("ff5a8c"),
|
||||||
}
|
}
|
||||||
const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"]
|
# Eigenschaften haben Karten nicht von Haus aus; sie kommen allein vom Elementpaar der eigenen
|
||||||
const KEYWORD_HELP := {
|
# Seite (ELEMENT_KEYWORD, siehe Game.Creature.has).
|
||||||
"Wächter": "Muss zuerst angegriffen werden.",
|
const KEYWORDS: Array[String] = ["Spott", "Flink", "Regeneration", "Doppelschlag"]
|
||||||
"Flink": "Kann sofort angreifen.",
|
const KEYWORD_TEXT := {
|
||||||
"Schild": "Blockt den nächsten Schaden.",
|
"Spott": "Muss zuerst angegriffen werden",
|
||||||
"Doppelschlag": "Greift zweimal pro Zug an.",
|
"Flink": "Greift sofort an",
|
||||||
|
"Regeneration": "Heilt sich am Zugende ganz",
|
||||||
|
"Doppelschlag": "Greift zweimal je Zug",
|
||||||
}
|
}
|
||||||
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"]
|
const ELEMENT_KEYWORD := {"Erde": "Spott", "Luft": "Flink", "Wasser": "Regeneration", "Feuer": "Doppelschlag"}
|
||||||
# Näherung der Kartenstärke: Seltenheit plus Zuschläge für Schlüsselwörter und Effekte.
|
|
||||||
const RARITY_POINTS := {"gewöhnlich": 1.0, "selten": 2.0, "episch": 3.0, "legendär": 4.0, "mythisch": 5.0}
|
|
||||||
const DATA_PATH := "res://data/karten.json"
|
const DATA_PATH := "res://data/karten.json"
|
||||||
|
|
||||||
|
|
||||||
|
## Reihenfolge der Elemente in Namen und Darstellung: wie ELEMENTS, Neutral zuletzt.
|
||||||
|
static func element_order(e: String) -> int:
|
||||||
|
var i := ELEMENTS.find(e)
|
||||||
|
return i if i >= 0 else ELEMENTS.size()
|
||||||
|
|
||||||
|
|
||||||
static func combo_key(elements: Array) -> String:
|
static func combo_key(elements: Array) -> String:
|
||||||
var sorted := elements.duplicate()
|
var sorted := elements.duplicate()
|
||||||
sorted.sort_custom(func(a, b): return ELEMENTS.find(a) < ELEMENTS.find(b))
|
sorted.sort_custom(func(a, b): return element_order(a) < element_order(b))
|
||||||
return "+".join(sorted)
|
return "+".join(sorted)
|
||||||
|
|
||||||
|
|
||||||
|
## Name der Elementkombination eines verschmolzenen Wesens; drei oder vier Elemente sind eine Chimäre.
|
||||||
static func combo_name(elements: Array) -> String:
|
static func combo_name(elements: Array) -> String:
|
||||||
if elements.size() == 1:
|
if elements.size() == 1:
|
||||||
return elements[0]
|
return elements[0]
|
||||||
return COMBOS[combo_key(elements)]
|
return COMBOS.get(combo_key(elements), "Chimäre")
|
||||||
|
|
||||||
|
|
||||||
static func field_combo_effect(name: String) -> Effect:
|
## Elementvorteil aus Sicht von own: +1 im Vorteil, -1 im Nachteil, sonst 0.
|
||||||
match name:
|
## Hat ein Wesen mehrere Elemente, kämpft es mit dem, das gegen die Gegenseite am besten steht.
|
||||||
"Dampf":
|
static func matchup(own: Array, other: Array) -> int:
|
||||||
return Effect.new("freeze", "strongest_enemy_creature")
|
var mine := best_element(own, other)
|
||||||
"Lava":
|
var theirs := best_element(other, own)
|
||||||
return Effect.new("damage", "strongest_enemy_creature", 1)
|
if mine == "" or theirs == "":
|
||||||
"Blitz":
|
return 0
|
||||||
return Effect.new("damage", "weakest_enemy_hero", 2)
|
return duel(mine, theirs)
|
||||||
"Schlamm":
|
|
||||||
return Effect.new("heal", "self_hero", 3)
|
|
||||||
"Sturm":
|
|
||||||
return Effect.new("draw", "none", 1)
|
|
||||||
"Sand":
|
|
||||||
return Effect.new("buff", "self_creature", 1, 1)
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
const TRIGGER_TEXT := {
|
## +1, wenn a b schlägt, -1, wenn b a schlägt, sonst 0.
|
||||||
"attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift",
|
static func duel(a: String, b: String) -> int:
|
||||||
"creature_played": "Wenn ein Gegner ein Wesen ausspielt",
|
if BEATS.get(a, "") == b:
|
||||||
}
|
return 1
|
||||||
|
if BEATS.get(b, "") == a:
|
||||||
|
return -1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class Effect extends RefCounted:
|
## Das Element aus own, das gegen die Elemente in other am besten steht.
|
||||||
var kind: String # damage, heal, draw, freeze, bounce, buff, shield, summon, cancel
|
static func best_element(own: Array, other: Array) -> String:
|
||||||
var target: String # siehe CHOSEN_TARGETS und Game.resolve()
|
var best := ""
|
||||||
var amount: int
|
var best_score := -9
|
||||||
var amount2: int
|
for e in own:
|
||||||
var card_id: String # für summon
|
var score := 0
|
||||||
|
for o in other:
|
||||||
|
score += duel(e, o)
|
||||||
|
if score > best_score:
|
||||||
|
best = e
|
||||||
|
best_score = score
|
||||||
|
return best
|
||||||
|
|
||||||
func _init(k: String, t: String, a: int = 0, a2: int = 0, cid: String = "") -> void:
|
|
||||||
kind = k
|
|
||||||
target = t
|
|
||||||
amount = a
|
|
||||||
amount2 = a2
|
|
||||||
card_id = cid
|
|
||||||
|
|
||||||
func to_dict() -> Dictionary:
|
## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts).
|
||||||
var d := {"kind": kind, "target": target}
|
static func cost_of(attack: int, health: int) -> int:
|
||||||
if amount != 0:
|
return mini(10, (2 * attack + health) / 3)
|
||||||
d["amount"] = amount
|
|
||||||
if amount2 != 0:
|
|
||||||
d["amount2"] = amount2
|
|
||||||
if card_id != "":
|
|
||||||
d["card_id"] = card_id
|
|
||||||
return d
|
|
||||||
|
|
||||||
static func from_dict(d: Dictionary) -> Effect:
|
|
||||||
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
|
|
||||||
|
|
||||||
## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl).
|
## Wert eines verschmolzenen Wesens aus den Grundwerten der Eltern: die aufgerundete Hälfte der
|
||||||
func text(trigger: String = "") -> String:
|
## Summe plus eins. Ein starker und ein schwacher Partner ergeben also weniger als der starke.
|
||||||
var n := str(amount)
|
static func fusion_value(a: int, b: int) -> int:
|
||||||
if target == "trigger_source":
|
return ceili((a + b) / 2.0) + 1
|
||||||
var played := trigger == "creature_played"
|
|
||||||
match kind:
|
|
||||||
"damage":
|
## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt).
|
||||||
if played:
|
static func fusion_name(elements: Array) -> String:
|
||||||
return tr("%s Schaden an dieses Wesen") % n
|
return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]])
|
||||||
return tr("%s Schaden an den Angreifer") % n
|
|
||||||
"freeze":
|
|
||||||
if played:
|
## Verschmelzung zweier Karten: Elemente (sortiert) und Eigenschaften beider, Angriff und Leben
|
||||||
return tr("Es wird eingefroren")
|
## nach fusion_value aus den Grundwerten, Kosten daraus wie bei jeder Karte; dann die Stufe level
|
||||||
return tr("Der Angreifer wird eingefroren")
|
## mit ihrem +1/+1 (ohne Angabe die höhere der Eltern). Das Bild trägt der stärkere Elternteil
|
||||||
"bounce":
|
## (Angriff plus Leben).
|
||||||
return tr("Es geht zurück auf die Hand")
|
static func fused(a: Card, b: Card, level: int = -1) -> Card:
|
||||||
match kind:
|
var attack := fusion_value(a.base_attack(), b.base_attack())
|
||||||
"cancel":
|
var health := fusion_value(a.base_health(), b.base_health())
|
||||||
return tr("Der Angriff verpufft")
|
var els := []
|
||||||
"summon":
|
for e in a.elements + b.elements:
|
||||||
return tr("Beschwöre einen %s") % Cards.by_id(card_id).title()
|
if not (e in els):
|
||||||
"damage":
|
els.append(e)
|
||||||
match target:
|
els.sort_custom(func(x, y): return element_order(x) < element_order(y))
|
||||||
"enemy_creature":
|
var c := Card.new()
|
||||||
return tr("%s Schaden an ein gegnerisches Wesen") % n
|
c.id = "fus_%s_%s" % [a.id, b.id]
|
||||||
"enemy_any":
|
c.name = fusion_name(els)
|
||||||
return tr("%s Schaden an ein Ziel") % n
|
c.elements = els
|
||||||
"all_enemy_creatures":
|
c.attack = attack
|
||||||
return tr("%s Schaden an alle gegnerischen Wesen") % n
|
c.health = health
|
||||||
"all_enemy_heroes":
|
c.cost = cost_of(attack, health)
|
||||||
return tr("%s Schaden an alle Gegner") % n
|
for k in KEYWORDS:
|
||||||
"all_enemies":
|
if k in a.keywords or k in b.keywords:
|
||||||
return tr("%s Schaden an alle Gegner und ihre Wesen") % n
|
c.keywords.append(k)
|
||||||
"heal":
|
var strong := a if a.attack + a.health >= b.attack + b.health else b
|
||||||
return tr("Heile dich um %s") % n
|
c.art = strong.art_card().id
|
||||||
"draw":
|
return c.leveled(maxi(level, maxi(maxi(a.look, b.look), 0)))
|
||||||
if amount == 1:
|
|
||||||
return tr("Ziehe 1 Karte")
|
|
||||||
return tr("Ziehe %s Karten") % n
|
## Platzhalter für ein verdecktes gegnerisches Wesen: unbekanntes Element, geschätzte Werte.
|
||||||
"freeze":
|
static func unknown() -> Card:
|
||||||
if target == "enemy_creature":
|
var c := Card.new()
|
||||||
return tr("Friere ein gegnerisches Wesen ein")
|
c.id = "?"
|
||||||
if target == "strongest_enemy_creature":
|
c.name = "Verdeckt"
|
||||||
return tr("Friere das stärkste gegnerische Wesen ein")
|
c.name_en = "Face-down"
|
||||||
return tr("Friere alle gegnerischen Wesen ein")
|
c.elements = []
|
||||||
"bounce":
|
c.attack = 2
|
||||||
if target == "enemy_creature":
|
c.health = 3
|
||||||
return tr("Ein gegnerisches Wesen zurück auf die Hand")
|
c.cost = cost_of(2, 3)
|
||||||
return tr("Alle gegnerischen Wesen zurück auf die Hand")
|
return c
|
||||||
"buff":
|
|
||||||
var b := "+%d/+%d" % [amount, amount2]
|
|
||||||
if target == "friendly_creature":
|
|
||||||
return tr("Ein befreundetes Wesen erhält %s") % b
|
|
||||||
return tr("Alle befreundeten Wesen erhalten %s") % b
|
|
||||||
"shield":
|
|
||||||
if target == "friendly_creature":
|
|
||||||
return tr("Ein befreundetes Wesen erhält Schild")
|
|
||||||
if target == "self_creature":
|
|
||||||
return tr("Erhält Schild")
|
|
||||||
return tr("Alle befreundeten Wesen erhalten Schild")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
class Card extends RefCounted:
|
class Card extends RefCounted:
|
||||||
@@ -187,49 +170,70 @@ class Card extends RefCounted:
|
|||||||
var name: String # deutscher Name (auch im Verlauf und in Tests)
|
var name: String # deutscher Name (auch im Verlauf und in Tests)
|
||||||
var name_en := "" # englischer Name
|
var name_en := "" # englischer Name
|
||||||
var elements: Array
|
var elements: Array
|
||||||
var cost: int
|
|
||||||
var rarity: String
|
|
||||||
var kind: String # "Wesen", "Zauber" oder "Falle"
|
|
||||||
var attack: int = 0
|
var attack: int = 0
|
||||||
var health: int = 0
|
var health: int = 0
|
||||||
var keywords: Array = []
|
var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet, höchstens 10 (aus den Grundwerten)
|
||||||
var effects: Array = []
|
var keywords: Array = [] # feste Eigenschaften der Karte (Elementpaare geben ihre zur Laufzeit)
|
||||||
var trigger: String = "" # nur Fallen: attacked, creature_played
|
var look := -1 # Seltenheitsstufe des Exemplars (gibt +1/+1 je Stufe); -1: Grundkarte aus dem Pool
|
||||||
|
var art := "" # verschmolzen: Karte, deren Bild das Wesen trägt (der stärkere Elternteil)
|
||||||
func is_creature() -> bool:
|
|
||||||
return kind == "Wesen"
|
|
||||||
|
|
||||||
func is_trap() -> bool:
|
|
||||||
return kind == "Falle"
|
|
||||||
|
|
||||||
func to_dict() -> Dictionary:
|
func to_dict() -> Dictionary:
|
||||||
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind}
|
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health}
|
||||||
if is_creature():
|
|
||||||
d["attack"] = attack
|
|
||||||
d["health"] = health
|
|
||||||
if not keywords.is_empty():
|
if not keywords.is_empty():
|
||||||
d["keywords"] = keywords
|
d["keywords"] = keywords
|
||||||
if not effects.is_empty():
|
if look >= 0:
|
||||||
d["effects"] = effects.map(func(e): return e.to_dict())
|
d["look"] = look
|
||||||
if trigger != "":
|
if art != "":
|
||||||
d["trigger"] = trigger
|
d["art"] = art
|
||||||
|
if cost != Cards.cost_of(attack, health):
|
||||||
|
d["cost"] = cost
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
## Wert der Karte: was sie über ihre Manakosten hinaus bringt, mal 100. Grundkarten liegen
|
||||||
|
## bei 100, jede Seltenheitsstufe gibt 100 dazu (Ultimate Rare 500); Fusionen aus ihren Werten.
|
||||||
|
func value() -> int:
|
||||||
|
return (Cards.cost_of(attack, health) - cost + 1) * 100
|
||||||
|
|
||||||
|
## Grundwerte ohne den Stufenbonus.
|
||||||
|
func base_attack() -> int:
|
||||||
|
return attack - maxi(look, 0)
|
||||||
|
|
||||||
|
func base_health() -> int:
|
||||||
|
return health - maxi(look, 0)
|
||||||
|
|
||||||
|
## Stufe dieser Karte für einen Besitzer mit dieser Sammlung (Game.Player.looks).
|
||||||
|
func rarity_in(looks: Dictionary) -> String:
|
||||||
|
return Cards.RARITIES[look if look >= 0 else int(looks.get(id, 0))]
|
||||||
|
|
||||||
static func from_dict(d: Dictionary) -> Card:
|
static func from_dict(d: Dictionary) -> Card:
|
||||||
var c := Card.new()
|
var c := Card.new()
|
||||||
c.id = d.id
|
c.id = d.id
|
||||||
c.name = d.name
|
c.name = d.name
|
||||||
c.name_en = str(d.get("name_en", ""))
|
c.name_en = str(d.get("name_en", ""))
|
||||||
c.elements = Array(d.elements)
|
c.elements = Array(d.elements)
|
||||||
c.cost = int(d.cost)
|
|
||||||
c.rarity = d.rarity
|
|
||||||
c.kind = d.kind
|
|
||||||
c.attack = int(d.get("attack", 0))
|
c.attack = int(d.get("attack", 0))
|
||||||
c.health = int(d.get("health", 0))
|
c.health = int(d.get("health", 0))
|
||||||
|
c.cost = int(d.get("cost", Cards.cost_of(c.attack, c.health)))
|
||||||
c.keywords = Array(d.get("keywords", []))
|
c.keywords = Array(d.get("keywords", []))
|
||||||
for e in d.get("effects", []):
|
c.look = int(d.get("look", -1))
|
||||||
c.effects.append(Effect.from_dict(e))
|
c.art = str(d.get("art", ""))
|
||||||
c.trigger = str(d.get("trigger", ""))
|
return c
|
||||||
|
|
||||||
|
## Karte, deren Bild gezeichnet wird: bei Verschmelzungen der stärkere Elternteil, sonst sie selbst.
|
||||||
|
func art_card() -> Card:
|
||||||
|
return Cards.by_id(art) if art != "" and Cards._by_id.has(art) else self
|
||||||
|
|
||||||
|
## Eigenes Exemplar (Hand, Brett): Schlüsselwörter gehören dem Exemplar, nicht dem Pool.
|
||||||
|
func copy() -> Card:
|
||||||
|
return Card.from_dict(to_dict())
|
||||||
|
|
||||||
|
## Exemplar in einer Seltenheitsstufe: +1/+1 je Stufe, die Kosten bleiben die der Grundkarte.
|
||||||
|
func leveled(level: int) -> Card:
|
||||||
|
var c := copy()
|
||||||
|
c.look = level
|
||||||
|
c.attack += level
|
||||||
|
c.health += level
|
||||||
|
c.cost = cost
|
||||||
return c
|
return c
|
||||||
|
|
||||||
## Anzeigename in der aktuellen Sprache.
|
## Anzeigename in der aktuellen Sprache.
|
||||||
@@ -244,32 +248,20 @@ class Card extends RefCounted:
|
|||||||
func combo() -> String:
|
func combo() -> String:
|
||||||
return Cards.combo_name(elements)
|
return Cards.combo_name(elements)
|
||||||
|
|
||||||
func target_type() -> String:
|
func is_neutral() -> bool:
|
||||||
for fx in effects:
|
return Cards.NEUTRAL in elements
|
||||||
if fx.target in Cards.CHOSEN_TARGETS:
|
|
||||||
return fx.target
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
## Kartentext: Neutral und die festen Eigenschaften.
|
||||||
func text() -> String:
|
func text() -> String:
|
||||||
var parts: Array[String] = []
|
var kws: Array[String] = []
|
||||||
if not keywords.is_empty():
|
if is_neutral():
|
||||||
var kws: Array[String] = []
|
kws.append(tr(Cards.NEUTRAL))
|
||||||
for k in Cards.KEYWORDS:
|
for k in Cards.KEYWORDS:
|
||||||
if k in keywords:
|
if k in keywords:
|
||||||
kws.append(tr(k))
|
kws.append(tr(k))
|
||||||
parts.append(", ".join(kws) + ".")
|
if kws.is_empty():
|
||||||
if not effects.is_empty():
|
return ""
|
||||||
var fx: Array[String] = []
|
return ", ".join(kws) + "."
|
||||||
for f in effects:
|
|
||||||
fx.append(f.text(trigger))
|
|
||||||
var s := ", ".join(fx) + "."
|
|
||||||
if is_trap():
|
|
||||||
parts.append(tr("Falle. %s: %s") % [tr(Cards.TRIGGER_TEXT[trigger]), s])
|
|
||||||
elif is_creature():
|
|
||||||
parts.append(tr("Beim Ausspielen: %s") % s)
|
|
||||||
else:
|
|
||||||
parts.append(s)
|
|
||||||
return " ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
static var _all: Array = []
|
static var _all: Array = []
|
||||||
@@ -294,14 +286,6 @@ static func has_id(id) -> bool:
|
|||||||
return _by_id.has(id)
|
return _by_id.has(id)
|
||||||
|
|
||||||
|
|
||||||
static func card_value(c: Card) -> float:
|
|
||||||
return RARITY_POINTS[c.rarity] + 0.3 * c.keywords.size() + 0.3 * c.effects.size()
|
|
||||||
|
|
||||||
|
|
||||||
static func of_rarity(r: String) -> Array:
|
|
||||||
return all().filter(func(c): return c.rarity == r)
|
|
||||||
|
|
||||||
|
|
||||||
## Lädt den Kartenpool aus data/karten.json.
|
## Lädt den Kartenpool aus data/karten.json.
|
||||||
static func _build() -> void:
|
static func _build() -> void:
|
||||||
var json := JSON.new()
|
var json := JSON.new()
|
||||||
|
|||||||
+92
-126
@@ -1,142 +1,108 @@
|
|||||||
class_name DeckGen
|
class_name DeckGen
|
||||||
## Deckstärke, Gegnerdecks und Archetypen (ausgelagert aus den Spielregeln).
|
## Stapel: Elementstapel nach Manakurve (Startstapel und Gegner), Auffüllen aus der Sammlung,
|
||||||
|
## zufällige Stapel für Tests und Simulation.
|
||||||
|
|
||||||
const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3}
|
const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens
|
||||||
# Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner).
|
## Manakurve eines Stapels mit DECK_MIN Karten: so viele Karten je Kosten 0 bis 10.
|
||||||
const RESONANCE_STEPS := [0, 1, 2, 3, 3]
|
const CURVE: Array[int] = [2, 5, 6, 6, 5, 3, 2, 1, 0, 0, 0]
|
||||||
|
|
||||||
# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, 60 Partien je Archetyp, tests/strength_probe.gd).
|
|
||||||
const ARCHETYPE_STRENGTH := {"Erde": 50, "Erde+Luft": 56, "Feuer": 40, "Feuer+Erde": 64, "Feuer+Luft": 42, "Feuer+Wasser": 57, "Luft": 45, "Wasser": 43, "Wasser+Erde": 50, "Wasser+Luft": 41}
|
|
||||||
|
|
||||||
|
|
||||||
|
## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang.
|
||||||
const ARCHETYPE_PATH := "res://data/archetypen.json"
|
static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array:
|
||||||
static var _strength: Dictionary = {}
|
var cheap := Cards.all().filter(func(c): return c.cost <= Game.START_MANA)
|
||||||
|
var rest := Cards.all().filter(func(c): return c.cost > Game.START_MANA and c.cost <= max_cost)
|
||||||
|
Game.shuffle(cheap, r)
|
||||||
## Gemessene Werte aus data/archetypen.json (vom Messlauf geschrieben), sonst die eingebaute Tabelle.
|
Game.shuffle(rest, r)
|
||||||
static func strength() -> Dictionary:
|
var out := []
|
||||||
if _strength.is_empty():
|
for c in cheap.slice(0, CHEAP_IN_RANDOM) + rest + cheap.slice(CHEAP_IN_RANDOM):
|
||||||
_strength = ARCHETYPE_STRENGTH.duplicate()
|
if out.size() >= Game.DECK_MIN:
|
||||||
if FileAccess.file_exists(ARCHETYPE_PATH):
|
|
||||||
var json := JSON.new()
|
|
||||||
if json.parse(FileAccess.get_file_as_string(ARCHETYPE_PATH)) == OK and json.data is Dictionary:
|
|
||||||
for k in json.data:
|
|
||||||
if _strength.has(k):
|
|
||||||
_strength[k] = int(json.data[k])
|
|
||||||
return _strength
|
|
||||||
|
|
||||||
|
|
||||||
## Elementkombinationen einer Stufe: "leicht" die schwächere Hälfte, "schwer" die stärkere, sonst alle.
|
|
||||||
static func archetypes(tier: String = "") -> Array:
|
|
||||||
var table := strength()
|
|
||||||
var keys: Array = table.keys()
|
|
||||||
keys.sort_custom(func(a, b): return table[a] < table[b])
|
|
||||||
var half: int = keys.size() / 2
|
|
||||||
match tier:
|
|
||||||
"leicht":
|
|
||||||
return keys.slice(0, half)
|
|
||||||
"schwer":
|
|
||||||
return keys.slice(half)
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
# Energiekurve der Gegnerdecks: Anzahl Karten je Kosten (6 = sechs und mehr).
|
|
||||||
const CURVE := {1: 3, 2: 5, 3: 4, 4: 3, 5: 2, 6: 3}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Gegnerdeck aus den Elementen entlang der Energiekurve. bias > 1 bevorzugt seltene Karten, bias < 1 gewöhnliche.
|
|
||||||
static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array:
|
|
||||||
var pool := Cards.all().filter(func(c): return c.elements.all(func(e): return e in elements))
|
|
||||||
var deck := []
|
|
||||||
var counts := {}
|
|
||||||
for bucket in CURVE:
|
|
||||||
for i in CURVE[bucket]:
|
|
||||||
var c = _pick_for_cost(r, pool, counts, bucket, bias)
|
|
||||||
if c != null:
|
|
||||||
counts[c.id] = counts.get(c.id, 0) + 1
|
|
||||||
deck.append(c)
|
|
||||||
while deck.size() < Game.DECK_SIZE:
|
|
||||||
var c = _pick_for_cost(r, pool, counts, r.randi_range(1, 6), bias)
|
|
||||||
if c == null:
|
|
||||||
break
|
break
|
||||||
counts[c.id] = counts.get(c.id, 0) + 1
|
out.append(c.id)
|
||||||
deck.append(c)
|
return out
|
||||||
return deck
|
|
||||||
|
|
||||||
|
|
||||||
## Karte nahe den gewünschten Kosten, gewichtet nach Seltenheit (bias) und Kostenabstand.
|
## Alle Karten eines Elements (oder die neutralen) in Datenreihenfolge: die Startsammlung.
|
||||||
## Bei extremer Seltenheitsvorgabe darf die Kurve nachgeben, sonst wäre das Ziel unerreichbar.
|
static func element_deck(element: String) -> Array:
|
||||||
static func _pick_for_cost(r: RandomNumberGenerator, pool: Array, counts: Dictionary, cost: int, bias: float):
|
return Cards.all().filter(func(c): return c.elements[0] == element).map(func(c): return c.id)
|
||||||
var candidates := []
|
|
||||||
var weights := PackedFloat32Array()
|
|
||||||
for c in pool:
|
|
||||||
if counts.get(c.id, 0) >= Game.MAX_COPIES:
|
|
||||||
continue
|
|
||||||
var spread: int = absi(mini(c.cost, 6) - cost)
|
|
||||||
var w: float = OPPONENT_RARITY_WEIGHT[c.rarity]
|
|
||||||
w = w / bias if c.rarity == "gewöhnlich" else w * bias
|
|
||||||
w *= pow(0.3, spread)
|
|
||||||
candidates.append(c)
|
|
||||||
weights.append(w)
|
|
||||||
if candidates.is_empty():
|
|
||||||
return null
|
|
||||||
return candidates[r.rand_weighted(weights)]
|
|
||||||
|
|
||||||
|
|
||||||
## Gegnerdeck auf eine Zielstärke: die Gewichtung wird angepasst, bis das Deck nahe am Ziel liegt.
|
## Stapel eines Elements nach der Manakurve: DECK_MIN verschiedene Karten des Elements bis max_cost,
|
||||||
static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array:
|
## je Kosten so viele wie CURVE vorgibt, zufällig gewählt. Was über dem Deckel fehlt, füllen die
|
||||||
var bias := 1.0
|
## erlaubten Kosten reihum von billig nach teuer auf.
|
||||||
var best := []
|
static func curve_deck(element: String, r: RandomNumberGenerator, max_cost: int = 10) -> Array:
|
||||||
var best_diff := 99.0
|
var by_cost := {}
|
||||||
for attempt in 24:
|
for c in Cards.all():
|
||||||
var deck := build_opponent_deck(r, elements, bias)
|
if c.elements[0] == element and c.cost <= max_cost:
|
||||||
var diff := deck_rating(deck) - target
|
if not by_cost.has(c.cost):
|
||||||
if absf(diff) < best_diff:
|
by_cost[c.cost] = []
|
||||||
best = deck
|
by_cost[c.cost].append(c.id)
|
||||||
best_diff = absf(diff)
|
for cost in by_cost:
|
||||||
if absf(diff) <= tolerance:
|
Game.shuffle(by_cost[cost], r)
|
||||||
|
var out := []
|
||||||
|
for cost in CURVE.size():
|
||||||
|
for k in CURVE[cost]:
|
||||||
|
if by_cost.has(cost) and not by_cost[cost].is_empty():
|
||||||
|
out.append(by_cost[cost].pop_back())
|
||||||
|
while out.size() < Game.DECK_MIN:
|
||||||
|
var added := false
|
||||||
|
for cost in range(0, max_cost + 1):
|
||||||
|
if out.size() >= Game.DECK_MIN:
|
||||||
|
break
|
||||||
|
if by_cost.has(cost) and not by_cost[cost].is_empty():
|
||||||
|
out.append(by_cost[cost].pop_back())
|
||||||
|
added = true
|
||||||
|
if not added:
|
||||||
break
|
break
|
||||||
bias = clampf(bias * (1.35 if diff < 0 else 1.0 / 1.35), 0.05, 20.0)
|
return out
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
## Zufälliger Gegner. target > 0: Deck auf diese Stärke gebaut. tier: Archetypen der Schwierigkeit.
|
## Füllt deck aus pool (Karten-IDs) bis limit auf, die wertvollsten zuerst (Card.value in der Stufe
|
||||||
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = Game.START_LIFE, target: float = 0.0, tier: String = "") -> Game.Player:
|
## aus levels: ID -> Stufe); bei gleichem Wert zuerst, was der Manakurve (CURVE, auf limit
|
||||||
var options := archetypes(tier)
|
## skaliert) noch fehlt, dann die billigeren, dann nach ID; nichts doppelt.
|
||||||
var elements: Array = options[r.randi_range(0, options.size() - 1)].split("+")
|
static func fill(deck: Array, pool: Array, limit: int, levels: Dictionary = {}) -> Array:
|
||||||
var deck := build_opponent_deck_rated(r, elements, target) if target > 0.0 else build_opponent_deck(r, elements)
|
var out := deck.duplicate()
|
||||||
return Game.Player.new("Gegner %d" % index, deck, false, life, Cards.combo_name(elements) + "-Magier")
|
var cards := pool.filter(func(id): return not (id in out)).map(func(id): return Cards.by_id(id).leveled(int(levels.get(id, 0))))
|
||||||
|
cards.sort_custom(func(a, b):
|
||||||
|
if a.value() != b.value():
|
||||||
|
return a.value() > b.value()
|
||||||
|
return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
|
||||||
|
var have := {} # Kosten -> Karten im Stapel
|
||||||
|
for id in out:
|
||||||
|
var slot := clampi(Cards.by_id(id).cost, 0, CURVE.size() - 1)
|
||||||
|
have[slot] = int(have.get(slot, 0)) + 1
|
||||||
|
var i := 0
|
||||||
|
while i < cards.size() and out.size() < limit:
|
||||||
|
var j := i
|
||||||
|
while j < cards.size() and cards[j].value() == cards[i].value():
|
||||||
|
j += 1
|
||||||
|
var rest := []
|
||||||
|
for c in cards.slice(i, j):
|
||||||
|
if out.size() >= limit:
|
||||||
|
break
|
||||||
|
var slot := clampi(c.cost, 0, CURVE.size() - 1)
|
||||||
|
if int(have.get(slot, 0)) < CURVE[slot] * limit / Game.DECK_MIN:
|
||||||
|
out.append(c.id)
|
||||||
|
have[slot] = int(have.get(slot, 0)) + 1
|
||||||
|
else:
|
||||||
|
rest.append(c)
|
||||||
|
for c in rest:
|
||||||
|
if out.size() >= limit:
|
||||||
|
break
|
||||||
|
out.append(c.id)
|
||||||
|
i = j
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
## Kartenwert, bei genug Partien mit der Siegquote der Karte gewichtet (Statistik aus Spieltests).
|
## Stärke eines Stapels: der mittlere Kartenwert (Card.value) in den Stufen aus levels,
|
||||||
static func card_value(c: Cards.Card, stats: Dictionary = {}) -> float:
|
## 100 für lauter Grundkarten bis 500 für lauter Ultimate Rare; leer: 0.
|
||||||
var v := Cards.card_value(c)
|
static func strength(deck: Array, levels: Dictionary) -> int:
|
||||||
if stats.has(c.id):
|
if deck.is_empty():
|
||||||
var st: Dictionary = stats[c.id]
|
return 0
|
||||||
if int(st.games) >= 10:
|
var sum := 0
|
||||||
v *= 0.5 + float(st.wins) / float(st.games)
|
for id in deck:
|
||||||
return v
|
sum += Cards.by_id(id).leveled(int(levels.get(id, 0))).value()
|
||||||
|
return roundi(float(sum) / deck.size())
|
||||||
|
|
||||||
|
|
||||||
## Abzug für schlechte Energiekurven: zu wenig günstige oder zu viele teure Karten.
|
static func random_opponent(life: int = Game.START_LIFE) -> Game.Player:
|
||||||
static func curve_penalty(cards: Array) -> float:
|
return Game.Player.new("Gegner", false, life)
|
||||||
var cheap := 0
|
|
||||||
var expensive := 0
|
|
||||||
for c in cards:
|
|
||||||
if c.cost <= 2:
|
|
||||||
cheap += 1
|
|
||||||
if c.cost >= 5:
|
|
||||||
expensive += 1
|
|
||||||
return minf(0.05 * maxi(0, 6 - cheap) + 0.05 * maxi(0, expensive - 5), 0.4)
|
|
||||||
|
|
||||||
|
|
||||||
## Deckstärke: Durchschnitt der Kartenwerte minus Kurvenabzug (1 = nur gewöhnliche Karten, bis etwa 3,5).
|
|
||||||
static func deck_rating(cards: Array, stats: Dictionary = {}) -> float:
|
|
||||||
if cards.is_empty():
|
|
||||||
return 0.0
|
|
||||||
var sum := 0.0
|
|
||||||
for c in cards:
|
|
||||||
sum += card_value(c, stats)
|
|
||||||
return sum / cards.size() - curve_penalty(cards)
|
|
||||||
+334
-440
File diff suppressed because it is too large.
Load diff
-428
@@ -1,428 +0,0 @@
|
|||||||
class_name Net
|
|
||||||
extends Node
|
|
||||||
## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten
|
|
||||||
## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen).
|
|
||||||
|
|
||||||
signal lobby_changed
|
|
||||||
signal started(seat: int)
|
|
||||||
signal snapshot_received(snap: Dictionary)
|
|
||||||
signal action_applied
|
|
||||||
signal ended(reason: String)
|
|
||||||
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
|
|
||||||
signal status_changed
|
|
||||||
|
|
||||||
const PORT := 24455
|
|
||||||
|
|
||||||
var peer: ENetMultiplayerPeer
|
|
||||||
var is_host := false
|
|
||||||
var connected := false
|
|
||||||
var my_name := "Spieler"
|
|
||||||
var my_deck: Array = [] # Karten-IDs
|
|
||||||
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
|
|
||||||
var decks := {} # peer_id -> Karten-IDs (Host)
|
|
||||||
var seats := {} # peer_id -> Spielerindex (Host)
|
|
||||||
var my_seat := -1
|
|
||||||
var game: Game # Host
|
|
||||||
var status := ""
|
|
||||||
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
|
|
||||||
var rejoin_seconds := 60.0
|
|
||||||
var spectator := false # eigene Rolle: nur zuschauen
|
|
||||||
var spectators := {} # Host: peer_id -> Name
|
|
||||||
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
|
|
||||||
var i_am_successor := false
|
|
||||||
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
|
|
||||||
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
|
|
||||||
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
|
|
||||||
var reconnecting := false
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
||||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
||||||
multiplayer.connected_to_server.connect(_on_connected)
|
|
||||||
multiplayer.connection_failed.connect(func():
|
|
||||||
if not reconnecting:
|
|
||||||
_fail("Verbindung fehlgeschlagen"))
|
|
||||||
multiplayer.server_disconnected.connect(_on_server_lost)
|
|
||||||
|
|
||||||
|
|
||||||
func host(port: int = PORT) -> Error:
|
|
||||||
peer = ENetMultiplayerPeer.new()
|
|
||||||
var err := peer.create_server(port, Game.MAX_OPPONENTS)
|
|
||||||
if err != OK:
|
|
||||||
status = "Kann nicht hosten (Port %d belegt?)" % port
|
|
||||||
return err
|
|
||||||
multiplayer.multiplayer_peer = peer
|
|
||||||
is_host = true
|
|
||||||
connected = true
|
|
||||||
names = {1: my_name}
|
|
||||||
decks = {1: my_deck}
|
|
||||||
spectators = {}
|
|
||||||
status = "Warte auf Mitspieler …"
|
|
||||||
lobby_changed.emit()
|
|
||||||
return OK
|
|
||||||
|
|
||||||
|
|
||||||
func join(ip: String, port: int = PORT) -> Error:
|
|
||||||
peer = ENetMultiplayerPeer.new()
|
|
||||||
var err := peer.create_client(ip, port)
|
|
||||||
if err != OK:
|
|
||||||
status = "Adresse ungültig"
|
|
||||||
return err
|
|
||||||
multiplayer.multiplayer_peer = peer
|
|
||||||
is_host = false
|
|
||||||
status = "Verbinde mit %s …" % ip
|
|
||||||
return OK
|
|
||||||
|
|
||||||
|
|
||||||
func leave() -> void:
|
|
||||||
if multiplayer.multiplayer_peer != null:
|
|
||||||
multiplayer.multiplayer_peer.close()
|
|
||||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
|
||||||
peer = null
|
|
||||||
is_host = false
|
|
||||||
connected = false
|
|
||||||
names = {}
|
|
||||||
decks = {}
|
|
||||||
seats = {}
|
|
||||||
waiting = {}
|
|
||||||
my_seat = -1
|
|
||||||
game = null
|
|
||||||
status = ""
|
|
||||||
spectator = false
|
|
||||||
spectators = {}
|
|
||||||
successor_ip = ""
|
|
||||||
i_am_successor = false
|
|
||||||
backup = {}
|
|
||||||
backup_names = {}
|
|
||||||
last_winner = ""
|
|
||||||
|
|
||||||
|
|
||||||
func local_addresses() -> Array:
|
|
||||||
return Array(IP.get_local_addresses()).filter(func(a): return "." in a and not a.begins_with("127."))
|
|
||||||
|
|
||||||
|
|
||||||
func _fail(reason: String) -> void:
|
|
||||||
status = reason
|
|
||||||
ended.emit(reason)
|
|
||||||
leave()
|
|
||||||
|
|
||||||
|
|
||||||
func _on_connected() -> void:
|
|
||||||
connected = true
|
|
||||||
status = "Verbunden"
|
|
||||||
register.rpc_id(1, my_name, my_deck, spectator)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Host-Wechsel ---------------------------------------------------------------
|
|
||||||
# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen
|
|
||||||
# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der
|
|
||||||
# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück.
|
|
||||||
|
|
||||||
func _successor_id() -> int:
|
|
||||||
var ids: Array = seats.keys().filter(func(id): return id != 1)
|
|
||||||
ids.sort()
|
|
||||||
return ids[0] if not ids.is_empty() else -1
|
|
||||||
|
|
||||||
|
|
||||||
func _update_successor() -> void:
|
|
||||||
if not is_host or game == null:
|
|
||||||
return
|
|
||||||
var succ := _successor_id()
|
|
||||||
var ip := ""
|
|
||||||
if succ > 0 and peer != null and peer.get_peer(succ) != null:
|
|
||||||
ip = peer.get_peer(succ).get_remote_address()
|
|
||||||
for id in seats:
|
|
||||||
if id != 1:
|
|
||||||
set_successor.rpc_id(id, ip, id == succ)
|
|
||||||
|
|
||||||
|
|
||||||
## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel.
|
|
||||||
func _seat_names() -> Dictionary:
|
|
||||||
var out := {}
|
|
||||||
for id in seats:
|
|
||||||
out[seats[id]] = names.get(id, "")
|
|
||||||
for pname in waiting:
|
|
||||||
out[waiting[pname].seat] = pname
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
|
||||||
func set_successor(ip: String, me: bool) -> void:
|
|
||||||
successor_ip = ip
|
|
||||||
i_am_successor = me
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
|
||||||
func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void:
|
|
||||||
backup = snap
|
|
||||||
backup_names = seat_names
|
|
||||||
|
|
||||||
|
|
||||||
func _on_server_lost() -> void:
|
|
||||||
if is_host:
|
|
||||||
return
|
|
||||||
if last_winner != "":
|
|
||||||
_fail("Host hat das Spiel beendet")
|
|
||||||
elif i_am_successor and not backup.is_empty():
|
|
||||||
_become_host()
|
|
||||||
elif successor_ip != "":
|
|
||||||
status = "Host weg, verbinde mit dem Nachfolger …"
|
|
||||||
status_changed.emit()
|
|
||||||
_reconnect(successor_ip)
|
|
||||||
else:
|
|
||||||
_fail("Host hat das Spiel beendet")
|
|
||||||
|
|
||||||
|
|
||||||
func _become_host() -> void:
|
|
||||||
var snap := backup
|
|
||||||
var seat_names := backup_names
|
|
||||||
var seat := my_seat
|
|
||||||
if multiplayer.multiplayer_peer != null:
|
|
||||||
multiplayer.multiplayer_peer.close()
|
|
||||||
var err := FAILED
|
|
||||||
for attempt in 5:
|
|
||||||
err = host()
|
|
||||||
if err == OK:
|
|
||||||
break
|
|
||||||
await get_tree().create_timer(1.0).timeout
|
|
||||||
if err != OK:
|
|
||||||
_fail("Host weg, Übernahme fehlgeschlagen")
|
|
||||||
return
|
|
||||||
game = Game.from_snapshot(snap)
|
|
||||||
seats = {1: seat}
|
|
||||||
my_seat = seat
|
|
||||||
waiting = {}
|
|
||||||
for s in seat_names:
|
|
||||||
if int(s) != seat:
|
|
||||||
waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()}
|
|
||||||
game.say("%s übernimmt als Host." % my_name)
|
|
||||||
status = "Du bist jetzt Host, die anderen verbinden neu …"
|
|
||||||
successor_ip = ""
|
|
||||||
i_am_successor = false
|
|
||||||
backup = {}
|
|
||||||
migrated.emit()
|
|
||||||
|
|
||||||
|
|
||||||
func _reconnect(ip: String) -> void:
|
|
||||||
reconnecting = true
|
|
||||||
await get_tree().create_timer(2.0).timeout
|
|
||||||
for attempt in 5:
|
|
||||||
if join(ip) == OK:
|
|
||||||
var t := 0.0
|
|
||||||
while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING:
|
|
||||||
await get_tree().process_frame
|
|
||||||
t += get_process_delta_time()
|
|
||||||
if connected:
|
|
||||||
reconnecting = false
|
|
||||||
return
|
|
||||||
await get_tree().create_timer(1.0).timeout
|
|
||||||
reconnecting = false
|
|
||||||
_fail("Nachfolge-Host nicht erreichbar")
|
|
||||||
|
|
||||||
|
|
||||||
func _on_peer_connected(_id: int) -> void:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
func _on_peer_disconnected(id: int) -> void:
|
|
||||||
if not is_host:
|
|
||||||
return
|
|
||||||
var pname: String = names.get(id, "")
|
|
||||||
names.erase(id)
|
|
||||||
decks.erase(id)
|
|
||||||
spectators.erase(id)
|
|
||||||
if game != null and seats.has(id):
|
|
||||||
var p: Game.Player = game.players[seats[id]]
|
|
||||||
if game.winner == "":
|
|
||||||
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
|
|
||||||
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)])
|
|
||||||
seats.erase(id)
|
|
||||||
_update_successor()
|
|
||||||
action_applied.emit()
|
|
||||||
lobby_update.rpc(names, spectators)
|
|
||||||
lobby_changed.emit()
|
|
||||||
|
|
||||||
|
|
||||||
## Host, je Bild: wartende Spieler überspringen; nach Ablauf der Frist scheiden sie aus.
|
|
||||||
func tick() -> void:
|
|
||||||
if not is_host or game == null or game.winner != "":
|
|
||||||
return
|
|
||||||
var now := Time.get_unix_time_from_system()
|
|
||||||
for pname in waiting.keys():
|
|
||||||
var w: Dictionary = waiting[pname]
|
|
||||||
var p: Game.Player = game.players[w.seat]
|
|
||||||
if now - float(w.since) > rejoin_seconds:
|
|
||||||
waiting.erase(pname)
|
|
||||||
game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
|
|
||||||
game.damage(p, 999)
|
|
||||||
game.check_end()
|
|
||||||
action_applied.emit()
|
|
||||||
elif game.current() == p:
|
|
||||||
p.mulligan_done = true
|
|
||||||
game.say("%s ist nicht da, der Zug wird übersprungen." % p.name)
|
|
||||||
game.end_turn()
|
|
||||||
action_applied.emit()
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("any_peer", "reliable")
|
|
||||||
func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void:
|
|
||||||
if not is_host:
|
|
||||||
return
|
|
||||||
var id := multiplayer.get_remote_sender_id()
|
|
||||||
if as_spectator:
|
|
||||||
spectators[id] = name
|
|
||||||
lobby_update.rpc(names, spectators)
|
|
||||||
lobby_changed.emit()
|
|
||||||
if game != null:
|
|
||||||
start_game.rpc_id(id, -1)
|
|
||||||
receive_snapshot.rpc_id(id, game.snapshot(-2))
|
|
||||||
return
|
|
||||||
if game != null:
|
|
||||||
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
|
|
||||||
if waiting.has(name):
|
|
||||||
seats[id] = waiting[name].seat
|
|
||||||
names[id] = name
|
|
||||||
waiting.erase(name)
|
|
||||||
game.say("%s ist zurück." % name)
|
|
||||||
start_game.rpc_id(id, seats[id])
|
|
||||||
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
|
||||||
_update_successor()
|
|
||||||
action_applied.emit()
|
|
||||||
else:
|
|
||||||
status = "%s wollte beitreten, das Spiel läuft schon" % name
|
|
||||||
return
|
|
||||||
names[id] = name
|
|
||||||
decks[id] = deck_ids
|
|
||||||
lobby_update.rpc(names, spectators)
|
|
||||||
lobby_changed.emit()
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
|
||||||
func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
|
|
||||||
names = n
|
|
||||||
spectators = sp
|
|
||||||
lobby_changed.emit()
|
|
||||||
|
|
||||||
|
|
||||||
## Host: Spiel mit allen Verbundenen und n_ai KI-Gegnern starten.
|
|
||||||
func start_battle(n_ai: int) -> void:
|
|
||||||
var ids: Array = names.keys()
|
|
||||||
ids.sort()
|
|
||||||
var r := Game.make_rng()
|
|
||||||
var players := []
|
|
||||||
seats = {}
|
|
||||||
for id in ids:
|
|
||||||
var deck_ids: Array = decks.get(id, [])
|
|
||||||
var deck := []
|
|
||||||
for cid in deck_ids:
|
|
||||||
if Cards.has_id(cid):
|
|
||||||
deck.append(Cards.by_id(cid))
|
|
||||||
if Game.validate_deck(deck)[0] == false:
|
|
||||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
|
||||||
Game.shuffle(pool, r)
|
|
||||||
deck = Game.build_opponent_deck(r, [pool[0], pool[1]])
|
|
||||||
players.append(Game.Player.new(names[id], deck, true, Game.START_LIFE))
|
|
||||||
for i in n_ai:
|
|
||||||
players.append(Game.random_opponent(r, i + 1))
|
|
||||||
game = Game.new(players[0], players.slice(1), r)
|
|
||||||
game.start()
|
|
||||||
for id in ids:
|
|
||||||
seats[id] = game.players.find(players[ids.find(id)])
|
|
||||||
my_seat = seats[1]
|
|
||||||
for id in ids:
|
|
||||||
if id != 1:
|
|
||||||
start_game.rpc_id(id, seats[id])
|
|
||||||
for id in spectators:
|
|
||||||
start_game.rpc_id(id, -1)
|
|
||||||
_update_successor()
|
|
||||||
started.emit(my_seat)
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
|
||||||
func start_game(seat: int) -> void:
|
|
||||||
my_seat = seat
|
|
||||||
started.emit(seat)
|
|
||||||
|
|
||||||
|
|
||||||
## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes,
|
|
||||||
## der Nachfolger zusätzlich den vollen Spielstand).
|
|
||||||
func broadcast() -> void:
|
|
||||||
if not is_host or game == null:
|
|
||||||
return
|
|
||||||
for id in seats:
|
|
||||||
if id != 1:
|
|
||||||
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
|
|
||||||
for id in spectators:
|
|
||||||
receive_snapshot.rpc_id(id, game.snapshot(-2))
|
|
||||||
var succ := _successor_id()
|
|
||||||
if succ > 0:
|
|
||||||
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "reliable")
|
|
||||||
func receive_snapshot(snap: Dictionary) -> void:
|
|
||||||
last_winner = str(snap.get("winner", ""))
|
|
||||||
snapshot_received.emit(snap)
|
|
||||||
|
|
||||||
|
|
||||||
## Mitspieler: Aktion an den Host schicken.
|
|
||||||
func send_action(action: Dictionary) -> void:
|
|
||||||
if is_host:
|
|
||||||
_apply(1, action)
|
|
||||||
else:
|
|
||||||
request_action.rpc_id(1, action)
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("any_peer", "reliable")
|
|
||||||
func request_action(action: Dictionary) -> void:
|
|
||||||
if not is_host:
|
|
||||||
return
|
|
||||||
_apply(multiplayer.get_remote_sender_id(), action)
|
|
||||||
|
|
||||||
|
|
||||||
func _apply(id: int, action: Dictionary) -> void:
|
|
||||||
if game == null or not seats.has(id):
|
|
||||||
return
|
|
||||||
var p: Game.Player = game.players[seats[id]]
|
|
||||||
if game.current() != p or game.winner != "":
|
|
||||||
return
|
|
||||||
match str(action.get("type", "")):
|
|
||||||
"play":
|
|
||||||
var idx := int(action.get("hand", -1))
|
|
||||||
if idx < 0 or idx >= p.hand.size():
|
|
||||||
return
|
|
||||||
game.play_card(p, p.hand[idx], resolve_ref(action.get("target")))
|
|
||||||
"attack":
|
|
||||||
var cr := game.find_creature(int(action.get("uid", -1)))
|
|
||||||
var t = resolve_ref(action.get("target"))
|
|
||||||
if cr != null and t != null:
|
|
||||||
game.attack(cr, t)
|
|
||||||
"mulligan":
|
|
||||||
var cards := []
|
|
||||||
for i in action.get("hand", []):
|
|
||||||
if int(i) >= 0 and int(i) < p.hand.size():
|
|
||||||
cards.append(p.hand[int(i)])
|
|
||||||
game.mulligan(p, cards)
|
|
||||||
"end":
|
|
||||||
game.end_turn()
|
|
||||||
_:
|
|
||||||
return
|
|
||||||
action_applied.emit()
|
|
||||||
|
|
||||||
|
|
||||||
func resolve_ref(ref):
|
|
||||||
if ref == null or not (ref is Dictionary):
|
|
||||||
return null
|
|
||||||
if ref.get("kind") == "creature":
|
|
||||||
return game.find_creature(int(ref.get("uid", -1)))
|
|
||||||
var i := int(ref.get("index", -1))
|
|
||||||
if i >= 0 and i < game.players.size():
|
|
||||||
return game.players[i]
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
static func ref_dict(target) -> Dictionary:
|
|
||||||
if target is Game.Creature:
|
|
||||||
return {"kind": "creature", "uid": target.uid}
|
|
||||||
return {"kind": "player", "index": target.order}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://drhh23lblvq8o
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
class_name NetState
|
|
||||||
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln).
|
|
||||||
|
|
||||||
|
|
||||||
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl.
|
|
||||||
## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer).
|
|
||||||
static func snapshot(g: Game, viewer: int) -> Dictionary:
|
|
||||||
var ps := []
|
|
||||||
for i in g.players.size():
|
|
||||||
var p: Game.Player = g.players[i]
|
|
||||||
var mine := i == viewer or viewer == -1
|
|
||||||
var board := []
|
|
||||||
for c in p.board:
|
|
||||||
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
|
|
||||||
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
|
|
||||||
"ready": c.ready, "attacks_left": c.attacks_left})
|
|
||||||
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
|
|
||||||
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
|
|
||||||
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
|
|
||||||
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
|
|
||||||
"deck": p.deck.map(func(c): return c.id) if viewer == -1 else p.deck.size(),
|
|
||||||
"traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
|
|
||||||
"board": board})
|
|
||||||
var evs := []
|
|
||||||
for ev in g.events:
|
|
||||||
var e: Dictionary = ev.duplicate()
|
|
||||||
if e.has("card"):
|
|
||||||
e["card"] = e.card.id
|
|
||||||
evs.append(e)
|
|
||||||
return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx,
|
|
||||||
"winner": g.winner, "winner_player": g.players.find(g.winner_player) if g.winner_player != null else -1,
|
|
||||||
"next_uid": g.next_uid, "log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs}
|
|
||||||
|
|
||||||
|
|
||||||
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
|
|
||||||
static func from_snapshot(d: Dictionary) -> Game:
|
|
||||||
var placeholder: Cards.Card = Cards.all()[0]
|
|
||||||
var list := []
|
|
||||||
for pd in d.players:
|
|
||||||
var p := Game.Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title)
|
|
||||||
p.max_life = int(pd.max_life)
|
|
||||||
p.energy = int(pd.energy)
|
|
||||||
p.max_energy = int(pd.max_energy)
|
|
||||||
p.fatigue = int(pd.fatigue)
|
|
||||||
p.start_bonus = int(pd.start_bonus)
|
|
||||||
p.order = int(pd.order)
|
|
||||||
p.perks = Array(pd.perks)
|
|
||||||
p.turns = int(pd.turns)
|
|
||||||
p.mulligan_done = bool(pd.mulligan_done)
|
|
||||||
if pd.hand is Array:
|
|
||||||
for cid in pd.hand:
|
|
||||||
p.hand.append(Cards.by_id(cid))
|
|
||||||
else:
|
|
||||||
for i in int(pd.hand):
|
|
||||||
p.hand.append(placeholder)
|
|
||||||
if pd.deck is Array:
|
|
||||||
for cid in pd.deck:
|
|
||||||
p.deck.append(Cards.by_id(cid))
|
|
||||||
else:
|
|
||||||
for i in int(pd.deck):
|
|
||||||
p.deck.append(placeholder)
|
|
||||||
if pd.traps is Array:
|
|
||||||
for cid in pd.traps:
|
|
||||||
p.traps.append(Cards.by_id(cid))
|
|
||||||
else:
|
|
||||||
for i in int(pd.traps):
|
|
||||||
p.traps.append(placeholder)
|
|
||||||
for cd in pd.board:
|
|
||||||
var c := Game.Creature.new(Cards.by_id(cd.card), p, int(cd.uid))
|
|
||||||
c.attack = int(cd.attack)
|
|
||||||
c.resonance = int(cd.resonance)
|
|
||||||
c.health = int(cd.health)
|
|
||||||
c.max_health = int(cd.max_health)
|
|
||||||
c.shield = bool(cd.shield)
|
|
||||||
c.frozen = bool(cd.frozen)
|
|
||||||
c.ready = bool(cd.ready)
|
|
||||||
c.attacks_left = int(cd.attacks_left)
|
|
||||||
p.board.append(c)
|
|
||||||
list.append(p)
|
|
||||||
var hi := int(d.human)
|
|
||||||
var g := Game.new(list[hi], list.slice(0, hi) + list.slice(hi + 1), RandomNumberGenerator.new())
|
|
||||||
g.players = list
|
|
||||||
g.turn = int(d.turn)
|
|
||||||
g.current_idx = int(d.current_idx)
|
|
||||||
g.winner = d.winner
|
|
||||||
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
|
|
||||||
g.next_uid = int(d.next_uid)
|
|
||||||
for line in d.log:
|
|
||||||
g.log.append(str(line))
|
|
||||||
for ev in d.events:
|
|
||||||
var e: Dictionary = ev.duplicate()
|
|
||||||
if e.has("card"):
|
|
||||||
e["card"] = Cards.by_id(e.card)
|
|
||||||
for key in ["player", "uid", "count", "amount"]:
|
|
||||||
if e.has(key):
|
|
||||||
e[key] = int(e[key])
|
|
||||||
for key in ["target", "attacker", "source"]:
|
|
||||||
if e.has(key):
|
|
||||||
var r: Dictionary = e[key]
|
|
||||||
if r.has("uid"):
|
|
||||||
r["uid"] = int(r.uid)
|
|
||||||
if r.has("index"):
|
|
||||||
r["index"] = int(r.index)
|
|
||||||
g.events.append(e)
|
|
||||||
return g
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://c3ao1mpgbrter
|
|
||||||
+327
-464
@@ -1,547 +1,410 @@
|
|||||||
class_name Profile
|
class_name Profile
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
## Spielstand: Sammlung, Münzen, Decks, Staub, Booster, Kartenstatistik (für Deckstärke und Schwierigkeit), Tagesaufgabe, Einstellungen.
|
## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen.
|
||||||
|
## Man startet mit allen neutralen Karten (Stapel: 30 nach Manakurve); die Elemente schaltet die Kampagne frei (je Kapitel einen
|
||||||
|
## Booster), die Werkstatt bringt den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe
|
||||||
|
## gibt (Cards.Card.leveled); kommt eine seltenere Version, ersetzt sie die alte, die zu Staub wird.
|
||||||
|
|
||||||
const SAVE_VERSION := 5
|
const SAVE_VERSION := 8
|
||||||
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
|
const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
|
||||||
const SAVE_PATH := "user://spielstand.json"
|
const SAVE_PATH := "user://spielstand.json"
|
||||||
const BOOSTER_COST := 100
|
const BOOSTER_COST := 100 # neutraler Booster
|
||||||
const ELEMENT_BOOSTER_COST := 120 # nur Karten eines Elements (auch seine Kombos)
|
const ELEMENT_BOOSTER_COST := 200 # Booster eines Elements (ab seinem Kapitel)
|
||||||
const CODE_PREFIX := "DINGER1:" # Deck-Code zum Teilen
|
|
||||||
const BOOSTER_SIZE := 5
|
const BOOSTER_SIZE := 5
|
||||||
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
|
const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0}
|
||||||
const START_COINS := 150
|
const START_COINS := 150
|
||||||
const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300}
|
# Münzen nach dem Kampf: je eigener Zug, je ausgespielte Karte und je Zug unter FAST_TURN_SEC
|
||||||
const REWARD_LOSS := 50
|
# Sekunden; ein Sieg verdoppelt alles, die Schwierigkeit multipliziert (siehe battle_coins).
|
||||||
const DECK_SLOTS := 5
|
const COINS_TURN := 5
|
||||||
# Schwierigkeit: Faktor auf die Zielstärke der Gegnerdecks, Belohnung und KI-Tiefe.
|
const COINS_CARD := 5
|
||||||
|
const COINS_FAST := 5
|
||||||
|
const FAST_TURN_SEC := 15.0
|
||||||
|
# Schwierigkeit im Einzelspieler: Stufe der Gegnerkarten relativ zu deiner Deckstärke (level),
|
||||||
|
# KI (smart: Vorausschau, sonst gierig), ein zufälliges Merkmal (perk) und der Münzfaktor.
|
||||||
const DIFFICULTIES := {
|
const DIFFICULTIES := {
|
||||||
"leicht": {"name": "Leicht", "factor": 0.9, "reward": 0.75, "smart": false, "tier": "leicht"},
|
"leicht": {"name": "Leicht", "level": -1, "smart": false, "perk": false, "reward": 1.0},
|
||||||
"normal": {"name": "Normal", "factor": 1.0, "reward": 1.0, "smart": true, "tier": ""},
|
"normal": {"name": "Normal", "level": 0, "smart": true, "perk": false, "reward": 1.5},
|
||||||
"schwer": {"name": "Schwer", "factor": 1.2, "reward": 1.5, "smart": true, "tier": "schwer"},
|
"schwer": {"name": "Schwer", "level": 1, "smart": true, "perk": true, "reward": 2.0},
|
||||||
}
|
}
|
||||||
# Sammlung: Zerlegen bringt Staub, Herstellen kostet Staub.
|
# Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub.
|
||||||
const DUST_DISMANTLE := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600}
|
const DUST_DISMANTLE := {"Common": 50, "Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
|
||||||
const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200}
|
const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
|
||||||
|
|
||||||
const DAILY_TASKS := [
|
|
||||||
{"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80},
|
|
||||||
{"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60},
|
|
||||||
{"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40},
|
|
||||||
{"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50},
|
|
||||||
{"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50},
|
|
||||||
{"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60},
|
|
||||||
{"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50},
|
|
||||||
{"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120},
|
|
||||||
{"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90},
|
|
||||||
{"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90},
|
|
||||||
{"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90},
|
|
||||||
{"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90},
|
|
||||||
]
|
|
||||||
|
|
||||||
static var today_override := "" # für Tests
|
|
||||||
|
|
||||||
var coins := 0
|
var coins := 0
|
||||||
var dust := 0
|
var dust := 0
|
||||||
var collection := {} # id -> Anzahl
|
var xp := 0 # Erfahrung: alle je verdienten Münzen (Skills.level_for)
|
||||||
var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids}
|
var skills := {} # Skill-ID -> gelernte Stufe (Skills.SKILLS)
|
||||||
var active_deck := 0
|
var collection := {} # eigene Karten: id -> Seltenheitsstufe (Index in Cards.RARITIES)
|
||||||
var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}}
|
var deck: Array = [] # Karten-IDs des eigenen Stapels (Game.DECK_MIN bis DECK_MAX)
|
||||||
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
|
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
|
||||||
var daily := {"date": "", "task": "", "progress": 0, "done": false}
|
var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
|
||||||
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
|
var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
|
||||||
var notices: Array[String] = [] # Meldungen für die Oberfläche (Tagesaufgabe, Fortschritt)
|
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
|
||||||
var card_stats := {} # id -> {"played", "games", "wins"}
|
|
||||||
var booster_since_mythic := 0
|
|
||||||
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
|
|
||||||
var records := {"streak": 0, "best_streak": 0, "fastest_win": 0, "loss_streak": 0, "margins": []}
|
|
||||||
|
|
||||||
## 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: alle neutralen Karten in der Sammlung, 30 davon nach Manakurve im Stapel.
|
||||||
static func starter() -> Profile:
|
static func starter() -> Profile:
|
||||||
var p := Profile.new()
|
var p := Profile.new()
|
||||||
p.coins = START_COINS
|
p.coins = START_COINS
|
||||||
for c in Cards.of_rarity("gewöhnlich"):
|
p.give_start()
|
||||||
if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"):
|
return p
|
||||||
p.collection[c.id] = 2
|
|
||||||
# Zwei seltene Karten zum Start, damit es etwas zum Aufbauen gibt
|
|
||||||
p.collection["f_drache"] = 1
|
## Alle neutralen Karten in Common werden die Sammlung, ein Kurvenstapel daraus der Stapel.
|
||||||
p.collection["e_baum"] = 1
|
func give_start() -> void:
|
||||||
var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche",
|
collection = {}
|
||||||
"e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"]
|
for id in DeckGen.element_deck(Cards.NEUTRAL):
|
||||||
for cid in starter_ids:
|
collection[id] = 0
|
||||||
p.deck.append(cid)
|
deck = DeckGen.curve_deck(Cards.NEUTRAL, Game.make_rng())
|
||||||
p.deck.append(cid)
|
|
||||||
p.deck[p.deck.find("f_asche")] = "f_drache"
|
|
||||||
p.deck[p.deck.find("e_troll")] = "e_baum"
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
|
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
|
||||||
static func load_or_new(path: String = SAVE_PATH) -> Profile:
|
static func load_or_new(path: String = SAVE_PATH) -> Profile:
|
||||||
for candidate in [path, path + ".bak"]:
|
for candidate in [path, path + ".bak"]:
|
||||||
if FileAccess.file_exists(candidate):
|
if FileAccess.file_exists(candidate):
|
||||||
var json := JSON.new()
|
var json := JSON.new()
|
||||||
if json.parse(FileAccess.get_file_as_string(candidate)) == OK and json.data is Dictionary:
|
if json.parse(FileAccess.get_file_as_string(candidate)) == OK and json.data is Dictionary:
|
||||||
return from_dict(json.data)
|
return from_dict(json.data)
|
||||||
if FileAccess.file_exists(path):
|
if FileAccess.file_exists(path):
|
||||||
DirAccess.rename_absolute(ProjectSettings.globalize_path(path), ProjectSettings.globalize_path(path) + ".corrupt")
|
DirAccess.rename_absolute(ProjectSettings.globalize_path(path), ProjectSettings.globalize_path(path) + ".corrupt")
|
||||||
var p := starter()
|
var p := starter()
|
||||||
p.save(path)
|
p.save(path)
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
## Liest jede Spielstand-Version; fehlende Felder bekommen Standardwerte (Migration).
|
## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und
|
||||||
|
## Einstellungen und bekommen die Startsammlung.
|
||||||
static func from_dict(d: Dictionary) -> Profile:
|
static func from_dict(d: Dictionary) -> Profile:
|
||||||
var p := Profile.new()
|
var p := Profile.new()
|
||||||
var version := int(d.get("version", 1))
|
p.coins = int(d.get("coins", 0))
|
||||||
p.coins = int(d.get("coins", 0))
|
p.dust = int(d.get("dust", 0))
|
||||||
p.dust = int(d.get("dust", 0))
|
p.xp = int(d.get("xp", 0))
|
||||||
var coll = d.get("collection", {})
|
var sk = d.get("skills", {})
|
||||||
for cid in coll:
|
for id in sk:
|
||||||
if Cards.has_id(cid):
|
if Skills.SKILLS.has(id):
|
||||||
p.collection[cid] = int(coll[cid])
|
p.skills[id] = clampi(int(sk[id]), 0, int(Skills.SKILLS[id].max))
|
||||||
if version >= 3:
|
p.campaign_stage = int(d.get("campaign_stage", 0))
|
||||||
var list = d.get("decks", [])
|
if int(d.get("version", 1)) >= SAVE_VERSION:
|
||||||
for i in mini(list.size(), DECK_SLOTS):
|
var coll = d.get("collection", {})
|
||||||
p.decks[i].name = str(list[i].get("name", "Deck %d" % (i + 1)))
|
for cid in coll:
|
||||||
p.decks[i].cards = []
|
if Cards.has_id(cid):
|
||||||
for cid in list[i].get("cards", []):
|
p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
|
||||||
if Cards.has_id(cid):
|
var ids := []
|
||||||
p.decks[i].cards.append(cid)
|
for cid in d.get("deck", []):
|
||||||
p.active_deck = clampi(int(d.get("active_deck", 0)), 0, DECK_SLOTS - 1)
|
if p.collection.has(cid) and not (cid in ids):
|
||||||
else:
|
ids.append(cid)
|
||||||
for cid in d.get("deck", []):
|
p.deck = ids.slice(0, Game.DECK_MAX)
|
||||||
if Cards.has_id(cid):
|
if p.collection.is_empty():
|
||||||
p.decks[0].cards.append(cid)
|
p.give_start()
|
||||||
if version >= 2:
|
var se = d.get("settings", {})
|
||||||
var st = d.get("stats", {})
|
p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)),
|
||||||
p.stats.games = int(st.get("games", 0))
|
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
|
||||||
p.stats.wins = int(st.get("wins", 0))
|
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
|
||||||
p.stats.boosters = int(st.get("boosters", 0))
|
"big_font": bool(se.get("big_font", false))}
|
||||||
for k in st.get("wins_by_opp", {}):
|
if not DIFFICULTIES.has(p.settings.difficulty):
|
||||||
p.stats.wins_by_opp[str(k)] = int(st.wins_by_opp[k])
|
p.settings.difficulty = "normal"
|
||||||
p.campaign_stage = int(d.get("campaign_stage", 0))
|
p.perk = str(d.get("perk", ""))
|
||||||
var dl = d.get("daily", {})
|
if not Game.PERKS.has(p.perk):
|
||||||
p.daily = {"date": str(dl.get("date", "")), "task": str(dl.get("task", "")),
|
p.perk = ""
|
||||||
"progress": int(dl.get("progress", 0)), "done": bool(dl.get("done", false))}
|
return p
|
||||||
var se = d.get("settings", {})
|
|
||||||
p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)),
|
|
||||||
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
|
|
||||||
"language": str(se.get("language", "de")), "difficulty": str(se.get("difficulty", "normal")),
|
|
||||||
"big_font": bool(se.get("big_font", false))}
|
|
||||||
if not DIFFICULTIES.has(p.settings.difficulty):
|
|
||||||
p.settings.difficulty = "normal"
|
|
||||||
if version >= 4:
|
|
||||||
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))
|
|
||||||
if version >= 5:
|
|
||||||
p.perk = str(d.get("perk", ""))
|
|
||||||
if not Game.PERKS.has(p.perk):
|
|
||||||
p.perk = ""
|
|
||||||
var rc = d.get("records", {})
|
|
||||||
p.records = {"streak": int(rc.get("streak", 0)), "best_streak": int(rc.get("best_streak", 0)),
|
|
||||||
"fastest_win": int(rc.get("fastest_win", 0)), "loss_streak": int(rc.get("loss_streak", 0)),
|
|
||||||
"margins": Array(rc.get("margins", [])).map(func(m): return int(m))}
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
func to_dict() -> Dictionary:
|
func to_dict() -> Dictionary:
|
||||||
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection,
|
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "xp": xp, "skills": skills, "collection": collection,
|
||||||
"decks": decks, "active_deck": active_deck,
|
"deck": deck, "campaign_stage": campaign_stage, "settings": settings, "perk": perk}
|
||||||
"stats": stats, "campaign_stage": campaign_stage,
|
|
||||||
"daily": daily, "settings": settings, "card_stats": card_stats,
|
|
||||||
"booster_since_mythic": booster_since_mythic,
|
|
||||||
"perk": perk, "records": records}
|
|
||||||
|
|
||||||
|
|
||||||
## Speichert und sichert den vorherigen Stand als .bak.
|
## Speichert und sichert den vorherigen Stand als .bak.
|
||||||
func save(path: String = SAVE_PATH) -> void:
|
func save(path: String = SAVE_PATH) -> void:
|
||||||
var abs := ProjectSettings.globalize_path(path)
|
var abs := ProjectSettings.globalize_path(path)
|
||||||
if FileAccess.file_exists(path):
|
if FileAccess.file_exists(path):
|
||||||
DirAccess.copy_absolute(abs, abs + ".bak")
|
DirAccess.copy_absolute(abs, abs + ".bak")
|
||||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||||
f.store_string(JSON.stringify(to_dict(), "\t"))
|
f.store_string(JSON.stringify(to_dict(), "\t"))
|
||||||
f.close()
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
func take_notices() -> Array[String]:
|
func take_notices() -> Array[String]:
|
||||||
var out := notices.duplicate()
|
var out := notices.duplicate()
|
||||||
notices.clear()
|
notices.clear()
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
# --- Decks und Sammlung -----------------------------------------------------------
|
# --- Sammlung und Stapel -----------------------------------------------------------
|
||||||
|
|
||||||
func deck_cards(slot: int = -1) -> Array:
|
func owns(cid: String) -> bool:
|
||||||
var ids: Array = decks[slot if slot >= 0 else active_deck].cards
|
return collection.has(cid)
|
||||||
var out := []
|
|
||||||
for cid in ids:
|
|
||||||
out.append(Cards.by_id(cid))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
func deck_valid(slot: int = -1) -> Array:
|
func level(cid: String) -> int:
|
||||||
return Game.validate_deck(deck_cards(slot), collection)
|
return int(collection.get(cid, 0))
|
||||||
|
|
||||||
|
|
||||||
func deck_name(slot: int = -1) -> String:
|
func rarity(cid: String) -> String:
|
||||||
return decks[slot if slot >= 0 else active_deck].name
|
return Cards.RARITIES[level(cid)]
|
||||||
|
|
||||||
|
|
||||||
func rename_deck(slot: int, name: String) -> String:
|
## Karten-ID -> Stufe für den Kampf (nur aufgewertete Karten, siehe Game.Player.looks).
|
||||||
var n := name.strip_edges()
|
func looks() -> Dictionary:
|
||||||
if n.is_empty() or n.length() > 20:
|
var out := {}
|
||||||
return "Name muss 1 bis 20 Zeichen haben"
|
for cid in collection:
|
||||||
decks[slot].name = n
|
if int(collection[cid]) > 0:
|
||||||
return ""
|
out[cid] = int(collection[cid])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve.
|
## Der Stapel für den Kampf: der gewählte, mit weiteren eigenen Karten bis DECK_MIN aufgefüllt,
|
||||||
## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt.
|
## falls er zu klein ist (Karten können durch Zerlegen wegfallen).
|
||||||
func autofill_deck(slot: int) -> String:
|
func deck_cards() -> Array:
|
||||||
var cards: Array = decks[slot].cards
|
return DeckGen.fill(deck.filter(func(id): return collection.has(id)), collection.keys(), Game.DECK_MIN, collection)
|
||||||
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]
|
|
||||||
|
|
||||||
|
|
||||||
func owned_cards() -> Array:
|
## Karte in den Stapel oder heraus. Gibt "" zurück oder eine Fehlermeldung.
|
||||||
return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0)
|
func toggle_deck(cid: String) -> String:
|
||||||
|
if cid in deck:
|
||||||
|
deck.erase(cid)
|
||||||
|
return ""
|
||||||
|
if not owns(cid):
|
||||||
|
return "Karte nicht in der Sammlung"
|
||||||
|
if deck.size() >= Game.DECK_MAX:
|
||||||
|
return "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX
|
||||||
|
deck.append(cid)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
func distinct_owned() -> int:
|
## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
|
||||||
var n := 0
|
func upgrade(cid: String) -> String:
|
||||||
for cid in collection:
|
if not owns(cid):
|
||||||
if collection[cid] > 0:
|
return "Karte nicht in der Sammlung"
|
||||||
n += 1
|
var lvl := level(cid)
|
||||||
return n
|
if lvl >= Cards.RARITIES.size() - 1:
|
||||||
|
return "Schon auf der höchsten Stufe"
|
||||||
|
var cost: int = DUST_UPGRADE[Cards.RARITIES[lvl + 1]]
|
||||||
|
if dust < cost:
|
||||||
|
return "Nicht genug Staub (%d nötig)" % cost
|
||||||
|
dust -= cost
|
||||||
|
collection[cid] = lvl + 1
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# --- Zerlegen und Herstellen ------------------------------------------------------
|
## Entzaubern: eine Stufe abgeben bringt Staub; eine Common-Karte verlässt die Sammlung.
|
||||||
|
## Gibt "" zurück oder eine Fehlermeldung.
|
||||||
## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung.
|
func dismantle(cid: String) -> String:
|
||||||
func dismantle(cid: String, n: int = 1) -> String:
|
if not owns(cid):
|
||||||
var owned: int = collection.get(cid, 0)
|
return "Karte nicht in der Sammlung"
|
||||||
if owned < n:
|
var lvl := level(cid)
|
||||||
return "Nicht genug Kopien"
|
dust += dust_gain(DUST_DISMANTLE[Cards.RARITIES[lvl]])
|
||||||
var needed := 0
|
if lvl == 0:
|
||||||
for d in decks:
|
collection.erase(cid)
|
||||||
needed = maxi(needed, d.cards.count(cid))
|
deck.erase(cid)
|
||||||
if owned - n < needed:
|
else:
|
||||||
return "Wird in einem Deck gebraucht"
|
collection[cid] = lvl - 1
|
||||||
collection[cid] = owned - n
|
return ""
|
||||||
dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung.
|
## Herstellen: eine fehlende Karte für Staub als Common. Gibt "" zurück oder eine Fehlermeldung.
|
||||||
func craft(cid: String) -> String:
|
func craft(cid: String) -> String:
|
||||||
var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity]
|
if owns(cid):
|
||||||
if dust < cost:
|
return "Karte schon in der Sammlung"
|
||||||
return "Nicht genug Staub (%d nötig)" % cost
|
if dust < CRAFT_COST:
|
||||||
dust -= cost
|
return "Nicht genug Staub (%d nötig)" % CRAFT_COST
|
||||||
collection[cid] = collection.get(cid, 0) + 1
|
dust -= CRAFT_COST
|
||||||
daily_event("craft")
|
collection[cid] = 0
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück.
|
|
||||||
func dismantle_extras() -> int:
|
|
||||||
var before := dust
|
|
||||||
for cid in collection.keys():
|
|
||||||
var extra: int = collection[cid] - Game.MAX_COPIES
|
|
||||||
if extra > 0:
|
|
||||||
dismantle(cid, extra)
|
|
||||||
return dust - before
|
|
||||||
|
|
||||||
|
|
||||||
# --- Booster ----------------------------------------------------------------------
|
# --- Booster ----------------------------------------------------------------------
|
||||||
|
|
||||||
## Kartenpool einer Seltenheit, bei Elementboostern nur Karten mit diesem Element.
|
## Preis eines Boosters: neutral BOOSTER_COST, je Element ELEMENT_BOOSTER_COST.
|
||||||
static func booster_pool(rarity: String, element: String) -> Array:
|
static func booster_cost(element: String) -> int:
|
||||||
var pool := Cards.of_rarity(rarity)
|
return BOOSTER_COST if element == Cards.NEUTRAL else ELEMENT_BOOSTER_COST
|
||||||
if element != "":
|
|
||||||
pool = pool.filter(func(c): return element in c.elements)
|
|
||||||
return pool
|
|
||||||
|
|
||||||
|
|
||||||
static func roll_booster(r: RandomNumberGenerator, element: String = "") -> Array:
|
## Der Booster eines Elements ist zu haben, sobald sein Kampagnenkapitel offen ist (neutral immer).
|
||||||
var rarities := RARITY_WEIGHTS.keys()
|
func booster_unlocked(element: String) -> bool:
|
||||||
var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
|
return campaign_stage >= Campaign.STAGES_PER_CHAPTER * Campaign.CHAPTER_ELEMENTS.find(element)
|
||||||
var cards := []
|
|
||||||
for i in BOOSTER_SIZE:
|
|
||||||
var rarity: String = rarities[r.rand_weighted(weights)]
|
|
||||||
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
|
|
||||||
and cards.all(func(c): return c.rarity == "gewöhnlich"):
|
|
||||||
rarity = "selten" # mindestens eine seltene Karte pro Booster
|
|
||||||
var pool := booster_pool(rarity, element)
|
|
||||||
cards.append(pool[r.randi_range(0, pool.size() - 1)])
|
|
||||||
return cards
|
|
||||||
|
|
||||||
|
|
||||||
## Deck-Code zum Teilen: "DINGER1:id*anzahl,…" (sortiert, daher vergleichbar).
|
## Fünf Karten des Elements mit gewürfelter Seltenheit; luck (Packglück) verschiebt die Chancen.
|
||||||
static func deck_code(card_ids: Array) -> String:
|
static func roll_booster(r: RandomNumberGenerator, element: String, luck: int = 0) -> Array:
|
||||||
var counts := {}
|
var rarities := RARITY_WEIGHTS.keys()
|
||||||
for cid in card_ids:
|
var weights := Skills.pack_weights(luck)
|
||||||
counts[cid] = counts.get(cid, 0) + 1
|
var pool := Cards.all().filter(func(c): return c.elements[0] == element)
|
||||||
var keys := counts.keys()
|
var out := []
|
||||||
keys.sort()
|
for i in BOOSTER_SIZE:
|
||||||
return CODE_PREFIX + ",".join(keys.map(func(k): return "%s*%d" % [k, counts[k]]))
|
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
|
||||||
|
|
||||||
|
|
||||||
## Karten-IDs aus einem Deck-Code; leer bei ungültigem Code.
|
## Öffnet einen Booster des Elements. Neue Karten kommen in Sammlung und Stapel (solange Platz ist).
|
||||||
static func parse_deck_code(code: String) -> Array:
|
## Von jeder Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine
|
||||||
var s := code.strip_edges()
|
## gleiche oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn der Booster
|
||||||
if not s.begins_with(CODE_PREFIX):
|
## noch gesperrt ist oder die Münzen nicht reichen.
|
||||||
return []
|
func open_booster(element: String, r: RandomNumberGenerator = null) -> Array:
|
||||||
var out := []
|
return open_boosters(element, 1, r)
|
||||||
for part in s.substr(CODE_PREFIX.length()).split(",", false):
|
|
||||||
var kv: PackedStringArray = part.strip_edges().split("*")
|
|
||||||
if kv.size() != 2 or not Cards.has_id(kv[0]) or not kv[1].is_valid_int():
|
|
||||||
return []
|
|
||||||
for i in clampi(int(kv[1]), 0, Game.MAX_COPIES):
|
|
||||||
out.append(kv[0])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
## Öffnet einen Booster (element: Elementbooster). Gibt die Karten zurück, leer wenn die Münzen nicht reichen.
|
## Öffnet n Booster des Elements auf einmal; leer, wenn gesperrt oder die Münzen für alle nicht reichen.
|
||||||
func open_booster(r: RandomNumberGenerator = null, element: String = "") -> Array:
|
func open_boosters(element: String, n: int, r: RandomNumberGenerator = null) -> Array:
|
||||||
var cost := ELEMENT_BOOSTER_COST if element != "" else BOOSTER_COST
|
if not booster_unlocked(element) or coins < pack_price(element) * n:
|
||||||
if coins < cost:
|
return []
|
||||||
return []
|
if r == null:
|
||||||
if r == null:
|
r = Game.make_rng()
|
||||||
r = Game.make_rng()
|
var out := []
|
||||||
coins -= cost
|
for i in n:
|
||||||
var cards := roll_booster(r, element)
|
out += _open_one(element, r)
|
||||||
booster_since_mythic += 1
|
return out
|
||||||
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch")
|
|
||||||
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic:
|
|
||||||
var pool := booster_pool("mythisch", element)
|
|
||||||
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")
|
|
||||||
return cards
|
|
||||||
|
|
||||||
|
|
||||||
# --- Kampfergebnis, Tagesaufgabe -------------------------------------------
|
func _open_one(element: String, r: RandomNumberGenerator) -> Array:
|
||||||
|
coins -= pack_price(element)
|
||||||
## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück.
|
var out := roll_booster(r, element, skill("packglueck"))
|
||||||
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
|
for res in out:
|
||||||
## turns: Dauer der Partie für die Bestenliste.
|
var lvl: int = Cards.RARITIES.find(res.rarity)
|
||||||
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
|
res["new"] = not owns(res.card.id)
|
||||||
turns: int = 0, reward_factor: float = 1.0, remaining_life: int = -1) -> int:
|
res["upgraded"] = false
|
||||||
stats.games += 1
|
res["dust"] = 0
|
||||||
records.margins.append(maxi(remaining_life, 0) if won else 0)
|
if res.new:
|
||||||
while records.margins.size() > 5:
|
collection[res.card.id] = lvl
|
||||||
records.margins.pop_front()
|
if deck.size() < Game.DECK_MAX:
|
||||||
if won:
|
deck.append(res.card.id)
|
||||||
records.streak += 1
|
elif lvl > level(res.card.id):
|
||||||
records.loss_streak = 0
|
res["upgraded"] = true
|
||||||
records.best_streak = maxi(records.best_streak, records.streak)
|
res["dust"] = dust_gain(DUST_DISMANTLE[rarity(res.card.id)])
|
||||||
if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win):
|
collection[res.card.id] = lvl
|
||||||
records.fastest_win = turns
|
else:
|
||||||
else:
|
res["dust"] = dust_gain(DUST_DISMANTLE[res.rarity])
|
||||||
records.streak = 0
|
dust += res.dust
|
||||||
records.loss_streak += 1
|
return out
|
||||||
var tip := suggest_difficulty()
|
|
||||||
if tip != "" and stage < 0:
|
|
||||||
notices.append("Tipp: %s. Schwierigkeit %s?" % [
|
|
||||||
"%d Siege in Folge" % records.streak if won else "%d Niederlagen in Folge" % records.loss_streak, DIFFICULTIES[tip].name])
|
|
||||||
for cid in played_ids:
|
|
||||||
_stat(cid).played += 1
|
|
||||||
var seen := {}
|
|
||||||
for c in deck_used:
|
|
||||||
if not seen.has(c.id):
|
|
||||||
seen[c.id] = true
|
|
||||||
_stat(c.id).games += 1
|
|
||||||
if won:
|
|
||||||
_stat(c.id).wins += 1
|
|
||||||
var reward := REWARD_LOSS
|
|
||||||
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 = int(round(REWARD_WIN[n_opp] * 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)
|
|
||||||
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)
|
|
||||||
return reward
|
|
||||||
|
|
||||||
|
|
||||||
func _stat(cid: String) -> Dictionary:
|
# --- Kampf und Kampagne -----------------------------------------------------------
|
||||||
if not card_stats.has(cid):
|
|
||||||
card_stats[cid] = {"played": 0, "games": 0, "wins": 0}
|
## Münzen eines Kampfes: COINS_TURN je eigenem Zug, COINS_CARD je ausgespielter Karte, COINS_FAST
|
||||||
return card_stats[cid]
|
## je schnellem Zug; ein Sieg verdoppelt, factor ist der Münzfaktor der Schwierigkeit.
|
||||||
|
static func battle_coins(won: bool, turns: int, cards: int, fast: int, factor: float = 1.0) -> int:
|
||||||
|
var pts := COINS_TURN * turns + COINS_CARD * cards + COINS_FAST * fast
|
||||||
|
if won:
|
||||||
|
pts *= 2
|
||||||
|
return int(round(pts * factor))
|
||||||
|
|
||||||
|
|
||||||
|
## Verbucht ein Kampfergebnis mit den verdienten Münzen (battle_coins, mal Münzsegen) und gibt die
|
||||||
|
## Gutschrift zurück; sie zählt auch als Erfahrung, ein neues Level meldet sich. stage >= 0:
|
||||||
|
## Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei und bringt ihre Belohnung obendrauf.
|
||||||
|
func record_battle(won: bool, earned: int, stage: int = -1) -> int:
|
||||||
|
var reward := int(round(earned * coin_factor()))
|
||||||
|
if won and stage >= 0 and stage == campaign_stage:
|
||||||
|
campaign_stage += 1
|
||||||
|
reward += Campaign.stage(stage).reward
|
||||||
|
notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name)
|
||||||
|
coins += reward
|
||||||
|
var before := player_level()
|
||||||
|
xp += reward
|
||||||
|
if player_level() > before:
|
||||||
|
var gained := player_level() - before
|
||||||
|
notices.append(("Level %d erreicht: +%d Skillpunkt" if gained == 1 else "Level %d erreicht: +%d Skillpunkte") % [player_level(), gained])
|
||||||
|
return reward
|
||||||
|
|
||||||
|
|
||||||
|
# --- Skilltree ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
## Spielerlevel aus der Erfahrung (nicht die Kartenstufe level(cid)).
|
||||||
|
func player_level() -> int:
|
||||||
|
return Skills.level_for(xp)
|
||||||
|
|
||||||
|
|
||||||
|
## Gelernte Stufe eines Skills.
|
||||||
|
func skill(id: String) -> int:
|
||||||
|
return int(skills.get(id, 0))
|
||||||
|
|
||||||
|
|
||||||
|
## Freie Skillpunkte: ein Punkt je Level, abzüglich der gelernten Stufen.
|
||||||
|
func skill_points() -> int:
|
||||||
|
var spent := 0
|
||||||
|
for id in skills:
|
||||||
|
spent += int(skills[id])
|
||||||
|
return player_level() - spent
|
||||||
|
|
||||||
|
|
||||||
|
## Lernt die nächste Stufe eines Skills für einen Punkt. Gibt "" zurück oder eine Fehlermeldung.
|
||||||
|
func learn(id: String) -> String:
|
||||||
|
if skill(id) >= int(Skills.SKILLS[id].max):
|
||||||
|
return "Schon auf der höchsten Stufe"
|
||||||
|
if skill_points() <= 0:
|
||||||
|
return "Kein Skillpunkt frei"
|
||||||
|
skills[id] = skill(id) + 1
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
## Preis eines Boosters mit Händler-Rabatt.
|
||||||
|
func pack_price(element: String) -> int:
|
||||||
|
return int(round(booster_cost(element) * (1.0 - 0.1 * skill("packpreis"))))
|
||||||
|
|
||||||
|
|
||||||
|
func coin_factor() -> float:
|
||||||
|
return 1.0 + 0.1 * skill("muenzen")
|
||||||
|
|
||||||
|
|
||||||
|
## Staub mit Staubsammler-Bonus.
|
||||||
|
func dust_gain(base: int) -> int:
|
||||||
|
return int(round(base * (1.0 + 0.25 * skill("staub"))))
|
||||||
|
|
||||||
|
|
||||||
|
## Merkmale im Kampf: das gewählte (nach Kapitel 1) und die der Kampfskills.
|
||||||
|
func battle_perks() -> Array:
|
||||||
|
var out := []
|
||||||
|
if perk != "" and perk_unlocked():
|
||||||
|
out.append(perk)
|
||||||
|
for id in Skills.PERK_OF:
|
||||||
|
if skill(id) > 0 and not (Skills.PERK_OF[id] in out):
|
||||||
|
out.append(Skills.PERK_OF[id])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
func difficulty() -> Dictionary:
|
func difficulty() -> Dictionary:
|
||||||
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
|
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
|
||||||
|
|
||||||
|
|
||||||
## Vorschlag: rauf nach drei deutlichen Siegen in Folge (im Schnitt mindestens 12 Leben übrig) oder fünf
|
## Deine Stufe: die Deckstärke des Kampfstapels (100 Common … 500 Ultimate Rare) als Seltenheitsstufe.
|
||||||
## Siegen egal wie knapp; runter nach drei Niederlagen in Folge. "" = kein Vorschlag.
|
func deck_level() -> int:
|
||||||
func suggest_difficulty() -> String:
|
return clampi(roundi((DeckGen.strength(deck_cards(), collection) - 100) / 100.0), 0, Cards.RARITIES.size() - 1)
|
||||||
var order := ["leicht", "normal", "schwer"]
|
|
||||||
var i := order.find(str(settings.get("difficulty", "normal")))
|
|
||||||
if i < order.size() - 1 and records.streak >= 3:
|
|
||||||
var last: Array = records.margins.slice(maxi(0, records.margins.size() - 3))
|
|
||||||
var avg := 0.0
|
|
||||||
for m in last:
|
|
||||||
avg += float(m) / last.size()
|
|
||||||
if records.streak >= 5 or (records.margins.size() >= 3 and avg >= 12.0):
|
|
||||||
return order[i + 1]
|
|
||||||
if records.loss_streak >= 3 and i > 0:
|
|
||||||
return order[i - 1]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
## Eigene Deckstärke, mit Kartenstatistik gewichtet.
|
## Stufe der Gegnerkarten im Einzelspieler: deine Stufe plus den Versatz der Schwierigkeit.
|
||||||
func deck_rating(cards: Array = []) -> float:
|
func opponent_level() -> int:
|
||||||
var deck_used: Array = cards if not cards.is_empty() else deck_cards()
|
return clampi(deck_level() + int(difficulty().level), 0, Cards.RARITIES.size() - 1)
|
||||||
return Game.deck_rating(deck_used, card_stats)
|
|
||||||
|
|
||||||
|
|
||||||
## Zielstärke der Gegnerdecks: eigene Deckstärke mal Schwierigkeitsfaktor.
|
## Elemente der offenen Kampagnenkapitel (Neutral immer).
|
||||||
func target_rating(cards: Array = []) -> float:
|
func unlocked_elements() -> Array:
|
||||||
return deck_rating(cards) * float(difficulty().factor)
|
return Campaign.CHAPTER_ELEMENTS.slice(0, campaign_stage / Campaign.STAGES_PER_CHAPTER + 1)
|
||||||
|
|
||||||
|
|
||||||
## Tauscht die schwächste Karte des aktiven Decks gegen cid, wenn das Deck dadurch stärker und gültig bleibt.
|
## Alle Karten der offenen Elemente: was Sammlung, Deck und Booster zeigen.
|
||||||
## dry_run: nur prüfen. Gibt "" zurück oder den Grund, warum nicht.
|
func unlocked_cards() -> Array:
|
||||||
func improve_deck_with(cid: String, dry_run: bool = false) -> String:
|
var open := unlocked_elements()
|
||||||
if collection.get(cid, 0) <= 0:
|
return Cards.all().filter(func(c): return c.elements[0] in open)
|
||||||
return "Karte nicht in der Sammlung"
|
|
||||||
var card := Cards.by_id(cid)
|
|
||||||
if deck.count(cid) >= mini(collection[cid], Game.MAX_COPIES):
|
|
||||||
return "Schon so oft im Deck wie möglich"
|
|
||||||
var elems := Game.deck_elements(deck_cards())
|
|
||||||
for e in card.elements:
|
|
||||||
if not (e in elems):
|
|
||||||
elems.append(e)
|
|
||||||
if elems.size() > Game.MAX_DECK_ELEMENTS:
|
|
||||||
return "Passt nicht zu den Elementen des Decks"
|
|
||||||
var weakest := ""
|
|
||||||
var weakest_value := Cards.card_value(card)
|
|
||||||
for other in deck:
|
|
||||||
if other == cid:
|
|
||||||
continue
|
|
||||||
var v := Cards.card_value(Cards.by_id(other))
|
|
||||||
if v < weakest_value:
|
|
||||||
weakest_value = v
|
|
||||||
weakest = other
|
|
||||||
if weakest == "":
|
|
||||||
return "Keine schwächere Karte im Deck"
|
|
||||||
if dry_run:
|
|
||||||
return ""
|
|
||||||
var trial: Array = deck.duplicate()
|
|
||||||
trial[trial.find(weakest)] = cid
|
|
||||||
var ok := Game.validate_deck(trial.map(func(id): return Cards.by_id(id)), collection)
|
|
||||||
if not ok[0]:
|
|
||||||
return ok[1]
|
|
||||||
deck = trial
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
## Karten aus new_ids, die das aktive Deck verbessern würden.
|
## Einzelspieler-Gegner: ein Element aus den offenen Kapiteln, 30 Karten nach Manakurve in opponent_level;
|
||||||
func deck_suggestions(new_ids: Array) -> Array:
|
## auf Schwer ein zufälliges Merkmal. Leben wie deins.
|
||||||
var out := []
|
func single_opponent(r: RandomNumberGenerator) -> Game.Player:
|
||||||
for cid in new_ids:
|
var elements := unlocked_elements()
|
||||||
if not (cid in out) and improve_deck_with(cid, true) == "":
|
var element: String = elements[r.randi_range(0, elements.size() - 1)]
|
||||||
out.append(cid)
|
var opp := Game.Player.new("Gegner", false, Game.START_LIFE, element)
|
||||||
return out
|
opp.deck = DeckGen.curve_deck(element, r)
|
||||||
|
var level := opponent_level()
|
||||||
|
for id in opp.deck:
|
||||||
|
opp.looks[id] = level
|
||||||
|
if bool(difficulty().perk):
|
||||||
|
var keys: Array = Game.PERKS.keys()
|
||||||
|
opp.perks = [keys[r.randi_range(0, keys.size() - 1)]]
|
||||||
|
return opp
|
||||||
|
|
||||||
|
|
||||||
func perk_unlocked() -> bool:
|
func perk_unlocked() -> bool:
|
||||||
return campaign_stage >= Campaign.STAGES.size()
|
return campaign_stage >= Campaign.STAGES_PER_CHAPTER
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
class_name Skills
|
||||||
|
## Skilltree: Erfahrung sind die verdienten Münzen, je Level ein Skillpunkt, Punkte kaufen Stufen
|
||||||
|
## der Skills in drei Ästen. Münzen kaufen keine Punkte.
|
||||||
|
|
||||||
|
const BRANCHES: Array[String] = ["Sammeln", "Wirtschaft", "Kampf"]
|
||||||
|
## id -> Name, Ast, Höchststufe, Text.
|
||||||
|
const SKILLS := {
|
||||||
|
"packglueck": {"name": "Packglück", "branch": "Sammeln", "max": 3, "text": "Seltenere Karten in Boostern"},
|
||||||
|
"staub": {"name": "Staubsammler", "branch": "Sammeln", "max": 3, "text": "Mehr Staub aus Doppelten und Zerlegen"},
|
||||||
|
"muenzen": {"name": "Münzsegen", "branch": "Wirtschaft", "max": 3, "text": "Mehr Münzen nach jedem Kampf"},
|
||||||
|
"packpreis": {"name": "Händler", "branch": "Wirtschaft", "max": 2, "text": "Booster werden billiger"},
|
||||||
|
"gratisziehen": {"name": "Freies Ziehen", "branch": "Kampf", "max": 1, "text": "Das zweite Ziehen je Zug ist auch gratis"},
|
||||||
|
"startmana": {"name": "Starker Start", "branch": "Kampf", "max": 1, "text": "Mehr Mana im ersten Zug"},
|
||||||
|
}
|
||||||
|
## Kampfskills geben ein Merkmal (Game.PERKS) im Kampf.
|
||||||
|
const PERK_OF := {"gratisziehen": "ziehen_gratis", "startmana": "start_energie"}
|
||||||
|
## Packglück je Stufe: Common gibt ab, die höheren Stufen gewinnen.
|
||||||
|
const LUCK_SHIFT := {"Common": -8.0, "Rare": 3.0, "Super Rare": 2.0, "Ultra Rare": 2.0, "Ultimate Rare": 1.0}
|
||||||
|
|
||||||
|
|
||||||
|
## Erfahrung, die Level n verlangt: 100, 300, 600, 1000, 1500 … (50 · n · (n + 1)).
|
||||||
|
static func xp_for_level(n: int) -> int:
|
||||||
|
return 50 * n * (n + 1)
|
||||||
|
|
||||||
|
|
||||||
|
## Level zu einer Erfahrung.
|
||||||
|
static func level_for(xp: int) -> int:
|
||||||
|
var n := 0
|
||||||
|
while xp >= xp_for_level(n + 1):
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
static func in_branch(branch: String) -> Array:
|
||||||
|
return SKILLS.keys().filter(func(id): return SKILLS[id].branch == branch)
|
||||||
|
|
||||||
|
|
||||||
|
## Boosterchancen je Stufe (Reihenfolge Cards.RARITIES) mit Packglück luck.
|
||||||
|
static func pack_weights(luck: int) -> PackedFloat32Array:
|
||||||
|
var out := PackedFloat32Array()
|
||||||
|
for rar in Cards.RARITIES:
|
||||||
|
out.append(float(Profile.RARITY_WEIGHTS[rar]) + luck * float(LUCK_SHIFT[rar]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Konkrete Wirkung eines Skills auf einer Stufe, als Text.
|
||||||
|
static func effect(id: String, rank: int) -> String:
|
||||||
|
match id:
|
||||||
|
"packglueck":
|
||||||
|
var w := pack_weights(rank)
|
||||||
|
return TranslationServer.translate("Chancen %d/%d/%d/%d/%d %%") % [int(w[0]), int(w[1]), int(w[2]), int(w[3]), int(w[4])]
|
||||||
|
"staub":
|
||||||
|
return TranslationServer.translate("+%d %% Staub") % (25 * rank)
|
||||||
|
"muenzen":
|
||||||
|
return TranslationServer.translate("+%d %% Münzen") % (10 * rank)
|
||||||
|
"packpreis":
|
||||||
|
return TranslationServer.translate("Neutral %d, Element %d Münzen") % [roundi(Profile.BOOSTER_COST * (1.0 - 0.1 * rank)), roundi(Profile.ELEMENT_BOOSTER_COST * (1.0 - 0.1 * rank))]
|
||||||
|
"gratisziehen":
|
||||||
|
return TranslationServer.translate("zwei Karten je Zug gratis" if rank > 0 else "eine Karte je Zug gratis")
|
||||||
|
"startmana":
|
||||||
|
return TranslationServer.translate("erster Zug: %d Mana") % (Game.START_MANA + (2 if rank > 0 else 0))
|
||||||
|
return ""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bbcmk1gtfjoim
|
||||||
+129
-37
@@ -4,6 +4,8 @@ extends RefCounted
|
|||||||
|
|
||||||
var screen # BattleScreen
|
var screen # BattleScreen
|
||||||
var enabled := true
|
var enabled := true
|
||||||
|
var banners: Array = [] # wartende Ansagen [Text, Farbe]
|
||||||
|
var banner_running := false
|
||||||
|
|
||||||
|
|
||||||
func _init(s) -> void:
|
func _init(s) -> void:
|
||||||
@@ -25,45 +27,24 @@ func play_events(evs: Array, centers: Dictionary) -> void:
|
|||||||
var atk: CreatureView = screen.tiles.get(ev.attacker.uid)
|
var atk: CreatureView = screen.tiles.get(ev.attacker.uid)
|
||||||
lunge(atk, center_of(ev.target, centers))
|
lunge(atk, center_of(ev.target, centers))
|
||||||
flash_target(ev.target)
|
flash_target(ev.target)
|
||||||
|
shake()
|
||||||
delay = 0.12
|
delay = 0.12
|
||||||
"damage":
|
"damage":
|
||||||
sfx("damage")
|
sfx("damage")
|
||||||
popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay)
|
popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay)
|
||||||
"heal":
|
"heal":
|
||||||
sfx("heal")
|
sfx("heal")
|
||||||
popup("+%d" % ev.amount, Style.GREEN, center_of(ev.target, centers), delay)
|
popup(tr("Regeneration"), Style.GREEN, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
|
||||||
"buff":
|
|
||||||
popup(ev.text, Style.GREEN, center_of(ev.target, centers), delay)
|
|
||||||
"blocked":
|
|
||||||
popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay)
|
|
||||||
"freeze":
|
|
||||||
popup("Eis", Color("aaddff"), center_of(ev.target, centers), delay)
|
|
||||||
"combo":
|
|
||||||
sfx("combo")
|
|
||||||
var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid})
|
|
||||||
if where.x < 0:
|
|
||||||
where = screen.target_center({"kind": "player", "index": ev.player})
|
|
||||||
popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1)
|
|
||||||
"play":
|
"play":
|
||||||
sfx("play")
|
sfx("click" if ev.get("hidden", false) else "play")
|
||||||
"spell":
|
"reveal":
|
||||||
sfx("play")
|
|
||||||
var caster: Game.Player = g.players[ev.player]
|
|
||||||
popup(ev.card.name, Style.GOLD, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
|
||||||
if caster != screen.perspective:
|
|
||||||
screen.show_preview(ev.card, true)
|
|
||||||
screen.preview_timer = 1.6
|
|
||||||
"trap_set":
|
|
||||||
sfx("click")
|
|
||||||
popup("Falle gelegt", Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
|
||||||
"trap":
|
|
||||||
sfx("combo")
|
sfx("combo")
|
||||||
popup("Falle: %s!" % ev.card.name, Style.ORANGE, screen.target_center({"kind": "player", "index": ev.player}) + Vector2(0, -20), 0.0)
|
popup(tr("Aufgedeckt!"), Style.GOLD, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
|
||||||
screen.show_preview(ev.card, true)
|
"fuse":
|
||||||
screen.preview_timer = 1.8
|
sfx("combo")
|
||||||
"mulligan":
|
popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
||||||
if ev.count > 0:
|
"draw":
|
||||||
popup(("tauscht 1 Karte" if int(ev.count) == 1 else "tauscht %d Karten" % ev.count), Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
|
sfx("click")
|
||||||
|
|
||||||
|
|
||||||
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
|
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
|
||||||
@@ -73,9 +54,24 @@ func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
|
|||||||
return c
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
## Mehrere Meldungen an derselben Stelle rücken nach unten, sonst liegen sie übereinander.
|
||||||
|
func _free_spot(at: Vector2) -> Vector2:
|
||||||
|
for step in 6:
|
||||||
|
var spot := at + Vector2(0, step * 26)
|
||||||
|
var taken := false
|
||||||
|
for l in screen.fx_layer.get_children():
|
||||||
|
if l is Label and l.get_meta("spot", Vector2(-999, -999)).distance_to(spot) < 20:
|
||||||
|
taken = true
|
||||||
|
break
|
||||||
|
if not taken:
|
||||||
|
return spot
|
||||||
|
return at
|
||||||
|
|
||||||
|
|
||||||
func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
|
func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
|
||||||
if not enabled or at.x < 0:
|
if not enabled or at.x < 0:
|
||||||
return
|
return
|
||||||
|
at = _free_spot(at)
|
||||||
var l := Label.new()
|
var l := Label.new()
|
||||||
l.text = text
|
l.text = text
|
||||||
l.add_theme_font_size_override("font_size", 22)
|
l.add_theme_font_size_override("font_size", 22)
|
||||||
@@ -84,6 +80,7 @@ func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
|
|||||||
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||||
l.add_theme_constant_override("outline_size", 6)
|
l.add_theme_constant_override("outline_size", 6)
|
||||||
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
l.set_meta("spot", at)
|
||||||
screen.fx_layer.add_child(l)
|
screen.fx_layer.add_child(l)
|
||||||
l.reset_size()
|
l.reset_size()
|
||||||
l.position = at - l.size / 2
|
l.position = at - l.size / 2
|
||||||
@@ -142,14 +139,31 @@ func pop_in(tile: CreatureView) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func fade_out(tile: CreatureView) -> void:
|
func fade_out(tile: CreatureView) -> void:
|
||||||
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
fade_view(tile)
|
||||||
|
|
||||||
|
|
||||||
|
## Blendet eine Ansicht aus und entfernt sie (Wesen, die sterben; Handkarten, die verschmelzen).
|
||||||
|
func fade_view(view: Control) -> void:
|
||||||
|
view.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
if not enabled:
|
if not enabled:
|
||||||
tile.queue_free()
|
view.queue_free()
|
||||||
return
|
return
|
||||||
var tw := tile.create_tween().set_parallel(true)
|
var tw := view.create_tween().set_parallel(true)
|
||||||
tw.tween_property(tile, "modulate:a", 0.0, 0.3)
|
tw.tween_property(view, "modulate:a", 0.0, 0.3)
|
||||||
tw.tween_property(tile, "scale", Vector2(0.6, 0.6), 0.3)
|
tw.tween_property(view, "scale", Vector2(0.6, 0.6), 0.3)
|
||||||
tw.chain().tween_callback(tile.queue_free)
|
tw.chain().tween_callback(view.queue_free)
|
||||||
|
|
||||||
|
|
||||||
|
## Eine Ansicht gleitet von from an ihren Platz (die neue Handkarte nach dem Verschmelzen).
|
||||||
|
func glide(view: Control, from: Rect2) -> void:
|
||||||
|
if not enabled:
|
||||||
|
return
|
||||||
|
var to := view.position
|
||||||
|
view.position = from.position
|
||||||
|
view.scale = from.size / view.size
|
||||||
|
var tw := view.create_tween().set_parallel(true)
|
||||||
|
tw.tween_property(view, "position", to, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||||
|
tw.tween_property(view, "scale", Vector2.ONE, 0.25).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||||
|
|
||||||
|
|
||||||
func move_to(tile: CreatureView, pos: Vector2) -> void:
|
func move_to(tile: CreatureView, pos: Vector2) -> void:
|
||||||
@@ -157,3 +171,81 @@ func move_to(tile: CreatureView, pos: Vector2) -> void:
|
|||||||
screen.create_tween().tween_property(tile, "position", pos, 0.2)
|
screen.create_tween().tween_property(tile, "position", pos, 0.2)
|
||||||
else:
|
else:
|
||||||
tile.position = pos
|
tile.position = pos
|
||||||
|
|
||||||
|
|
||||||
|
## Ein Wesen fliegt von der Handkachel auf seinen Platz.
|
||||||
|
func fly_in(tile: CreatureView, from: Rect2) -> void:
|
||||||
|
if not enabled:
|
||||||
|
return
|
||||||
|
tile.busy = true
|
||||||
|
tile.position = from.position
|
||||||
|
tile.scale = from.size / tile.size
|
||||||
|
var tw := tile.create_tween().set_parallel(true)
|
||||||
|
tw.tween_property(tile, "position", tile.slot, 0.22).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||||
|
tw.tween_property(tile, "scale", Vector2.ONE, 0.22).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||||
|
tw.chain().tween_callback(func():
|
||||||
|
tile.busy = false
|
||||||
|
tile.position = tile.slot)
|
||||||
|
|
||||||
|
|
||||||
|
## Kurzes Zittern des Bretts beim Treffer.
|
||||||
|
func shake() -> void:
|
||||||
|
if not enabled:
|
||||||
|
return
|
||||||
|
var layer: Control = screen.tile_layer
|
||||||
|
var tw: Tween = screen.create_tween()
|
||||||
|
for i in 3:
|
||||||
|
tw.tween_property(layer, "position", Vector2(3 if i % 2 == 0 else -3, 0), 0.03)
|
||||||
|
tw.tween_property(layer, "position", Vector2.ZERO, 0.03)
|
||||||
|
|
||||||
|
|
||||||
|
## Ansage quer über die Mitte: Zugbeginn, Feldwechsel. Mehrere folgen nacheinander.
|
||||||
|
func banner(text: String, color: Color) -> void:
|
||||||
|
if not enabled:
|
||||||
|
return
|
||||||
|
banners.append([text, color])
|
||||||
|
if not banner_running:
|
||||||
|
_next_banner()
|
||||||
|
|
||||||
|
|
||||||
|
func _next_banner() -> void:
|
||||||
|
if banners.is_empty() or not is_instance_valid(screen) or not screen.is_inside_tree():
|
||||||
|
banner_running = false
|
||||||
|
return
|
||||||
|
banner_running = true
|
||||||
|
var next: Array = banners.pop_front()
|
||||||
|
var text: String = next[0]
|
||||||
|
var color: Color = next[1]
|
||||||
|
var delay := 0.0
|
||||||
|
var band := ColorRect.new()
|
||||||
|
band.color = Color(0, 0, 0, 0.55)
|
||||||
|
band.position = Vector2(0, 355)
|
||||||
|
band.size = Vector2(1280, 90)
|
||||||
|
band.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
band.scale.y = 0.0
|
||||||
|
band.pivot_offset = Vector2(640, 45)
|
||||||
|
screen.fx_layer.add_child(band)
|
||||||
|
var l := Label.new()
|
||||||
|
l.text = text
|
||||||
|
l.add_theme_font_size_override("font_size", 40)
|
||||||
|
l.add_theme_font_override("font", Style.FONT_BOLD)
|
||||||
|
l.add_theme_color_override("font_color", color)
|
||||||
|
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
|
||||||
|
l.add_theme_constant_override("outline_size", 8)
|
||||||
|
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
screen.fx_layer.add_child(l)
|
||||||
|
l.reset_size()
|
||||||
|
l.position = Vector2(640, 400) - l.size / 2
|
||||||
|
l.pivot_offset = l.size / 2
|
||||||
|
l.scale = Vector2(0.6, 0.6)
|
||||||
|
l.modulate.a = 0.0
|
||||||
|
var tw: Tween = screen.create_tween().set_parallel(true)
|
||||||
|
tw.tween_property(band, "scale:y", 1.0, 0.15).set_delay(delay).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||||
|
tw.tween_property(l, "scale", Vector2.ONE, 0.25).set_delay(delay).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||||
|
tw.tween_property(l, "modulate:a", 1.0, 0.12).set_delay(delay)
|
||||||
|
tw.tween_property(l, "modulate:a", 0.0, 0.3).set_delay(delay + 0.8)
|
||||||
|
tw.tween_property(band, "scale:y", 0.0, 0.2).set_delay(delay + 0.9)
|
||||||
|
tw.chain().tween_callback(func():
|
||||||
|
l.queue_free()
|
||||||
|
band.queue_free()
|
||||||
|
_next_banner())
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
class_name BattleGamepad
|
class_name BattleGamepad
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Hand, eigene Wesen und Ziele,
|
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Stapel, Hand, eigene Wesen
|
||||||
## A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, X macht rückgängig.
|
## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y öffnet das Kartenmenü (im
|
||||||
|
## Gegnerzug: überspringen), X macht rückgängig. Verschmelzen: im Menü „Verschmelzen mit …“,
|
||||||
|
## dann A auf dem Partner.
|
||||||
|
|
||||||
var screen # BattleScreen
|
var screen # BattleScreen
|
||||||
var active := false
|
var active := false
|
||||||
var zone := "hand"
|
var zone := "deck"
|
||||||
var index := 0
|
var index := 0
|
||||||
var targets: Array = []
|
var targets: Array = []
|
||||||
|
|
||||||
@@ -26,10 +28,20 @@ func button(button: int) -> void:
|
|||||||
if screen.confirm.visible:
|
if screen.confirm.visible:
|
||||||
screen.confirm.visible = false
|
screen.confirm.visible = false
|
||||||
screen.cancel_all()
|
screen.cancel_all()
|
||||||
zone = "hand"
|
zone = "deck"
|
||||||
JOY_BUTTON_Y:
|
JOY_BUTTON_Y:
|
||||||
if screen.b_skip.visible:
|
if screen.b_skip.visible:
|
||||||
screen.skip_ai = true
|
screen.skip_ai = true
|
||||||
|
elif screen.menu_layer.visible:
|
||||||
|
screen.close_menu()
|
||||||
|
else:
|
||||||
|
var items := items()
|
||||||
|
if not items.is_empty() and zone in ["hand", "own"]:
|
||||||
|
var item = items[clampi(index, 0, items.size() - 1)]
|
||||||
|
if zone == "hand":
|
||||||
|
screen.open_menu(item.card, Rect2(item.position, item.size), true)
|
||||||
|
else:
|
||||||
|
screen.open_menu(item.creature, Rect2(item.slot, item.size), true)
|
||||||
JOY_BUTTON_X:
|
JOY_BUTTON_X:
|
||||||
if screen.b_undo.visible:
|
if screen.b_undo.visible:
|
||||||
screen.undo()
|
screen.undo()
|
||||||
@@ -41,17 +53,34 @@ func button(button: int) -> void:
|
|||||||
move(1)
|
move(1)
|
||||||
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
|
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
|
||||||
if zone != "target":
|
if zone != "target":
|
||||||
zone = "own" if zone == "hand" else "hand"
|
_next_zone(1 if button == JOY_BUTTON_DPAD_DOWN else -1)
|
||||||
index = 0
|
|
||||||
screen.refresh()
|
screen.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
## Wechselt den Bereich und überspringt leere (eine leere Hand ist keine Station).
|
||||||
|
## step 0: beim aktuellen Bereich bleiben, falls er etwas enthält.
|
||||||
|
func _next_zone(step: int) -> void:
|
||||||
|
var order := ["deck", "hand", "own"]
|
||||||
|
var at := order.find(zone)
|
||||||
|
index = 0
|
||||||
|
if step == 0 and not items().is_empty():
|
||||||
|
return
|
||||||
|
var dir := -1 if step < 0 else 1
|
||||||
|
for i in order.size():
|
||||||
|
at = posmod(at + dir, order.size())
|
||||||
|
zone = order[at]
|
||||||
|
if not items().is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
func items() -> Array:
|
func items() -> Array:
|
||||||
match zone:
|
match zone:
|
||||||
|
"deck":
|
||||||
|
return [screen.deck] if screen.deck.visible else []
|
||||||
"hand":
|
"hand":
|
||||||
return screen.hand_views
|
return screen.hand_views
|
||||||
"own":
|
"own":
|
||||||
return screen.own_views.filter(func(v): return v.creature.can_attack())
|
return screen.own_views
|
||||||
"target":
|
"target":
|
||||||
return targets
|
return targets
|
||||||
return []
|
return []
|
||||||
@@ -64,9 +93,6 @@ func move(dir: int) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func confirm() -> void:
|
func confirm() -> void:
|
||||||
if screen.mulligan.visible:
|
|
||||||
screen.do_mulligan([])
|
|
||||||
return
|
|
||||||
if screen.overlay.visible:
|
if screen.overlay.visible:
|
||||||
screen.leave()
|
screen.leave()
|
||||||
return
|
return
|
||||||
@@ -78,22 +104,10 @@ func confirm() -> void:
|
|||||||
index = clampi(index, 0, items.size() - 1)
|
index = clampi(index, 0, items.size() - 1)
|
||||||
var item = items[index]
|
var item = items[index]
|
||||||
match zone:
|
match zone:
|
||||||
|
"deck":
|
||||||
|
screen.on_click("deck", null)
|
||||||
"hand":
|
"hand":
|
||||||
screen.on_click("hand", item.card)
|
screen.on_click("hand", item.card)
|
||||||
if screen.pending != null:
|
|
||||||
targets = []
|
|
||||||
var valid: Array = screen.game.valid_targets(screen.perspective, screen.pending.target_type())
|
|
||||||
for v in screen.enemy_views:
|
|
||||||
if v.creature in valid:
|
|
||||||
targets.append(v)
|
|
||||||
for v in screen.own_views:
|
|
||||||
if v.creature in valid:
|
|
||||||
targets.append(v)
|
|
||||||
for o in screen.others():
|
|
||||||
if o in valid:
|
|
||||||
targets.append(screen.hero_panels[o])
|
|
||||||
zone = "target"
|
|
||||||
index = 0
|
|
||||||
"own":
|
"own":
|
||||||
screen.on_click("own", item.creature)
|
screen.on_click("own", item.creature)
|
||||||
if screen.selected != null:
|
if screen.selected != null:
|
||||||
@@ -113,8 +127,8 @@ func confirm() -> void:
|
|||||||
for o in screen.hero_panels:
|
for o in screen.hero_panels:
|
||||||
if screen.hero_panels[o] == item:
|
if screen.hero_panels[o] == item:
|
||||||
screen.on_click("hero", o)
|
screen.on_click("hero", o)
|
||||||
zone = "hand"
|
zone = "own"
|
||||||
index = 0
|
_next_zone(0)
|
||||||
|
|
||||||
|
|
||||||
## Pulsierender Rahmen als Zeiger.
|
## Pulsierender Rahmen als Zeiger.
|
||||||
|
|||||||
+61
-28
@@ -1,40 +1,73 @@
|
|||||||
class_name BattleLayout
|
class_name BattleLayout
|
||||||
## Feste Bildschirmaufteilung des Kampfes (1280x800).
|
## Feste Bildschirmaufteilung des Duells (1280x800). Mitte von oben nach unten: Hand des Gegners
|
||||||
|
## (Rückseiten), sein Brett, eigenes Brett, eigene Hand. Links: Kasten des Gegners, daneben die
|
||||||
|
## Elementzähler beider Seiten je neben ihrem Brett, Zusatzzeile und Meldungen. Rechts von oben:
|
||||||
|
## Stapel und Kasten des Gegners, die Vorschau, eigener Kasten und Stapel. Optionen per Esc.
|
||||||
|
|
||||||
const SIZES := {1: [110, 110, 10], 2: [96, 110, 8], 3: [78, 100, 4], 4: [54, 72, 4], 5: [44, 62, 3]}
|
const B_UNDO := Rect2(20, 10, 210, 32) # links oben: Rückgängig, darunter Überspringen (Gegnerzug)
|
||||||
const HAND_W := 130
|
const B_SKIP := Rect2(20, 48, 210, 32)
|
||||||
const HAND_H := 185
|
const OPP_DECK := Rect2(1090, 10, 130, 182) # Stapel des Gegners rechts oben
|
||||||
const HAND_GAP := 8
|
const OPP_BOX := Rect2(1050, 200, 210, 70) # Kasten des Gegners darunter
|
||||||
const BAR := Rect2(335, 415, 610, 68)
|
const OPP_HAND_Y := 22 # Bogen hebt die äußeren Rückseiten um bis zu 12 Pixel
|
||||||
const OWN_X := (1280 - 5 * 110 - 4 * 10) / 2
|
const OPP_ELEMENTS := Rect2(20, 220, 210, 128) # Elementzähler des Gegners, links von seinem Brett
|
||||||
|
const OWN_ELEMENTS := Rect2(20, 440, 210, 128) # eigene Elementzähler, links vom eigenen Brett
|
||||||
|
const INFO := Rect2(20, 580, 210, 60) # Zusatzzeile zur Vorschau
|
||||||
static func compact(n_others: int) -> bool:
|
const MSG := Rect2(20, 650, 210, 80) # Meldungen
|
||||||
return n_others >= 4
|
const STATUS := Rect2(1050, 526, 210, 70) # eigener Kasten rechts über dem Stapel
|
||||||
|
const DECK := Rect2(1090, 606, 130, 182) # eigener Stapel rechts unten
|
||||||
|
const OPTIONS := Rect2(440, 180, 400, 420) # Optionsmenü (Esc)
|
||||||
static func hero_box_rect(n_others: int, i: int) -> Rect2:
|
const OWN_X := 295
|
||||||
var col_w := 1280 / n_others
|
const HAND_W := 130 # Handkarten so groß wie die Wesen auf dem Feld
|
||||||
var margin := 6 if compact(n_others) else 10
|
const HAND_H := 165
|
||||||
return Rect2(i * col_w + margin, 10, col_w - 2 * margin, 64)
|
const HAND_GAP := 6
|
||||||
|
const HAND_Y := 605
|
||||||
|
const HAND_ARC := 3.0 # Grad Neigung und Pixel Absenkung je Platz von der Mitte (leichter Bogen)
|
||||||
|
const LOG_PANEL := Rect2(240, 60, 800, 560)
|
||||||
|
const PREVIEW_SIZE := Vector2(170, 238)
|
||||||
|
const CENTER_X := 240
|
||||||
|
const CENTER_W := 800
|
||||||
|
|
||||||
|
|
||||||
static func own_slot(j: int) -> Rect2:
|
static func own_slot(j: int) -> Rect2:
|
||||||
return Rect2(OWN_X + j * 120, 250, 110, 140)
|
return Rect2(OWN_X + j * 140, 410, HAND_W, HAND_H)
|
||||||
|
|
||||||
|
|
||||||
static func other_slot(n_others: int, i: int, j: int) -> Rect2:
|
## Brett des Gegners über seiner Elementleiste.
|
||||||
var col_w := 1280 / n_others
|
static func opp_slot(j: int) -> Rect2:
|
||||||
var sz: Array = SIZES[n_others]
|
return Rect2(OWN_X + j * 140, 205, HAND_W, HAND_H)
|
||||||
var bx: int = i * col_w + (col_w - 5 * sz[0] - 4 * sz[2]) / 2
|
|
||||||
return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1])
|
|
||||||
|
|
||||||
|
|
||||||
|
## Hand: alle Karten unten, mittig; passen sie nicht nebeneinander, überlappen sie sich
|
||||||
|
## gleichmäßig. Den Bogen legt hand_arc dazu.
|
||||||
static func hand_rect(count: int, i: int) -> Rect2:
|
static func hand_rect(count: int, i: int) -> Rect2:
|
||||||
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
|
var step: float = HAND_W + HAND_GAP
|
||||||
var x := (1280 - total) / 2
|
if count > 1:
|
||||||
return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H)
|
step = minf(step, float(CENTER_W - HAND_W) / (count - 1))
|
||||||
|
var total: float = HAND_W + (count - 1) * step
|
||||||
|
var x := CENTER_X + (CENTER_W - total) / 2
|
||||||
|
return Rect2(x + i * step, HAND_Y, HAND_W, HAND_H)
|
||||||
|
|
||||||
|
|
||||||
static func trap_rect(i: int) -> Rect2:
|
## Hand des Gegners oben, gleich groß, nur die Rückseiten.
|
||||||
return Rect2(400 + i * 78, 490, 70, 98)
|
static func opp_hand_rect(count: int, i: int) -> Rect2:
|
||||||
|
var r := hand_rect(count, i)
|
||||||
|
r.position.y = OPP_HAND_Y
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
## Leichter Bogen: Karte i von count dreht sich um ihre Mitte und sinkt zum Rand hin ab
|
||||||
|
## (mirrored: für den Gegner oben spiegelverkehrt). Liefert [Grad, y-Versatz].
|
||||||
|
static func hand_arc(count: int, i: int, mirrored: bool = false) -> Array:
|
||||||
|
var t := i - (count - 1) / 2.0
|
||||||
|
var sign := -1.0 if mirrored else 1.0
|
||||||
|
return [t * HAND_ARC * sign, t * t * HAND_ARC * sign]
|
||||||
|
|
||||||
|
|
||||||
|
## Vorschau der gezeigten Karte: fest rechts zwischen den beiden Kästen.
|
||||||
|
static func preview_rect(_anchor: Rect2) -> Rect2:
|
||||||
|
return Rect2(Vector2(1070, 278), PREVIEW_SIZE)
|
||||||
|
|
||||||
|
|
||||||
|
## Zusatzzeile (Merkmale, Eigenschaften) links unter den Elementpaaren.
|
||||||
|
static func info_rect(_preview: Rect2) -> Rect2:
|
||||||
|
return INFO
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
class_name BattleMulligan
|
|
||||||
extends Control
|
|
||||||
## Mulligan-Maske: Karten anklicken, behalten oder tauschen.
|
|
||||||
|
|
||||||
signal decided(indexes: Array)
|
|
||||||
|
|
||||||
var layer: Control
|
|
||||||
var b_keep: Button
|
|
||||||
var b_swap: Button
|
|
||||||
var selected: Array = []
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
||||||
visible = false
|
|
||||||
var dark := ColorRect.new()
|
|
||||||
dark.color = Color(0, 0, 0, 0.67)
|
|
||||||
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
||||||
dark.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
||||||
add_child(dark)
|
|
||||||
Style.panel(self, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
|
|
||||||
Style.label(self, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
|
|
||||||
Style.label(self, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
|
|
||||||
layer = Control.new()
|
|
||||||
layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
add_child(layer)
|
|
||||||
b_keep = Style.button(self, "Behalten", Rect2(430, 515, 200, 44), func(): finish(false), Color("285a3c"))
|
|
||||||
b_swap = Style.button(self, "Tauschen", Rect2(650, 515, 200, 44), func(): finish(true))
|
|
||||||
|
|
||||||
|
|
||||||
func open(hand: Array) -> void:
|
|
||||||
selected = []
|
|
||||||
visible = true
|
|
||||||
Style.clear(layer)
|
|
||||||
for i in hand.size():
|
|
||||||
var r := BattleLayout.hand_rect(hand.size(), i)
|
|
||||||
var v := CardView.new(hand[i], r.size)
|
|
||||||
v.position = Vector2(r.position.x, 322)
|
|
||||||
var idx := i
|
|
||||||
v.clicked.connect(func(_view): toggle(idx))
|
|
||||||
layer.add_child(v)
|
|
||||||
_borders()
|
|
||||||
|
|
||||||
|
|
||||||
func toggle(idx: int) -> void:
|
|
||||||
if idx in selected:
|
|
||||||
selected.erase(idx)
|
|
||||||
else:
|
|
||||||
selected.append(idx)
|
|
||||||
_borders()
|
|
||||||
|
|
||||||
|
|
||||||
func _borders() -> void:
|
|
||||||
var views := layer.get_children()
|
|
||||||
for i in views.size():
|
|
||||||
views[i].border = Style.YELLOW if i in selected else null
|
|
||||||
views[i].queue_redraw()
|
|
||||||
b_swap.text = ("Tauschen (%d)" % selected.size()) if not selected.is_empty() else "Tauschen"
|
|
||||||
|
|
||||||
|
|
||||||
func finish(swap: bool) -> void:
|
|
||||||
visible = false
|
|
||||||
decided.emit(selected.duplicate() if swap else [])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://gwdm38e83cbj
|
|
||||||
+714
-480
File diff suppressed because it is too large.
Load diff
+197
-77
@@ -1,21 +1,32 @@
|
|||||||
class_name BoosterScreen
|
class_name BoosterScreen
|
||||||
extends Control
|
extends Control
|
||||||
|
## Booster kaufen und öffnen: unten die Pack-Kacheln der offenen Kapitel (Neutral und die Elemente in
|
||||||
|
## Kapitelreihenfolge, gesperrte sind unsichtbar) mit ×1, ×5, ×10; die Karten fliegen aus dem Pack in ein Raster
|
||||||
|
## (ein Pack: fünf große, mehrere: zehn je Reihe, beste Stufe zuerst) und liegen verdeckt. Klick
|
||||||
|
## oder „Alle aufdecken“ dreht sie gestaffelt um, seltene blitzen auf; danach steht das Fazit.
|
||||||
|
|
||||||
const CARD_W := 200
|
const CARD_W := 200
|
||||||
const CARD_H := 280
|
const CARD_H := 280
|
||||||
|
const SMALL_W := 108
|
||||||
|
const SMALL_H := 151
|
||||||
|
const AREA := Rect2(40, 100, 1200, 460)
|
||||||
|
const QUANTITIES := [1, 5, 10]
|
||||||
|
|
||||||
var app
|
var app
|
||||||
var cards: Array = []
|
var cards: Array = [] # {"card", "rarity", "new", "upgraded", "dust"} je Karte
|
||||||
var revealed := {}
|
var revealed := {}
|
||||||
var new_ids := {}
|
|
||||||
var coins_label: Label
|
var coins_label: Label
|
||||||
var msg_label: Label
|
var summary_label: Label
|
||||||
var b_buy: Button
|
var b_buy: Button # Neutral ×1 (Tests)
|
||||||
var b_all: Button
|
var b_all: Button
|
||||||
var b_improve: Button
|
var scroll: ScrollContainer
|
||||||
var suggestion := ""
|
|
||||||
var slots: Control
|
var slots: Control
|
||||||
var element_buttons: Array = []
|
var pack_tiles := {} # Element -> PackTile (Kapitelreihenfolge)
|
||||||
|
var pack_buttons := {} # Element -> der ×1-Knopf
|
||||||
|
var fresh := {} # gerade aufgedeckt: Index -> Verzögerung fürs Umdrehen
|
||||||
|
var fly_from := Vector2.ZERO # woher die Karten einfliegen (Mitte der gekauften Kachel)
|
||||||
|
var fly := false # beim nächsten refresh einfliegen
|
||||||
|
var last_pack := "" # zuletzt gekauftes Pack (pulsiert)
|
||||||
|
|
||||||
|
|
||||||
class CardBack extends Control:
|
class CardBack extends Control:
|
||||||
@@ -41,34 +52,56 @@ class CardBack extends Control:
|
|||||||
var rect := Rect2(Vector2.ZERO, size)
|
var rect := Rect2(Vector2.ZERO, size)
|
||||||
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect)
|
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect)
|
||||||
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
|
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
|
||||||
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
|
Style.text(self, "?", 48 if size.x >= 180 else 30, Style.GOLD, size / 2, "center", true)
|
||||||
|
|
||||||
|
|
||||||
func slot_rect(i: int) -> Rect2:
|
## Pack-Kachel eines Elements: Symbol, Name, Preis; die Kaufknöpfe hängt der Bildschirm an.
|
||||||
var gap := 20
|
class PackTile extends Control:
|
||||||
var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2
|
var element: String
|
||||||
return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H)
|
var locked := false
|
||||||
|
var hint := ""
|
||||||
|
var pulse := false
|
||||||
|
var buy_buttons: Array = []
|
||||||
|
|
||||||
|
func _init(e: String, rect: Rect2) -> void:
|
||||||
|
element = e
|
||||||
|
position = rect.position
|
||||||
|
size = rect.size
|
||||||
|
pivot_offset = rect.size / 2
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var col: Color = Cards.ELEMENT_COLORS[element]
|
||||||
|
draw_style_box(Style.box(Style.darker(col, 0.5), 10, Style.GOLD if pulse else Color(col, 0.7), 2), Rect2(Vector2.ZERO, size))
|
||||||
|
Style.element_icon(self, element, Vector2(32, 40), 34, Style.lighter(col, 0.4))
|
||||||
|
Style.text(self, tr(element), 16, Style.TEXT, Vector2(60, 30), "midleft", true)
|
||||||
|
Style.text(self, hint, 13, Style.MUTED, Vector2(60, 52), "midleft")
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true)
|
Style.header(self, "Booster", func(): app.show_screen(MenuScreen.new()))
|
||||||
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
|
coins_label = Style.chip(self, "", Rect2(860, 26, 240, 38))
|
||||||
Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.",
|
scroll = ScrollContainer.new()
|
||||||
16, Style.MUTED, Rect2(0, 115, 1280, 24), "center")
|
scroll.position = AREA.position
|
||||||
Style.label(self, "Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos" % Profile.ELEMENT_BOOSTER_COST,
|
scroll.size = AREA.size
|
||||||
14, Style.MUTED, Rect2(0, 586, 1280, 22), "center")
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
for i in Cards.ELEMENTS.size():
|
add_child(scroll)
|
||||||
var e: String = Cards.ELEMENTS[i]
|
|
||||||
element_buttons.append(Style.button(self, e, Rect2(322 + i * 162, 612, 150, 38), buy.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)))
|
|
||||||
slots = Control.new()
|
slots = Control.new()
|
||||||
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
slots.mouse_filter = Control.MOUSE_FILTER_PASS
|
||||||
add_child(slots)
|
slots.custom_minimum_size = AREA.size
|
||||||
msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 700, 1280, 22), "center")
|
scroll.add_child(slots)
|
||||||
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(290, 720, 330, 48), buy, Color("5a4628"))
|
summary_label = Style.label(self, "", 16, Style.GOLD, Rect2(0, 566, 1280, 24), "center", true)
|
||||||
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all)
|
b_all = Style.button(self, "Alle aufdecken", Rect2(540, 594, 200, 40), reveal_all)
|
||||||
b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c"))
|
for i in Campaign.CHAPTER_ELEMENTS.size():
|
||||||
b_improve.visible = false
|
var e: String = Campaign.CHAPTER_ELEMENTS[i]
|
||||||
Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new()))
|
var tile := PackTile.new(e, Rect2(70 + i * 230, 644, 220, 136))
|
||||||
|
add_child(tile)
|
||||||
|
for k in QUANTITIES.size():
|
||||||
|
var b := Style.button(tile, "×%d" % QUANTITIES[k], Rect2(10 + k * 68, 88, 64, 36), buy.bind(e, QUANTITIES[k]))
|
||||||
|
b.add_theme_font_size_override("font_size", 14)
|
||||||
|
tile.buy_buttons.append(b)
|
||||||
|
pack_tiles[e] = tile
|
||||||
|
pack_buttons[e] = tile.buy_buttons[0]
|
||||||
|
b_buy = pack_buttons[Cards.NEUTRAL]
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
@@ -77,90 +110,177 @@ func _input(event: InputEvent) -> void:
|
|||||||
app.show_screen(MenuScreen.new())
|
app.show_screen(MenuScreen.new())
|
||||||
|
|
||||||
|
|
||||||
func buy(element: String = "") -> void:
|
## Kauft n Packs eines Elements; die Karten liegen verdeckt, mehrere Packs nach Stufe sortiert.
|
||||||
|
func buy(element: String, n: int) -> void:
|
||||||
var p: Profile = app.profile
|
var p: Profile = app.profile
|
||||||
var before: Dictionary = p.collection.duplicate()
|
var before: int = p.coins
|
||||||
var got: Array = p.open_booster(null, element)
|
var got: Array = p.open_boosters(element, n)
|
||||||
if got.is_empty():
|
if got.is_empty():
|
||||||
msg_label.text = "Nicht genug Münzen"
|
|
||||||
msg_label.add_theme_color_override("font_color", Style.RED)
|
|
||||||
return
|
return
|
||||||
app.save()
|
app.save()
|
||||||
app.sfx.play("play")
|
app.sfx.play("play")
|
||||||
|
if n > 1:
|
||||||
|
got.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
|
||||||
cards = got
|
cards = got
|
||||||
revealed = {}
|
revealed = {}
|
||||||
new_ids = {}
|
fresh = {}
|
||||||
for c in cards:
|
fly = true
|
||||||
if before.get(c.id, 0) == 0:
|
var tile: PackTile = pack_tiles[element]
|
||||||
new_ids[c.id] = true
|
fly_from = tile.position + tile.size / 2
|
||||||
var notes: Array[String] = p.take_notices()
|
last_pack = element
|
||||||
msg_label.text = " · ".join(notes)
|
scroll.scroll_vertical = 0
|
||||||
msg_label.add_theme_color_override("font_color", Style.GREEN)
|
|
||||||
refresh()
|
refresh()
|
||||||
|
_count_coins(before, p.coins)
|
||||||
|
|
||||||
|
|
||||||
|
## Die Münzen zählen sichtbar herunter.
|
||||||
|
func _count_coins(from: int, to: int) -> void:
|
||||||
|
if not Style.animations or from == to:
|
||||||
|
return
|
||||||
|
create_tween().tween_method(func(v: float): coins_label.text = "Münzen: %d" % int(round(v)), float(from), float(to), 0.5)
|
||||||
|
|
||||||
|
|
||||||
func reveal(i: int) -> void:
|
func reveal(i: int) -> void:
|
||||||
revealed[i] = true
|
revealed[i] = true
|
||||||
app.sfx.play("click")
|
fresh = {i: 0.0}
|
||||||
|
app.sfx.play("combo" if cards[i].rarity != "Common" else "click")
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
## Deckt alles auf, von vorn nach hinten gestaffelt; ab Ultra Rare bleibt die Karte kurz allein.
|
||||||
func reveal_all() -> void:
|
func reveal_all() -> void:
|
||||||
for i in 5:
|
fresh = {}
|
||||||
|
var delay := 0.0
|
||||||
|
var step := 0.12 if cards.size() <= Profile.BOOSTER_SIZE else 0.06
|
||||||
|
for i in cards.size():
|
||||||
|
if not revealed.has(i):
|
||||||
|
fresh[i] = delay
|
||||||
|
delay += step
|
||||||
|
if Cards.RARITIES.find(cards[i].rarity) >= 3:
|
||||||
|
delay += 0.35
|
||||||
revealed[i] = true
|
revealed[i] = true
|
||||||
|
app.sfx.play("play")
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
## Nimmt die vorgeschlagene Karte ins aktive Deck (ersetzt die schwächste).
|
## Umdrehen: die Karte wächst aus der Mitte; seltene Stufen blitzen in ihrer Farbe auf.
|
||||||
func improve() -> void:
|
func flip(v: CardView, delay: float, rarity: String) -> void:
|
||||||
if suggestion == "":
|
if not Style.animations:
|
||||||
return
|
return
|
||||||
var err: String = app.profile.improve_deck_with(suggestion)
|
v.scale.x = 0.0
|
||||||
if err == "":
|
var tw := create_tween()
|
||||||
app.save()
|
tw.tween_interval(delay)
|
||||||
msg_label.text = tr("%s ist jetzt im Deck %s") % [Cards.by_id(suggestion).title(), app.profile.deck_name()]
|
tw.tween_property(v, "scale:x", 1.0, 0.18).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||||
msg_label.add_theme_color_override("font_color", Style.GREEN)
|
if rarity == "Common":
|
||||||
suggestion = ""
|
return
|
||||||
else:
|
var burst := Style.panel(slots, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12)
|
||||||
msg_label.text = err
|
burst.pivot_offset = burst.size / 2
|
||||||
msg_label.add_theme_color_override("font_color", Style.RED)
|
burst.modulate.a = 0.0
|
||||||
refresh()
|
var bt := create_tween()
|
||||||
|
bt.tween_interval(delay + 0.1)
|
||||||
|
bt.tween_property(burst, "modulate:a", 1.0, 0.05)
|
||||||
|
bt.parallel().tween_property(burst, "scale", Vector2(1.25, 1.25), 0.45).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||||
|
bt.tween_property(burst, "modulate:a", 0.0, 0.3)
|
||||||
|
bt.tween_callback(burst.queue_free)
|
||||||
|
|
||||||
|
|
||||||
|
## Einflug einer verdeckten Karte aus dem Pack auf ihren Platz, reihenweise gestaffelt.
|
||||||
|
func fly_in(back: Control, target: Vector2, i: int, per_row: int) -> void:
|
||||||
|
if not Style.animations:
|
||||||
|
return
|
||||||
|
back.position = fly_from - AREA.position - back.size / 2
|
||||||
|
back.scale = Vector2(0.4, 0.4)
|
||||||
|
var tw := create_tween().set_parallel()
|
||||||
|
var delay := (i / per_row) * 0.1 + (i % per_row) * 0.03
|
||||||
|
tw.tween_property(back, "position", target, 0.3).set_delay(delay).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||||
|
tw.tween_property(back, "scale", Vector2.ONE, 0.3).set_delay(delay).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||||
|
|
||||||
|
|
||||||
|
## Platz der i-ten Karte: ein Pack fünf große in einer Reihe, sonst zehn kleine je Reihe.
|
||||||
|
func slot_rect(i: int) -> Rect2:
|
||||||
|
var single := cards.size() <= Profile.BOOSTER_SIZE
|
||||||
|
var w := CARD_W if single else SMALL_W
|
||||||
|
var h := CARD_H if single else SMALL_H
|
||||||
|
var per_row := 5 if single else 10
|
||||||
|
var gap := 20 if single else 12
|
||||||
|
var x0 := (AREA.size.x - per_row * w - (per_row - 1) * gap) / 2
|
||||||
|
var row_h := h + (60 if single else 44)
|
||||||
|
return Rect2(x0 + (i % per_row) * (w + gap), 10 + (i / per_row) * row_h, w, h)
|
||||||
|
|
||||||
|
|
||||||
|
## Fazit nach dem Aufdecken: neue Karten, Aufwertungen, Staub.
|
||||||
|
func summary() -> String:
|
||||||
|
if cards.is_empty() or revealed.size() < cards.size():
|
||||||
|
return ""
|
||||||
|
var fresh_n := 0
|
||||||
|
var up := 0
|
||||||
|
var dust := 0
|
||||||
|
for res in cards:
|
||||||
|
if res.get("new", false):
|
||||||
|
fresh_n += 1
|
||||||
|
elif res.upgraded:
|
||||||
|
up += 1
|
||||||
|
dust += int(res.dust)
|
||||||
|
return tr("%d neu · %d aufgewertet · +%d Staub") % [fresh_n, up, dust]
|
||||||
|
|
||||||
|
|
||||||
func refresh() -> void:
|
func refresh() -> void:
|
||||||
var p: Profile = app.profile
|
var p: Profile = app.profile
|
||||||
coins_label.text = "Münzen: %d" % p.coins
|
coins_label.text = "Münzen: %d" % p.coins
|
||||||
b_buy.disabled = p.coins < Profile.BOOSTER_COST
|
var open: Array = p.unlocked_elements()
|
||||||
for b in element_buttons:
|
var x0 := (1280 - open.size() * 220 - (open.size() - 1) * 10) / 2
|
||||||
b.disabled = p.coins < Profile.ELEMENT_BOOSTER_COST
|
for e in pack_tiles:
|
||||||
b_all.disabled = cards.is_empty() or revealed.size() >= 5
|
var tile: PackTile = pack_tiles[e]
|
||||||
# Deck-Vorschlag, sobald alle Karten offen liegen
|
tile.locked = not p.booster_unlocked(e)
|
||||||
suggestion = ""
|
tile.visible = not tile.locked
|
||||||
if not cards.is_empty() and revealed.size() >= 5:
|
if not tile.locked:
|
||||||
var ids: Array = cards.map(func(c): return c.id)
|
tile.position = Vector2(x0 + open.find(e) * 230, 644)
|
||||||
var better: Array = p.deck_suggestions(ids)
|
tile.hint = tr("%d Münzen") % p.pack_price(e)
|
||||||
if not better.is_empty():
|
for k in QUANTITIES.size():
|
||||||
suggestion = better[0]
|
tile.buy_buttons[k].disabled = tile.locked or p.coins < p.pack_price(e) * QUANTITIES[k]
|
||||||
b_improve.visible = suggestion != ""
|
tile.pulse = e == last_pack and not cards.is_empty() and revealed.size() >= cards.size()
|
||||||
if suggestion != "":
|
tile.queue_redraw()
|
||||||
b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).title(), p.deck_name()]
|
b_all.disabled = cards.is_empty() or revealed.size() >= cards.size()
|
||||||
|
summary_label.text = summary()
|
||||||
Style.clear(slots)
|
Style.clear(slots)
|
||||||
for i in 5:
|
var single := cards.size() <= Profile.BOOSTER_SIZE
|
||||||
|
var per_row := 5 if single else 10
|
||||||
|
var n := maxi(cards.size(), Profile.BOOSTER_SIZE)
|
||||||
|
var rows := (n + per_row - 1) / per_row
|
||||||
|
slots.custom_minimum_size = Vector2(AREA.size.x, maxf(AREA.size.y, slot_rect(n - 1).end.y + 60))
|
||||||
|
for i in n:
|
||||||
var r := slot_rect(i)
|
var r := slot_rect(i)
|
||||||
if cards.is_empty():
|
if cards.is_empty():
|
||||||
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
|
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
|
||||||
elif revealed.has(i):
|
elif revealed.has(i):
|
||||||
var c: Cards.Card = cards[i]
|
var res: Dictionary = cards[i]
|
||||||
var v := CardView.new(c, r.size)
|
var c: Cards.Card = res.card
|
||||||
|
var v := CardView.new(c.leveled(Cards.RARITIES.find(res.rarity)), r.size, false, res.rarity)
|
||||||
v.position = r.position
|
v.position = r.position
|
||||||
slots.add_child(v)
|
slots.add_child(v)
|
||||||
Style.label(slots, tr(c.rarity), 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
|
if fresh.has(i):
|
||||||
if new_ids.has(c.id):
|
flip(v, fresh[i], res.rarity)
|
||||||
Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
|
var fs := 16 if single else 12
|
||||||
|
Style.label(slots, tr(res.rarity), fs, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x - 10, r.end.y + 6, r.size.x + 20, 20), "center", true)
|
||||||
|
var y2 := r.end.y + (30 if single else 24)
|
||||||
|
if res.get("new", false):
|
||||||
|
Style.label(slots, "Neu!", fs, Style.GOLD, Rect2(r.position.x, y2, r.size.x, 20), "center", true)
|
||||||
|
elif res.upgraded:
|
||||||
|
Style.label(slots, "Aufgewertet", fs, Style.GREEN, Rect2(r.position.x - 10, y2, r.size.x + 20, 20), "center", true)
|
||||||
else:
|
else:
|
||||||
Style.label(slots, "jetzt %dx" % p.collection[c.id], 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
|
Style.label(slots, "+%d Staub" % res.dust, fs - 2, Style.MUTED, Rect2(r.position.x, y2, r.size.x, 20), "center")
|
||||||
else:
|
else:
|
||||||
var back := CardBack.new(r.size)
|
var back := CardBack.new(r.size)
|
||||||
back.position = r.position
|
back.position = r.position
|
||||||
|
back.pivot_offset = r.size / 2
|
||||||
back.clicked.connect(reveal.bind(i))
|
back.clicked.connect(reveal.bind(i))
|
||||||
slots.add_child(back)
|
slots.add_child(back)
|
||||||
|
if fly:
|
||||||
|
fly_in(back, r.position, i, per_row)
|
||||||
|
fly = false
|
||||||
|
fresh = {}
|
||||||
|
if rows > 0 and last_pack != "" and pack_tiles[last_pack].pulse and Style.animations:
|
||||||
|
var tile: PackTile = pack_tiles[last_pack]
|
||||||
|
var tw := tile.create_tween().set_loops(3)
|
||||||
|
tw.tween_property(tile, "scale", Vector2(1.04, 1.04), 0.35).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||||
|
tw.tween_property(tile, "scale", Vector2.ONE, 0.35).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||||
@@ -1,32 +1,40 @@
|
|||||||
class_name CampaignScreen
|
class_name CampaignScreen
|
||||||
extends Control
|
extends Control
|
||||||
|
## Kampagne als Pfad: zehn Stationen je Kapitel (fünf Kapitel: Neutral, dann je ein Element),
|
||||||
|
## darunter die gewählte Stufe im Detail.
|
||||||
|
|
||||||
var app
|
var app
|
||||||
var chapter := 1
|
var chapter := 1
|
||||||
|
var selected := 0 # gewählte Stufe (Index über alle Kapitel)
|
||||||
var perk_box: OptionButton
|
var perk_box: OptionButton
|
||||||
|
var node_buttons: Array = []
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
var p: Profile = app.profile
|
var p: Profile = app.profile
|
||||||
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size())
|
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTER_ELEMENTS.size())
|
||||||
|
selected = mini(p.campaign_stage, Campaign.total() - 1)
|
||||||
build()
|
build()
|
||||||
|
|
||||||
|
|
||||||
func build() -> void:
|
func build() -> void:
|
||||||
Style.clear(self)
|
Style.clear(self)
|
||||||
|
node_buttons = []
|
||||||
var p: Profile = app.profile
|
var p: Profile = app.profile
|
||||||
var col := Style.column(self, 1000, 16, 5)
|
Style.header(self, "Kampagne", func(): app.show_screen(MenuScreen.new()))
|
||||||
Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true)
|
var n := Campaign.STAGES_PER_CHAPTER
|
||||||
var tabs := Style.row(col, 10)
|
var x := 40
|
||||||
tabs.alignment = BoxContainer.ALIGNMENT_CENTER
|
for c in Campaign.CHAPTER_ELEMENTS.size():
|
||||||
for c in Campaign.CHAPTERS.size():
|
var b := Style.button(self, "Kapitel %d" % (c + 1), Rect2(x, 88, 130, 38), set_chapter.bind(c + 1), Style.PRIMARY if chapter == c + 1 else null)
|
||||||
var b := Style.cbutton(tabs, "Kapitel %d" % (c + 1), Vector2(140, 36), set_chapter.bind(c + 1), Color("285a3c") if chapter == c + 1 else null)
|
b.disabled = p.campaign_stage < c * n
|
||||||
b.disabled = p.campaign_stage < c * Campaign.STAGES.size()
|
x += 138
|
||||||
|
var element := Campaign.element_of(chapter)
|
||||||
|
Style.label(self, tr(element), 20, Cards.ELEMENT_COLORS[element], Rect2(0, 136, 1280, 30), "center", true)
|
||||||
if p.perk_unlocked():
|
if p.perk_unlocked():
|
||||||
var pr := Style.row(tabs, 8)
|
Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(740, 96, 150, 24), "right")
|
||||||
Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110)
|
|
||||||
perk_box = OptionButton.new()
|
perk_box = OptionButton.new()
|
||||||
perk_box.custom_minimum_size = Vector2(300, 36)
|
perk_box.position = Vector2(900, 88)
|
||||||
|
perk_box.size = Vector2(340, 38)
|
||||||
perk_box.add_item("keins")
|
perk_box.add_item("keins")
|
||||||
var keys: Array = Game.PERKS.keys()
|
var keys: Array = Game.PERKS.keys()
|
||||||
for k in keys:
|
for k in keys:
|
||||||
@@ -35,54 +43,82 @@ func build() -> void:
|
|||||||
perk_box.item_selected.connect(func(i: int):
|
perk_box.item_selected.connect(func(i: int):
|
||||||
p.perk = "" if i == 0 else keys[i - 1]
|
p.perk = "" if i == 0 else keys[i - 1]
|
||||||
app.save())
|
app.save())
|
||||||
pr.add_child(perk_box)
|
add_child(perk_box)
|
||||||
var offset := (chapter - 1) * Campaign.STAGES.size()
|
# Pfad mit Stationen
|
||||||
for k in Campaign.STAGES.size():
|
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 i := offset + k
|
||||||
var st := Campaign.stage(i)
|
|
||||||
var done := i < p.campaign_stage
|
var done := i < p.campaign_stage
|
||||||
var next := i == p.campaign_stage
|
var next := i == p.campaign_stage
|
||||||
var panel := PanelContainer.new()
|
var b := Button.new()
|
||||||
panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL if not done else Style.darker(Style.PANEL, 0.8), 6,
|
b.text = str(i + 1)
|
||||||
Style.GOLD if next else null, 2 if next else 0))
|
b.position = centers[k] - Vector2(24, 24)
|
||||||
col.add_child(panel)
|
b.size = Vector2(48, 48)
|
||||||
var pad := MarginContainer.new() # Innenabstand, damit Name und Knopf nicht am Rahmen kleben
|
var base: Color = Style.PRIMARY if done else (Color("5a4628") if next else Style.PANEL)
|
||||||
for side in ["left", "right"]:
|
var edge: Color = Style.GOLD if i == selected else (Style.GREEN if done else Style.MUTED)
|
||||||
pad.add_theme_constant_override("margin_" + side, 14)
|
b.add_theme_stylebox_override("normal", Style.box(base, 24, edge, 3 if i == selected else 2))
|
||||||
panel.add_child(pad)
|
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 24, edge, 3))
|
||||||
var r := Style.row(pad, 10)
|
b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 24, edge, 3))
|
||||||
r.custom_minimum_size = Vector2(0, 50)
|
b.add_theme_stylebox_override("disabled", Style.box(Style.darker(Style.PANEL, 0.8), 24, Style.PANEL_LIGHT, 2))
|
||||||
var nm := Style.clabel(r, "%s · %s" % [st.name, st.title], 16, Style.TEXT if not done else Style.MUTED, "left", true, 420)
|
b.add_theme_stylebox_override("focus", Style.box(Color(0, 0, 0, 0), 24, Style.GOLD, 3))
|
||||||
nm.size_flags_horizontal = Control.SIZE_EXPAND_FILL # schiebt die Punkte und den Knopf nach rechts
|
b.disabled = i > p.campaign_stage
|
||||||
var dots := Control.new()
|
b.pressed.connect(func():
|
||||||
dots.custom_minimum_size = Vector2(60, 0)
|
selected = i
|
||||||
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
build())
|
||||||
var elems: Array = st.elements
|
Style.juice(b)
|
||||||
dots.draw.connect(func():
|
add_child(b)
|
||||||
for m in elems.size():
|
node_buttons.append(b)
|
||||||
dots.draw_circle(Vector2(10 + m * 18, dots.size.y / 2), 7, Cards.ELEMENT_COLORS[elems[m]]))
|
if next and Style.animations:
|
||||||
r.add_child(dots)
|
b.pivot_offset = b.size / 2
|
||||||
if done:
|
var tw := b.create_tween().set_loops()
|
||||||
Style.cbutton(r, "Wiederholen", Vector2(130, 36), _start.bind(i))
|
tw.tween_property(b, "scale", Vector2(1.1, 1.1), 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||||
elif next:
|
tw.tween_property(b, "scale", Vector2.ONE, 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||||
Style.cbutton(r, "Kämpfen", Vector2(130, 36), _start.bind(i), Color("285a3c"))
|
# Gewählte Stufe
|
||||||
else:
|
var st := Campaign.stage(selected)
|
||||||
var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
|
var done := selected < p.campaign_stage
|
||||||
b.disabled = true
|
var next := selected == p.campaign_stage
|
||||||
|
Style.panel(self, Rect2(240, 270, 800, 300))
|
||||||
|
Style.label(self, "%s · %s" % [st.name, st.title], 28, Style.TEXT, Rect2(270, 290, 740, 40), "center", true)
|
||||||
|
Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 350, 740, 24), "center")
|
||||||
|
var perks: Array = st.perks
|
||||||
|
var py := 390
|
||||||
|
for k in perks:
|
||||||
|
Style.label(self, "★ " + tr(Game.PERKS[k]), 15, Style.GOLD, Rect2(270, py, 740, 22), "center")
|
||||||
|
py += 24
|
||||||
|
if done:
|
||||||
|
Style.button(self, "Wiederholen", Rect2(540, 500, 200, 50), _start.bind(selected))
|
||||||
|
elif next:
|
||||||
|
var b := Style.button(self, "Kämpfen", Rect2(540, 500, 200, 50), _start.bind(selected), Style.PRIMARY)
|
||||||
|
b.add_theme_font_size_override("font_size", 20)
|
||||||
|
else:
|
||||||
|
var b := Style.button(self, "Gesperrt", Rect2(540, 500, 200, 50), func(): pass)
|
||||||
|
b.disabled = true
|
||||||
if p.campaign_stage >= Campaign.total():
|
if p.campaign_stage >= Campaign.total():
|
||||||
Style.clabel(col, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true)
|
Style.label(self, "Alle Kapitel abgeschlossen", 16, Style.GREEN, Rect2(0, 600, 1280, 24), "center", true)
|
||||||
var br := Style.row(col)
|
|
||||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
||||||
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))
|
|
||||||
|
|
||||||
|
|
||||||
func set_chapter(c: int) -> void:
|
func set_chapter(c: int) -> void:
|
||||||
chapter = c
|
chapter = c
|
||||||
|
var p: Profile = app.profile
|
||||||
|
var first := (c - 1) * Campaign.STAGES_PER_CHAPTER
|
||||||
|
selected = p.campaign_stage if Campaign.chapter(mini(p.campaign_stage, Campaign.total() - 1)) == c else first
|
||||||
build()
|
build()
|
||||||
|
|
||||||
|
|
||||||
func _start(stage: int) -> void:
|
func _start(stage: int) -> void:
|
||||||
app.show_screen(BattleScreen.new(0, stage))
|
app.show_screen(BattleScreen.new(stage))
|
||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
|
|||||||
+9
-14
@@ -1,14 +1,13 @@
|
|||||||
class_name CardGrid
|
class_name CardGrid
|
||||||
extends ScrollContainer
|
extends ScrollContainer
|
||||||
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte.
|
## Scrollbares Kartenraster.
|
||||||
|
|
||||||
signal card_clicked(card: Cards.Card)
|
signal card_clicked(card: Cards.Card)
|
||||||
signal card_hover(card: Cards.Card, entered: bool)
|
|
||||||
|
|
||||||
const COLS := 7
|
const COLS := 6
|
||||||
const CW := 100
|
const CW := 118
|
||||||
const CH := 140
|
const CH := 165
|
||||||
const GAP := 12
|
const GAP := 10
|
||||||
|
|
||||||
var grid: GridContainer
|
var grid: GridContainer
|
||||||
|
|
||||||
@@ -25,20 +24,16 @@ func _init(rect: Rect2) -> void:
|
|||||||
add_child(grid)
|
add_child(grid)
|
||||||
|
|
||||||
|
|
||||||
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool
|
## cards: Karten; rarity_fn(card) -> String: die Stufe, die die Karte zeigt (Optik)
|
||||||
func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
|
func fill(cards: Array, rarity_fn: Callable) -> void:
|
||||||
Style.clear(grid)
|
Style.clear(grid)
|
||||||
for c in cards:
|
for c in cards:
|
||||||
var cell := Control.new()
|
var cell := Control.new()
|
||||||
cell.custom_minimum_size = Vector2(CW, CH + 16)
|
cell.custom_minimum_size = Vector2(CW, CH)
|
||||||
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
var v := CardView.new(c, Vector2(CW, CH), true)
|
var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c))
|
||||||
v.dim = dim_fn.call(c)
|
|
||||||
v.clicked.connect(func(_view): card_clicked.emit(c))
|
v.clicked.connect(func(_view): card_clicked.emit(c))
|
||||||
v.hover.connect(func(card, entered): card_hover.emit(card, entered))
|
|
||||||
cell.add_child(v)
|
cell.add_child(v)
|
||||||
var text: String = label_fn.call(c)
|
|
||||||
Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center")
|
|
||||||
grid.add_child(cell)
|
grid.add_child(cell)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+70
-45
@@ -1,23 +1,40 @@
|
|||||||
class_name CardView
|
class_name CardView
|
||||||
extends Control
|
extends Control
|
||||||
## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau).
|
## Zeichnet eine Karte (Hand, Sammlung, Booster, Vorschau).
|
||||||
|
## Oben Kosten (links) und Name, in der Mitte das Bild im Kreis, unten Angriff (links) und
|
||||||
|
## Leben (rechts). Die Elemente färben den Hintergrund (verschmolzen: ein Streifen je Element),
|
||||||
|
## das Bild trägt bei Verschmelzungen der stärkere Elternteil; die Seltenheit steckt in Name,
|
||||||
|
## Bild, Werten und Rahmen (siehe Style).
|
||||||
|
|
||||||
signal clicked(view: CardView)
|
signal clicked(view: CardView)
|
||||||
|
signal pressed(view: CardView, pos: Vector2) # nur mit drag_enabled: der Kampf macht daraus Klick oder Ziehen
|
||||||
signal hover(card: Cards.Card, entered: bool)
|
signal hover(card: Cards.Card, entered: bool)
|
||||||
|
|
||||||
var card: Cards.Card
|
var card: Cards.Card
|
||||||
var border = null # Color oder null (dann Seltenheitsfarbe)
|
var border = null # Color oder null (dann Rahmen der Seltenheitsstufe)
|
||||||
|
var rarity := "Common" # nur Optik
|
||||||
var dim := false
|
var dim := false
|
||||||
|
var lift := false # hebt sich beim Zeigen an (Hand)
|
||||||
|
var drag_enabled := false # Hand im Kampf: Drücken meldet pressed statt clicked
|
||||||
|
var drop := false # Ziel der gerade gezogenen Karte
|
||||||
|
var marked := false # Haken: die Karte ist im Stapel (Deck)
|
||||||
|
|
||||||
|
|
||||||
## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
|
## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
|
||||||
func _init(c: Cards.Card, sz: Vector2, focusable: bool = false) -> void:
|
func _init(c: Cards.Card, sz: Vector2, focusable: bool = false, rar: String = "Common") -> void:
|
||||||
card = c
|
card = c
|
||||||
|
rarity = rar
|
||||||
|
set_process(Style.rarity_animated(rarity)) # bewegter Glanz
|
||||||
custom_minimum_size = sz
|
custom_minimum_size = sz
|
||||||
size = sz
|
size = sz
|
||||||
|
pivot_offset = sz / 2
|
||||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
mouse_entered.connect(func(): hover.emit(card, true))
|
mouse_entered.connect(func():
|
||||||
mouse_exited.connect(func(): hover.emit(card, false))
|
hover.emit(card, true)
|
||||||
|
_lift(true))
|
||||||
|
mouse_exited.connect(func():
|
||||||
|
hover.emit(card, false)
|
||||||
|
_lift(false))
|
||||||
if focusable:
|
if focusable:
|
||||||
focus_mode = Control.FOCUS_ALL
|
focus_mode = Control.FOCUS_ALL
|
||||||
focus_entered.connect(func():
|
focus_entered.connect(func():
|
||||||
@@ -28,10 +45,24 @@ func _init(c: Cards.Card, sz: Vector2, focusable: bool = false) -> void:
|
|||||||
queue_redraw())
|
queue_redraw())
|
||||||
|
|
||||||
|
|
||||||
|
func _lift(up: bool) -> void:
|
||||||
|
if not lift or not Style.animations:
|
||||||
|
return
|
||||||
|
z_index = 1 if up else 0
|
||||||
|
create_tween().tween_property(self, "scale", Vector2(1.07, 1.07) if up else Vector2.ONE, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
func _gui_input(event: InputEvent) -> void:
|
func _gui_input(event: InputEvent) -> void:
|
||||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
accept_event()
|
accept_event()
|
||||||
clicked.emit(self)
|
if drag_enabled:
|
||||||
|
pressed.emit(self, event.global_position)
|
||||||
|
else:
|
||||||
|
clicked.emit(self)
|
||||||
elif has_focus() and event.is_action_pressed("ui_accept"):
|
elif has_focus() and event.is_action_pressed("ui_accept"):
|
||||||
accept_event()
|
accept_event()
|
||||||
clicked.emit(self)
|
clicked.emit(self)
|
||||||
@@ -39,48 +70,42 @@ func _gui_input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
var rect := Rect2(Vector2.ZERO, size)
|
var rect := Rect2(Vector2.ZERO, size)
|
||||||
var big := size.x >= 180
|
var seed := hash(card.id)
|
||||||
var mid := size.x >= 115
|
var frame: Color = border if border != null else Style.rarity_frame(rarity, seed)
|
||||||
var f_name := 18 if big else (14 if mid else 12)
|
if rarity != "Common":
|
||||||
var f_text := 14 if big else (11 if mid else 10)
|
Style.glow(self, rect, Style.rarity_color(rarity, seed), 8, 8 if size.x >= 180 else 6)
|
||||||
var f_stat := 24 if big else (18 if mid else 15)
|
|
||||||
Style.fill_elements(self, rect, card.elements, 8)
|
Style.fill_elements(self, rect, card.elements, 8)
|
||||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[card.rarity]
|
var big := size.x >= 180
|
||||||
Style.border(self, rect, bcol, 3 if big else 2, 8)
|
var mid := size.x >= 140
|
||||||
# Kosten
|
var f_name := 17 if big else (14 if mid else 11)
|
||||||
var r := 16 if big else 12
|
var f_stat := 20 if big else (17 if mid else 14)
|
||||||
var cc := Vector2(r + 4, r + 4)
|
var r_cost := 17.0 if big else (14.0 if mid else 12.0)
|
||||||
draw_circle(cc, r, Color("14141c"))
|
var r_stat := 18.0 if big else (15.0 if mid else 12.0)
|
||||||
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true)
|
var pad := 8.0 if mid else 6.0
|
||||||
# Name und Kombo
|
var shiny := Style.rarity_stats_shiny(rarity)
|
||||||
var name_left := 2 * r + 10
|
# Oben: Kosten links, daneben der Name; das Namensfeld ist genau so hoch wie der Kristall
|
||||||
var nm := Style.fit(card.title(), true, f_name, size.x - name_left - 6)
|
var top_h := r_cost * 2 + 4
|
||||||
Style.text(self, nm, f_name, Style.TEXT, Vector2((name_left + size.x - 6) / 2.0, 4), "midtop", true)
|
draw_rect(Rect2(2, 2, size.x - 4, top_h), Color(0.05, 0.05, 0.08, 0.6))
|
||||||
if mid:
|
Style.card_name(self, card.title(), f_name, rarity, Rect2(r_cost * 2 + 10, 2, size.x - r_cost * 2 - pad - 10, top_h), seed)
|
||||||
var sub: String = (tr(card.combo()) + " · " + tr(card.kind)) if card.is_combo() else tr(card.kind)
|
# Mitte: das Bild im Kreis, so groß wie der Platz bis zu den Werten erlaubt
|
||||||
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop")
|
var pic_top := top_h + 4
|
||||||
# Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest
|
var pic_bottom := size.y - r_stat - 4
|
||||||
var tsize := Style.fs(f_text)
|
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
|
||||||
var text_w: float = size.x - 20
|
Style.picture(self, card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rarity, seed)
|
||||||
var needed: float = Style.FONT.get_multiline_string_size(card.text(), HORIZONTAL_ALIGNMENT_LEFT, text_w, tsize).y + 8 if card.text() != "" else size.y * 0.42
|
Style.rarity_sparkle(self, rect, rarity, seed) # vor den Werten, damit nichts die Zahlen überlagert
|
||||||
var box_bottom: float = size.y - (28 if big else 22)
|
# Unten: Angriff links, Leben rechts
|
||||||
var box_top: float = maxf(size.y * 0.30, box_bottom - maxf(needed, size.y * 0.42))
|
Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat, Color.WHITE, shiny)
|
||||||
var box := Rect2(6, box_top, size.x - 12, box_bottom - box_top)
|
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)
|
||||||
# Bild
|
Style.card_sheen(self, rect, rarity, seed)
|
||||||
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0)
|
Style.border(self, rect, frame, 3 if big else 2, 8)
|
||||||
var band_bottom: float = box_top - 4
|
Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, card.cost, f_stat, shiny)
|
||||||
if band_bottom - band_top > 14:
|
if marked:
|
||||||
var gsize: float = minf(band_bottom - band_top, size.x * 0.42)
|
var mc := Vector2(size.x / 2, size.y - r_stat + 2)
|
||||||
Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize)
|
draw_circle(mc, r_stat * 0.6, Style.GREEN)
|
||||||
draw_rect(box, Color(0.06, 0.06, 0.09, 0.67))
|
Style.text(self, "✓", f_stat - 4, Color.WHITE, mc, "center", true)
|
||||||
var max_lines := maxi(1, int((box.size.y - 6) / Style.FONT.get_height(tsize))) # große Schrift: lieber kürzen als überlaufen
|
|
||||||
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)),
|
|
||||||
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
|
|
||||||
# Werte
|
|
||||||
if card.is_creature():
|
|
||||||
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
|
|
||||||
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
|
|
||||||
if dim:
|
if dim:
|
||||||
draw_rect(rect, Color(0, 0, 0, 0.47))
|
draw_rect(rect, Color(0, 0, 0, 0.47))
|
||||||
if has_focus():
|
if has_focus():
|
||||||
Style.border(self, rect, Style.GOLD, 4, 8)
|
Style.border(self, rect, Style.GOLD, 4, 8)
|
||||||
|
if drop:
|
||||||
|
Style.border(self, rect, Style.GOLD, 5, 8)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
class_name CheatScreen
|
||||||
|
extends Control
|
||||||
|
## Cheats zum Ausprobieren: Münzen, Staub, Level, Skills, Kampagnenkapitel und Sammlung direkt setzen.
|
||||||
|
## Jeder Knopf speichert sofort; oben steht der aktuelle Stand.
|
||||||
|
|
||||||
|
var app
|
||||||
|
var status_label: Label
|
||||||
|
var msg_label: Label
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
Style.header(self, "Cheats", func(): app.show_screen(MenuScreen.new()))
|
||||||
|
status_label = Style.label(self, "", 16, Style.GOLD, Rect2(40, 90, 1200, 48), "left", true)
|
||||||
|
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||||
|
var groups := [
|
||||||
|
["Währung", [["+1.000 Münzen", func(): app.profile.coins += 1000], ["+10.000 Münzen", func(): app.profile.coins += 10000],
|
||||||
|
["+1.000 Staub", func(): app.profile.dust += 1000], ["+10.000 Staub", func(): app.profile.dust += 10000]]],
|
||||||
|
["Erfahrung", [["+1 Level", func(): app.profile.xp = Skills.xp_for_level(app.profile.player_level() + 1)],
|
||||||
|
["+5 Level", func(): app.profile.xp = Skills.xp_for_level(app.profile.player_level() + 5)],
|
||||||
|
["Skills zurücksetzen", func(): app.profile.skills = {}], ["Erfahrung auf 0", func():
|
||||||
|
app.profile.xp = 0
|
||||||
|
app.profile.skills = {}]]],
|
||||||
|
["Kampagne", [["Kapitel 2 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 10)],
|
||||||
|
["Kapitel 3 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 20)],
|
||||||
|
["Kapitel 4 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 30)],
|
||||||
|
["Kapitel 5 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 40)],
|
||||||
|
["Kampagne zurücksetzen", func(): app.profile.campaign_stage = 0]]],
|
||||||
|
["Sammlung", [["Alle Karten (Common)", func(): _give_all(0)], ["Alle Karten Ultimate Rare", func(): _give_all(4)],
|
||||||
|
["Eigene Karten +1 Stufe", func():
|
||||||
|
for id in app.profile.collection:
|
||||||
|
app.profile.collection[id] = mini(int(app.profile.collection[id]) + 1, Cards.RARITIES.size() - 1)],
|
||||||
|
["Sammlung zurücksetzen", func(): app.profile.give_start()]]],
|
||||||
|
]
|
||||||
|
var y := 160
|
||||||
|
for g in groups:
|
||||||
|
Style.label(self, g[0], 18, Style.TEXT, Rect2(40, y, 200, 30), "left", true)
|
||||||
|
var x := 250
|
||||||
|
for entry in g[1]:
|
||||||
|
var b := Style.button(self, entry[0], Rect2(x, y - 4, 190, 40), _apply.bind(entry[1]))
|
||||||
|
b.add_theme_font_size_override("font_size", 14)
|
||||||
|
x += 198
|
||||||
|
y += 70
|
||||||
|
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(40, y + 10, 1200, 24), "left")
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
app.show_screen(MenuScreen.new())
|
||||||
|
|
||||||
|
|
||||||
|
## Alle Karten der Sammlung geben, in Stufe level; der Stapel bleibt.
|
||||||
|
func _give_all(level: int) -> void:
|
||||||
|
for c in Cards.all():
|
||||||
|
app.profile.collection[c.id] = level
|
||||||
|
|
||||||
|
|
||||||
|
func _apply(action: Callable) -> void:
|
||||||
|
action.call()
|
||||||
|
app.save()
|
||||||
|
app.sfx.play("click")
|
||||||
|
msg_label.text = tr("Gespeichert")
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func refresh() -> void:
|
||||||
|
var p: Profile = app.profile
|
||||||
|
status_label.text = tr("Münzen %d · Staub %d · EP %d (Level %d, %d Skillpunkte frei) · Kampagne %d/%d · Sammlung %d Karten, Stapel %d") % [
|
||||||
|
p.coins, p.dust, p.xp, p.player_level(), p.skill_points(), p.campaign_stage, Campaign.total(), p.collection.size(), p.deck.size()]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dluwr50n0sd17
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
class_name CollectionScreen
|
class_name CollectionScreen
|
||||||
extends Control
|
extends Control
|
||||||
## Sammlung: alle Karten mit Besitzstand; ausgewählte Karten lassen sich
|
## Sammlung: alle Karten des Spiels, die fehlenden abgedunkelt. Eigene Karten lassen sich
|
||||||
## zerlegen (Staub) oder herstellen.
|
## aufwerten (Staub) oder zerlegen (bringt Staub), fehlende herstellen (Staub).
|
||||||
|
|
||||||
var app
|
var app
|
||||||
var filter := ""
|
var filter := ""
|
||||||
@@ -14,41 +14,39 @@ var msg_label: Label
|
|||||||
var preview_holder: Control
|
var preview_holder: Control
|
||||||
var info_label: Label
|
var info_label: Label
|
||||||
var b_dismantle: Button
|
var b_dismantle: Button
|
||||||
|
var b_upgrade: Button
|
||||||
var b_craft: Button
|
var b_craft: Button
|
||||||
var b_extras: Button
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
Style.label(self, "Sammlung", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
|
Style.header(self, "Sammlung", func(): app.show_screen(MenuScreen.new()))
|
||||||
Style.label(self, "Karte anklicken, dann zerlegen oder herstellen. Überschuss: alles über %d Kopien." % Game.MAX_COPIES,
|
Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
|
||||||
14, Style.MUTED, Rect2(240, 18, 600, 24))
|
var filters: Array = app.profile.unlocked_elements() # nur Elemente offener Kapitel
|
||||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
for i in filters.size():
|
||||||
for i in Cards.ELEMENTS.size():
|
var e: String = filters[i]
|
||||||
var e: String = Cards.ELEMENTS[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))
|
||||||
var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
|
||||||
fb.add_theme_font_size_override("font_size", 14)
|
fb.add_theme_font_size_override("font_size", 14)
|
||||||
search_box = LineEdit.new()
|
search_box = LineEdit.new()
|
||||||
search_box.position = Vector2(424, 55)
|
search_box.position = Vector2(526, 86)
|
||||||
search_box.size = Vector2(200, 34)
|
search_box.size = Vector2(190, 34)
|
||||||
search_box.placeholder_text = "Suche"
|
search_box.placeholder_text = "Suche"
|
||||||
search_box.text_changed.connect(func(t: String):
|
search_box.text_changed.connect(func(t: String):
|
||||||
search = t
|
search = t
|
||||||
refresh())
|
refresh())
|
||||||
add_child(search_box)
|
add_child(search_box)
|
||||||
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
|
grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
|
||||||
grid.card_clicked.connect(select)
|
grid.card_clicked.connect(select)
|
||||||
add_child(grid)
|
add_child(grid)
|
||||||
Style.panel(self, Rect2(845, 50, 420, 680))
|
Style.panel(self, Rect2(820, 86, 420, 696))
|
||||||
dust_label = Style.label(self, "", 22, Style.BLUE, Rect2(860, 58, 390, 30), "left", true)
|
dust_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
|
||||||
preview_holder = Control.new()
|
preview_holder = Control.new()
|
||||||
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
add_child(preview_holder)
|
add_child(preview_holder)
|
||||||
info_label = Style.label(self, "", 14, Style.MUTED, Rect2(860, 396, 390, 66), "center")
|
info_label = Style.label(self, "", 15, Style.MUTED, Rect2(840, 476, 380, 30), "center")
|
||||||
b_dismantle = Style.button(self, "", Rect2(860, 470, 390, 44), dismantle, Color("5a3c28"))
|
b_upgrade = Style.button(self, "", Rect2(840, 530, 380, 46), upgrade, Style.PRIMARY)
|
||||||
b_craft = Style.button(self, "", Rect2(860, 524, 390, 44), craft, Color("285a3c"))
|
b_dismantle = Style.button(self, "", Rect2(840, 586, 380, 46), dismantle, Color("5a3c28"))
|
||||||
b_extras = Style.button(self, "", Rect2(860, 600, 390, 44), dismantle_extras)
|
b_craft = Style.button(self, "", Rect2(840, 530, 380, 46), craft, Style.PRIMARY)
|
||||||
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 660, 390, 48), "center")
|
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(840, 650, 380, 48), "center")
|
||||||
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
|
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
@@ -62,13 +60,23 @@ func set_filter(e: String) -> void:
|
|||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
## Karten der offenen Elemente: eigene zuerst (höhere Stufen vorn), dann die fehlenden, sonst in
|
||||||
|
## Kartenreihenfolge.
|
||||||
func cards() -> Array:
|
func cards() -> Array:
|
||||||
var out: Array = Cards.all().duplicate()
|
var out: Array = app.profile.unlocked_cards()
|
||||||
if filter != "":
|
if filter != "":
|
||||||
out = out.filter(func(c): return filter in c.elements)
|
out = out.filter(func(c): return filter in c.elements)
|
||||||
if search != "":
|
if search != "":
|
||||||
var q := search.to_lower()
|
var q := search.to_lower()
|
||||||
out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
|
out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
|
||||||
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -83,14 +91,28 @@ func say(m: String, ok: bool) -> void:
|
|||||||
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
|
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:
|
func dismantle() -> void:
|
||||||
if selected == null:
|
if selected == null:
|
||||||
return
|
return
|
||||||
|
var lvl: int = app.profile.level(selected.id)
|
||||||
var err: String = app.profile.dismantle(selected.id)
|
var err: String = app.profile.dismantle(selected.id)
|
||||||
if err == "":
|
if err == "":
|
||||||
app.sfx.play("click")
|
app.sfx.play("click")
|
||||||
app.save()
|
app.save()
|
||||||
say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[selected.rarity]], true)
|
say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]], true)
|
||||||
else:
|
else:
|
||||||
say(err, false)
|
say(err, false)
|
||||||
refresh()
|
refresh()
|
||||||
@@ -101,53 +123,53 @@ func craft() -> void:
|
|||||||
return
|
return
|
||||||
var err: String = app.profile.craft(selected.id)
|
var err: String = app.profile.craft(selected.id)
|
||||||
if err == "":
|
if err == "":
|
||||||
app.sfx.play("play")
|
app.sfx.play("combo")
|
||||||
app.save()
|
app.save()
|
||||||
var notes: Array[String] = app.profile.take_notices()
|
say(tr("%s hergestellt") % selected.title(), true)
|
||||||
say(tr("%s hergestellt") % selected.title() + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
|
|
||||||
else:
|
else:
|
||||||
say(err, false)
|
say(err, false)
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
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:
|
func refresh() -> void:
|
||||||
var p: Profile = app.profile
|
var p: Profile = app.profile
|
||||||
dust_label.text = "Staub: %d" % p.dust
|
dust_label.text = "Staub: %d" % p.dust
|
||||||
grid.fill(cards(),
|
var shown := cards()
|
||||||
func(c): return "%dx" % p.collection.get(c.id, 0) if p.collection.get(c.id, 0) > 0 else "fehlt",
|
grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
|
||||||
func(c): return p.collection.get(c.id, 0) == 0)
|
var views := grid.views()
|
||||||
|
for i in views.size():
|
||||||
|
views[i].dim = not p.owns(shown[i].id)
|
||||||
Style.clear(preview_holder)
|
Style.clear(preview_holder)
|
||||||
|
b_craft.visible = false
|
||||||
|
b_upgrade.visible = true
|
||||||
|
b_dismantle.visible = true
|
||||||
if selected == null:
|
if selected == null:
|
||||||
info_label.text = "Keine Karte gewählt"
|
info_label.text = "Keine Karte gewählt"
|
||||||
|
b_upgrade.text = "Aufwerten"
|
||||||
b_dismantle.text = "Zerlegen"
|
b_dismantle.text = "Zerlegen"
|
||||||
b_craft.text = "Herstellen"
|
b_upgrade.disabled = true
|
||||||
b_dismantle.disabled = true
|
b_dismantle.disabled = true
|
||||||
b_craft.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:
|
else:
|
||||||
var v := CardView.new(selected, Vector2(200, 280))
|
var next: String = Cards.RARITIES[lvl + 1]
|
||||||
v.position = Vector2(955, 100)
|
b_upgrade.text = "Aufwerten zu %s (%d Staub)" % [tr(next), Profile.DUST_UPGRADE[next]]
|
||||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
b_upgrade.disabled = p.dust < Profile.DUST_UPGRADE[next]
|
||||||
preview_holder.add_child(v)
|
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]
|
||||||
var owned: int = p.collection.get(selected.id, 0)
|
b_dismantle.disabled = false
|
||||||
var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0})
|
|
||||||
info_label.text = "%s · %s · Wert %.1f\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [
|
|
||||||
selected.combo(), selected.rarity, Cards.card_value(selected), owned, st.played, st.games, st.wins]
|
|
||||||
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity]
|
|
||||||
b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity]
|
|
||||||
b_dismantle.disabled = owned == 0
|
|
||||||
b_craft.disabled = p.dust < Profile.DUST_CRAFT[selected.rarity]
|
|
||||||
var extras := 0
|
|
||||||
for cid in p.collection:
|
|
||||||
extras += maxi(0, p.collection[cid] - Game.MAX_COPIES)
|
|
||||||
b_extras.text = "Überschuss zerlegen (%d Karten)" % extras
|
|
||||||
b_extras.disabled = extras == 0
|
|
||||||
+100
-52
@@ -2,7 +2,8 @@ class_name CreatureView
|
|||||||
extends Control
|
extends Control
|
||||||
## Zeichnet ein Wesen auf dem Feld. Bleibt über Zustandsänderungen bestehen (für Animationen).
|
## Zeichnet ein Wesen auf dem Feld. Bleibt über Zustandsänderungen bestehen (für Animationen).
|
||||||
|
|
||||||
signal clicked(view: CreatureView)
|
signal pressed(view: CreatureView, pos: Vector2) # linke Maustaste gedrückt; der Kampf macht daraus Klick oder Ziehen
|
||||||
|
signal double_clicked(view: CreatureView) # Doppelklick: eigenes Wesen greift das Leben des Gegners an
|
||||||
signal hover(card: Cards.Card, entered: bool)
|
signal hover(card: Cards.Card, entered: bool)
|
||||||
|
|
||||||
var creature: Game.Creature
|
var creature: Game.Creature
|
||||||
@@ -11,6 +12,11 @@ var width := 2
|
|||||||
var flash := false # kurz rot: wurde gerade angegriffen
|
var flash := false # kurz rot: wurde gerade angegriffen
|
||||||
var slot: Vector2 # Zielposition im Raster
|
var slot: Vector2 # Zielposition im Raster
|
||||||
var busy := false # wird gerade animiert (kein Neupositionieren)
|
var busy := false # wird gerade animiert (kein Neupositionieren)
|
||||||
|
var matchup := 0 # fremdes Wesen: sein Konter gegen den gewählten Angreifer (Game.matchup_bonus); die Werte zeigen, was ihn erwartet
|
||||||
|
var attack_shift := 0 # eigener Angreifer: Elementvorteil gegen das Ziel unter der Maus
|
||||||
|
var doomed := false # stirbt bei dem Angriff, der gerade unter der Maus liegt
|
||||||
|
var secret := false # verdecktes fremdes Wesen: nur der Rücken ist zu sehen
|
||||||
|
var drop := false # Ziel der gerade gezogenen Karte
|
||||||
|
|
||||||
|
|
||||||
func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
|
func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
|
||||||
@@ -20,73 +26,115 @@ func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
|
|||||||
custom_minimum_size = sz
|
custom_minimum_size = sz
|
||||||
size = sz
|
size = sz
|
||||||
pivot_offset = sz / 2
|
pivot_offset = sz / 2
|
||||||
|
set_process(Style.rarity_animated(rarity())) # bewegter Glanz
|
||||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
mouse_entered.connect(func(): hover.emit(creature.card, true))
|
mouse_entered.connect(func(): hover.emit(creature.card, true))
|
||||||
mouse_exited.connect(func(): hover.emit(creature.card, false))
|
mouse_exited.connect(func(): hover.emit(creature.card, false))
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
func _gui_input(event: InputEvent) -> void:
|
func _gui_input(event: InputEvent) -> void:
|
||||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
accept_event()
|
accept_event()
|
||||||
clicked.emit(self)
|
if event.double_click:
|
||||||
|
double_clicked.emit(self)
|
||||||
|
else:
|
||||||
|
pressed.emit(self, event.global_position)
|
||||||
|
|
||||||
|
|
||||||
|
func rarity() -> String:
|
||||||
|
var owner = creature.owner()
|
||||||
|
return creature.card.rarity_in(owner.looks if owner != null else {})
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
var rect := Rect2(Vector2.ZERO, size)
|
var rect := Rect2(Vector2.ZERO, size)
|
||||||
|
if secret:
|
||||||
|
_draw_back(rect)
|
||||||
|
_draw_drop()
|
||||||
|
return
|
||||||
var small := size.x < 100
|
var small := size.x < 100
|
||||||
var mini := size.x < 60
|
var rar := rarity()
|
||||||
|
var seed := hash(creature.card.id) + creature.uid
|
||||||
|
if rar != "Common":
|
||||||
|
Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 6)
|
||||||
Style.fill_elements(self, rect, creature.card.elements, 6)
|
Style.fill_elements(self, rect, creature.card.elements, 6)
|
||||||
var bcol: Color = border if border != null else Cards.RARITY_COLORS[creature.card.rarity]
|
var bcol: Color = border if border != null else Style.rarity_frame(rar, seed)
|
||||||
if flash:
|
if flash:
|
||||||
bcol = Style.RED
|
bcol = Style.RED
|
||||||
|
var shiny := Style.rarity_stats_shiny(rar)
|
||||||
|
# Oben: Kosten links, daneben der Name; das Namensfeld ist genau so hoch wie der Kristall
|
||||||
|
var fs := 12 if small else 13
|
||||||
|
var r_cost := 10.0 if small else 11.0
|
||||||
|
var top_h := r_cost * 2 + 4
|
||||||
|
draw_rect(Rect2(2, 2, size.x - 4, top_h), Color(0.05, 0.05, 0.08, 0.6))
|
||||||
|
Style.card_name(self, creature.card.title(), fs, rar, Rect2(r_cost * 2 + 10, 2, size.x - r_cost * 2 - 16, top_h), seed)
|
||||||
|
# Mitte: das Bild im Kreis; unten Angriff links, Leben rechts
|
||||||
|
var r_stat := 12.0 if small else 14.0
|
||||||
|
var pic_top := top_h + 4
|
||||||
|
var pic_bottom := size.y - r_stat - 4
|
||||||
|
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
|
||||||
|
Style.picture(self, creature.card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rar, seed)
|
||||||
|
# Unter dem Namen links die Eigenschaften (in der Farbe des Elements, das sie gibt)
|
||||||
|
var icon := 6.0 if small else 7.0
|
||||||
|
var y := top_h + 6 + icon
|
||||||
|
for k in creature.keywords():
|
||||||
|
var col := Color("f2f2f6")
|
||||||
|
for e in creature.card.elements:
|
||||||
|
if Cards.ELEMENT_KEYWORD.get(e, "") == k:
|
||||||
|
col = Style.lighter(Cards.ELEMENT_COLORS[e], 0.45)
|
||||||
|
Style.keyword_icon(self, k, Vector2(6 + icon, y), icon, col)
|
||||||
|
y += icon * 2 + 6
|
||||||
|
Style.rarity_sparkle(self, rect, rar, seed) # vor den Werten, damit nichts die Zahlen überlagert
|
||||||
|
_draw_stats(15 if small else 17, r_stat, shiny)
|
||||||
|
Style.card_sheen(self, rect, rar, seed)
|
||||||
Style.border(self, rect, bcol, 4 if flash else width, 6)
|
Style.border(self, rect, bcol, 4 if flash else width, 6)
|
||||||
if mini:
|
Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, creature.card.cost, 13, shiny)
|
||||||
_draw_mini()
|
if creature.hidden:
|
||||||
return
|
draw_rect(rect, Color(0.05, 0.05, 0.1, 0.55))
|
||||||
var fs := 11 if small else 13
|
Style.text_outlined(self, tr("verdeckt"), 12, Style.TEXT, Vector2(size.x / 2, size.y / 2), "center", 3)
|
||||||
var nm := Style.fit(creature.card.title(), true, fs, size.x - 6)
|
if doomed:
|
||||||
Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true)
|
draw_rect(Rect2(0, size.y * 0.5 - 12, size.x, 24), Color(0.05, 0.02, 0.02, 0.8))
|
||||||
var badges: Array[String] = []
|
Style.text_outlined(self, tr("stirbt"), 14, Style.RED, Vector2(size.x / 2, size.y * 0.5), "center", 3)
|
||||||
if creature.taunt():
|
_draw_drop()
|
||||||
badges.append("Wächter")
|
|
||||||
if creature.shield:
|
|
||||||
badges.append("Schild")
|
|
||||||
if creature.attacks_per_turn() == 2:
|
|
||||||
badges.append("x2")
|
|
||||||
if creature.frozen:
|
|
||||||
badges.append("Eis")
|
|
||||||
if creature.resonance > 0:
|
|
||||||
badges.append("Resonanz")
|
|
||||||
var y := 18.0 if small else 22.0
|
|
||||||
for b in badges:
|
|
||||||
Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop")
|
|
||||||
y += 12 if small else 15
|
|
||||||
var f := 16 if small else 20
|
|
||||||
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
|
|
||||||
Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true)
|
|
||||||
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
|
|
||||||
Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true)
|
|
||||||
if creature.frozen:
|
|
||||||
draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35))
|
|
||||||
|
|
||||||
|
|
||||||
## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe.
|
## Goldener Rahmen, solange eine Karte zum Verschmelzen darüber schwebt.
|
||||||
func _draw_mini() -> void:
|
func _draw_drop() -> void:
|
||||||
var letters: Array[String] = []
|
if drop:
|
||||||
if creature.taunt():
|
Style.border(self, Rect2(Vector2.ZERO, size), Style.GOLD, 5, 6)
|
||||||
letters.append("W")
|
|
||||||
if creature.shield:
|
|
||||||
letters.append("S")
|
## Verdecktes fremdes Wesen: ein Kartenrücken.
|
||||||
if creature.attacks_per_turn() == 2:
|
func _draw_back(rect: Rect2) -> void:
|
||||||
letters.append("2")
|
var bcol: Color = border if border != null else Style.GOLD
|
||||||
if creature.frozen:
|
if flash:
|
||||||
letters.append("E")
|
bcol = Style.RED
|
||||||
if creature.resonance > 0:
|
draw_style_box(Style.box(Color("3c285a"), 6, bcol, 4 if flash else maxi(width, 2)), rect)
|
||||||
letters.append("R")
|
Style.border(self, rect.grow(-10), Color("5a3c82"), 2, 6)
|
||||||
Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true)
|
Style.text(self, "?", 36, Style.GOLD, size / 2, "center", true)
|
||||||
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW
|
|
||||||
Style.text(self, str(creature.power()), 13, atk_col, Vector2(4, size.y - 2), "bottomleft", true)
|
|
||||||
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
|
## Angriff und Leben für den Angriff, der gerade vorbereitet wird. Fremdes Wesen bei gewähltem
|
||||||
Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true)
|
## Angreifer: sein Konter gegen ihn (im Nachteil 1 weniger, im Vorteil 1 mehr, auch bei 0 Angriff),
|
||||||
if creature.frozen:
|
## das Leben bleibt echt. Eigener Angreifer über einem Ziel: sein Angriff mit Elementvorteil.
|
||||||
draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35))
|
func shown_stats() -> Array:
|
||||||
|
if matchup != 0:
|
||||||
|
return [maxi(0, creature.power() + matchup), creature.health]
|
||||||
|
if attack_shift != 0:
|
||||||
|
return [maxi(0, creature.power() + attack_shift), creature.health]
|
||||||
|
return [creature.power(), creature.health]
|
||||||
|
|
||||||
|
|
||||||
|
## Angriff gelb, Leben blau; die Zahl wird grün, wenn sie für den aktuellen Angriff steigt, rot, wenn sie sinkt.
|
||||||
|
func _draw_stats(f: int, r: float, shiny: bool) -> void:
|
||||||
|
var shown := shown_stats()
|
||||||
|
var atk: int = shown[0]
|
||||||
|
var hp: int = shown[1]
|
||||||
|
var atk_text := Style.GREEN if atk > creature.power() else (Style.RED if atk < creature.power() else Color.WHITE)
|
||||||
|
var hp_text := Style.GREEN if hp > creature.health else (Style.RED if hp < creature.health else Color.WHITE)
|
||||||
|
Style.stat_circle(self, Vector2(r - 1, size.y - r + 1), r, str(atk), Style.AMBER, f, atk_text, shiny)
|
||||||
|
Style.stat_circle(self, Vector2(size.x - r + 1, size.y - r + 1), r, str(hp), Style.LIFE, f, hp_text, shiny)
|
||||||
+207
-363
@@ -1,215 +1,145 @@
|
|||||||
class_name DeckScreen
|
class_name DeckScreen
|
||||||
extends Control
|
extends Control
|
||||||
## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten.
|
## Deck zusammenstellen: alle eigenen Karten im Raster (Filter nach Element und Mana, Stapelkarten
|
||||||
|
## zuerst, dann höchste Stufen vorn), ein Klick nimmt eine Karte in den Stapel oder wieder heraus. Stapelkarten tragen einen Haken, die anderen sind
|
||||||
|
## abgedunkelt. Rechts Stapelgröße, Stärke (mittlerer Kartenwert, 100 Grundkarten … 500 Ultimate
|
||||||
|
## Rare), Manakurve, Elementanteile und die Vorschau der gezeigten Karte. Der Stapel hält DECK_MIN
|
||||||
|
## bis DECK_MAX Karten; „Auffüllen“ nimmt die wertvollsten eigenen Karten des gewählten Filters.
|
||||||
|
|
||||||
const SORTS := ["Kosten", "Name", "Element", "Seltenheit"]
|
const PREVIEW := Rect2(940, 362, 180, 252)
|
||||||
|
const MAX_COST := 10
|
||||||
|
|
||||||
var app
|
var app
|
||||||
var slot := 0
|
var filter := "" # Element oder ""
|
||||||
var deck: Array = []
|
var cost_filter := -1 # Kosten 0 bis 10 oder -1
|
||||||
var filter := ""
|
|
||||||
var search := ""
|
|
||||||
var sort_mode := 0
|
|
||||||
var show_missing := false
|
|
||||||
var msg_label: Label
|
|
||||||
var grid: CardGrid
|
var grid: CardGrid
|
||||||
var deck_list: Control
|
var all_button: Button
|
||||||
var deck_label: Label
|
var element_buttons := {} # Element -> Knopf mit Symbol
|
||||||
var elems_label: Label
|
var cost_buttons: Array = [] # je Kosten ein Knopf
|
||||||
var valid_label: Label
|
var count_label: Label
|
||||||
var active_label: Label
|
var strength_label: Label
|
||||||
var curve: Control
|
var curve: CurveView
|
||||||
var preview: CardView
|
var share: ElementShare
|
||||||
var search_box: LineEdit
|
var preview_holder: Control
|
||||||
var sort_box: OptionButton
|
var shown_card: Cards.Card = null # Karte in der Vorschau
|
||||||
var missing_box: CheckButton
|
var msg_label: Label
|
||||||
var slot_buttons: Array = []
|
|
||||||
var b_activate: Button
|
|
||||||
var b_autofill: Button
|
|
||||||
var name_box: LineEdit
|
|
||||||
var confirm: Control
|
|
||||||
var code_dialog: Control
|
|
||||||
var code_box: LineEdit
|
|
||||||
|
|
||||||
|
|
||||||
class DeckRow extends Control:
|
## Filterknopf mit dem Symbol eines Elements.
|
||||||
signal clicked(card)
|
class ElementButton extends Button:
|
||||||
signal hover(card, entered: bool)
|
var element: String
|
||||||
var card: Cards.Card
|
|
||||||
var count: int
|
|
||||||
var lit := false
|
|
||||||
|
|
||||||
func _init(c: Cards.Card, n: int, sz: Vector2) -> void:
|
func _init(e: String) -> void:
|
||||||
card = c
|
element = e
|
||||||
count = n
|
tooltip_text = tr(e)
|
||||||
size = sz
|
|
||||||
custom_minimum_size = sz
|
|
||||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
|
||||||
focus_mode = Control.FOCUS_ALL
|
|
||||||
var light := func(on: bool):
|
|
||||||
lit = on
|
|
||||||
queue_redraw()
|
|
||||||
hover.emit(card, on)
|
|
||||||
mouse_entered.connect(light.bind(true))
|
|
||||||
mouse_exited.connect(light.bind(false))
|
|
||||||
focus_entered.connect(light.bind(true))
|
|
||||||
focus_exited.connect(light.bind(false))
|
|
||||||
|
|
||||||
func _gui_input(event: InputEvent) -> void:
|
|
||||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
|
||||||
accept_event()
|
|
||||||
clicked.emit(card)
|
|
||||||
elif has_focus() and event.is_action_pressed("ui_accept"):
|
|
||||||
accept_event()
|
|
||||||
clicked.emit(card)
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
if lit:
|
Style.element_icon(self, element, size / 2, size.y * 0.6, Style.lighter(Cards.ELEMENT_COLORS[element], 0.4))
|
||||||
draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size))
|
|
||||||
for k in card.elements.size():
|
|
||||||
draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]])
|
|
||||||
Style.text(self, "%dx %s" % [count, card.title()], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
|
|
||||||
Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true)
|
|
||||||
|
|
||||||
|
|
||||||
class EnergyCurve extends Control:
|
## Manakurve: ein Balken je Kosten 0 bis 10 mit der Zahl der Stapelkarten.
|
||||||
var cards: Array = []
|
class CurveView extends Control:
|
||||||
|
var counts: Array = []
|
||||||
|
|
||||||
|
func _init(rect: Rect2) -> void:
|
||||||
|
position = rect.position
|
||||||
|
size = rect.size
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
|
func set_counts(c: Array) -> void:
|
||||||
|
counts = c
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
Style.text(self, "Energiekurve", 12, Style.MUTED, Vector2(0, 0))
|
var n := 11
|
||||||
var counts := {}
|
var gap := 4.0
|
||||||
for c in cards:
|
var bw := (size.x - gap * (n - 1)) / n
|
||||||
var k: int = mini(c.cost, 8)
|
var top := 16.0
|
||||||
counts[k] = counts.get(k, 0) + 1
|
var base := size.y - 16.0
|
||||||
for i in range(1, 9):
|
var mx := 1
|
||||||
var n: int = counts.get(i, 0)
|
for v in counts:
|
||||||
var x := (i - 1) * 48
|
mx = maxi(mx, int(v))
|
||||||
draw_rect(Rect2(x, 104 - n * 5, 36, n * 5), Style.BLUE)
|
for i in n:
|
||||||
if n > 0:
|
var v: int = int(counts[i]) if i < counts.size() else 0
|
||||||
Style.text(self, str(n), 11, Style.TEXT, Vector2(x + 18, 102 - n * 5), "midbottom")
|
var x := i * (bw + gap)
|
||||||
Style.text(self, str(i) + ("+" if i == 8 else ""), 12, Style.MUTED, Vector2(x + 18, 108), "midtop")
|
var h := (base - top) * v / mx
|
||||||
|
draw_rect(Rect2(x, base - 2, bw, 2), Style.PANEL_LIGHT)
|
||||||
|
if v > 0:
|
||||||
|
draw_rect(Rect2(x, base - h, bw, h), Style.BLUE)
|
||||||
|
Style.text(self, str(v), 11, Style.TEXT, Vector2(x + bw / 2, base - h - 8), "center", true)
|
||||||
|
Style.text(self, str(i), 11, Style.MUTED, Vector2(x + bw / 2, base + 8), "center")
|
||||||
|
|
||||||
|
|
||||||
|
## Elementanteile des Stapels: je Element eine farbige Kachel mit der Kartenzahl.
|
||||||
|
class ElementShare extends Control:
|
||||||
|
var counts := {}
|
||||||
|
|
||||||
|
func _init(rect: Rect2) -> void:
|
||||||
|
position = rect.position
|
||||||
|
size = rect.size
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
|
||||||
|
func set_counts(c: Dictionary) -> void:
|
||||||
|
counts = c
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
func shown() -> Array:
|
||||||
|
return ElementView.all().filter(func(e): return int(counts.get(e, 0)) > 0)
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var items := shown()
|
||||||
|
if items.is_empty():
|
||||||
|
return
|
||||||
|
var w := size.x / items.size()
|
||||||
|
for i in items.size():
|
||||||
|
var e: String = items[i]
|
||||||
|
var col: Color = Cards.ELEMENT_COLORS[e]
|
||||||
|
var r := Rect2(i * w + 2, 0, w - 4, size.y)
|
||||||
|
draw_style_box(Style.box(Color(col, 0.25), 6, Color(col, 0.7), 1), r)
|
||||||
|
Style.text(self, "%s %d" % [tr(e), int(counts[e])], 12, Style.TEXT, r.get_center(), "center", true)
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
slot = app.profile.active_deck
|
Style.header(self, "Deck", func(): app.show_screen(MenuScreen.new()))
|
||||||
deck = app.profile.decks[slot].cards.duplicate()
|
all_button = Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
|
||||||
Style.label(self, "Deck bauen", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
|
var filters: Array = Cards.ELEMENTS + [Cards.NEUTRAL]
|
||||||
Style.label(self, "Klick: Karte ins Deck. Klick in der Liste: Karte entfernen. Mausrad: scrollen.",
|
for i in filters.size():
|
||||||
14, Style.MUTED, Rect2(300, 18, 700, 24))
|
var e: String = filters[i]
|
||||||
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
|
var fb := ElementButton.new(e)
|
||||||
for i in Cards.ELEMENTS.size():
|
fb.position = Vector2(108 + i * 40, 86)
|
||||||
var e: String = Cards.ELEMENTS[i]
|
fb.size = Vector2(34, 34)
|
||||||
var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
fb.pressed.connect(set_filter.bind(e))
|
||||||
fb.add_theme_font_size_override("font_size", 14)
|
Style.juice(fb)
|
||||||
search_box = LineEdit.new()
|
add_child(fb)
|
||||||
search_box.position = Vector2(424, 55)
|
element_buttons[e] = fb
|
||||||
search_box.size = Vector2(136, 34)
|
for c in MAX_COST + 1:
|
||||||
search_box.placeholder_text = "Suche"
|
var cb := Style.button(self, str(c), Rect2(330 + c * 40, 86, 36, 34), set_cost.bind(c))
|
||||||
search_box.text_changed.connect(func(t: String):
|
cb.add_theme_font_size_override("font_size", 14)
|
||||||
search = t
|
cost_buttons.append(cb)
|
||||||
refresh())
|
grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
|
||||||
add_child(search_box)
|
grid.card_clicked.connect(toggle)
|
||||||
sort_box = OptionButton.new()
|
|
||||||
sort_box.position = Vector2(570, 55)
|
|
||||||
sort_box.size = Vector2(130, 34)
|
|
||||||
for s in SORTS:
|
|
||||||
sort_box.add_item(s)
|
|
||||||
sort_box.item_selected.connect(func(i: int):
|
|
||||||
sort_mode = i
|
|
||||||
refresh())
|
|
||||||
add_child(sort_box)
|
|
||||||
missing_box = CheckButton.new()
|
|
||||||
missing_box.position = Vector2(710, 58)
|
|
||||||
missing_box.text = "Fehlende"
|
|
||||||
missing_box.toggled.connect(func(on: bool):
|
|
||||||
show_missing = on
|
|
||||||
refresh())
|
|
||||||
add_child(missing_box)
|
|
||||||
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
|
|
||||||
grid.card_clicked.connect(add)
|
|
||||||
grid.card_hover.connect(show_preview)
|
|
||||||
add_child(grid)
|
add_child(grid)
|
||||||
# Rechte Seite: Slots, Deckliste, Kurve
|
Style.panel(self, Rect2(820, 86, 420, 696))
|
||||||
Style.panel(self, Rect2(845, 50, 420, 680))
|
count_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
|
||||||
for i in Profile.DECK_SLOTS:
|
strength_label = Style.chip(self, "", Rect2(860, 144, 340, 36), Style.GOLD)
|
||||||
slot_buttons.append(Style.button(self, str(i + 1), Rect2(860 + i * 46, 58, 40, 32), select_slot.bind(i)))
|
Style.label(self, "Manakurve", 13, Style.MUTED, Rect2(850, 190, 360, 20), "center")
|
||||||
b_activate = Style.button(self, "Aktiv setzen", Rect2(1100, 58, 150, 32), activate)
|
curve = CurveView.new(Rect2(850, 212, 360, 100))
|
||||||
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)
|
add_child(curve)
|
||||||
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
|
share = ElementShare.new(Rect2(850, 322, 360, 30))
|
||||||
Style.button(self, "Speichern", Rect2(860, 740, 140, 44), save_deck, Color("285a3c"))
|
add_child(share)
|
||||||
Style.button(self, "Leeren", Rect2(1008, 740, 70, 44), clear_deck)
|
preview_holder = Control.new()
|
||||||
Style.button(self, "Code", Rect2(1086, 740, 84, 44), open_code)
|
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
Style.button(self, "Zurück", Rect2(1178, 740, 82, 44), leave)
|
add_child(preview_holder)
|
||||||
# Deck-Code zum Teilen: kopieren, einfügen, übernehmen
|
Style.button(self, "Auffüllen", Rect2(840, 626, 380, 44), fill_up, Style.PRIMARY)
|
||||||
code_dialog = Style.overlay(self)
|
Style.button(self, "Leeren", Rect2(840, 678, 380, 40), clear_deck)
|
||||||
Style.panel(code_dialog, Rect2(240, 270, 800, 240), Style.PANEL, 10, Style.GOLD, 2)
|
msg_label = Style.label(self, "", 14, Style.MUTED, Rect2(840, 726, 380, 48), "center")
|
||||||
Style.label(code_dialog, "Deck-Code", 26, Style.TEXT, Rect2(240, 290, 800, 40), "center", true)
|
msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||||
Style.label(code_dialog, "Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.", 15, Style.MUTED, Rect2(240, 334, 800, 24), "center")
|
|
||||||
code_box = LineEdit.new()
|
|
||||||
code_box.position = Vector2(270, 368)
|
|
||||||
code_box.size = Vector2(740, 36)
|
|
||||||
code_dialog.add_child(code_box)
|
|
||||||
Style.button(code_dialog, "In Zwischenablage", Rect2(270, 430, 180, 44), func(): DisplayServer.clipboard_set(code_box.text))
|
|
||||||
Style.button(code_dialog, "Aus Zwischenablage", Rect2(460, 430, 180, 44), func(): code_box.text = DisplayServer.clipboard_get())
|
|
||||||
Style.button(code_dialog, "Übernehmen", Rect2(650, 430, 170, 44), func(): apply_code(code_box.text), Color("285a3c"))
|
|
||||||
Style.button(code_dialog, "Schließen", Rect2(830, 430, 180, 44), func(): code_dialog.visible = false)
|
|
||||||
confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.",
|
|
||||||
"Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD)
|
|
||||||
preview = CardView.new(Cards.all()[0], Vector2(200, 280))
|
|
||||||
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
||||||
preview.visible = false
|
|
||||||
add_child(preview)
|
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
func dirty() -> bool:
|
|
||||||
return deck != app.profile.decks[slot].cards
|
|
||||||
|
|
||||||
|
|
||||||
func leave() -> void:
|
|
||||||
if dirty():
|
|
||||||
confirm.visible = true
|
|
||||||
else:
|
|
||||||
app.show_screen(MenuScreen.new())
|
|
||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if event.is_action_pressed("ui_cancel") and not search_box.has_focus() and not name_box.has_focus() and not code_box.has_focus():
|
if event.is_action_pressed("ui_cancel"):
|
||||||
if confirm.visible:
|
app.show_screen(MenuScreen.new())
|
||||||
confirm.visible = false
|
|
||||||
elif code_dialog.visible:
|
|
||||||
code_dialog.visible = false
|
|
||||||
else:
|
|
||||||
leave()
|
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
|
||||||
if preview.visible:
|
|
||||||
var pos := get_local_mouse_position() + Vector2(16, 16)
|
|
||||||
pos.x = clampf(pos.x, 0, 1280 - preview.size.x)
|
|
||||||
pos.y = clampf(pos.y, 0, 800 - preview.size.y)
|
|
||||||
preview.position = pos
|
|
||||||
|
|
||||||
|
|
||||||
func set_filter(e: String) -> void:
|
func set_filter(e: String) -> void:
|
||||||
@@ -217,206 +147,120 @@ func set_filter(e: String) -> void:
|
|||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
func select_slot(i: int) -> void:
|
## Manafilter: derselbe Knopf noch einmal hebt ihn auf.
|
||||||
slot = i
|
func set_cost(c: int) -> void:
|
||||||
deck = app.profile.decks[slot].cards.duplicate()
|
cost_filter = -1 if cost_filter == c else c
|
||||||
say("", true)
|
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
func rename(t: String) -> void:
|
## Hebt den aktiven Filterknopf hervor.
|
||||||
if t.strip_edges() == app.profile.deck_name(slot):
|
func _mark(b: Button, active: bool, base: Color) -> void:
|
||||||
return
|
b.add_theme_stylebox_override("normal", Style.box(Style.lighter(base) if active else base, 8, Style.GOLD if active else Style.MUTED, 2 if active else 1))
|
||||||
var err: String = app.profile.rename_deck(slot, t)
|
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 8, Style.GOLD if active else Style.MUTED, 2))
|
||||||
|
b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 8, Style.GOLD if active else Style.MUTED, 2))
|
||||||
|
|
||||||
|
|
||||||
|
func toggle(card: Cards.Card) -> void:
|
||||||
|
var err: String = app.profile.toggle_deck(card.id)
|
||||||
|
say(err)
|
||||||
if err == "":
|
if err == "":
|
||||||
|
app.sfx.play("click")
|
||||||
app.save()
|
app.save()
|
||||||
say("Umbenannt in %s" % app.profile.deck_name(slot), true)
|
|
||||||
else:
|
|
||||||
say(err, false)
|
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
func autofill() -> void:
|
## Fehler rot, sonst der Hinweis.
|
||||||
app.profile.decks[slot].cards = deck.duplicate()
|
func say(err: String) -> void:
|
||||||
var err: String = app.profile.autofill_deck(slot)
|
msg_label.text = tr(err) if err != "" else tr("Klick auf eine Karte: rein oder raus.")
|
||||||
deck = app.profile.decks[slot].cards.duplicate()
|
msg_label.add_theme_color_override("font_color", Style.RED if err != "" else Style.MUTED)
|
||||||
if err == "":
|
|
||||||
app.save()
|
|
||||||
say("Aufgefüllt und gespeichert", true)
|
|
||||||
else:
|
|
||||||
say(err, false)
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
|
|
||||||
func activate() -> void:
|
## Füllt mit den wertvollsten eigenen Karten des gewählten Filters bis zum Höchstwert auf.
|
||||||
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
|
func fill_up() -> void:
|
||||||
if not ok[0]:
|
var p: Profile = app.profile
|
||||||
say("Erst ein spielbares Deck speichern", false)
|
var pool: Array = p.collection.keys()
|
||||||
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 != "":
|
if filter != "":
|
||||||
out = out.filter(func(c): return filter in c.elements)
|
pool = pool.filter(func(id): return filter in Cards.by_id(id).elements)
|
||||||
if search != "":
|
p.deck = DeckGen.fill(p.deck, pool, Game.DECK_MAX, p.collection)
|
||||||
var q := search.to_lower()
|
app.save()
|
||||||
out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
|
|
||||||
match sort_mode:
|
|
||||||
0:
|
|
||||||
out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
|
|
||||||
1:
|
|
||||||
out.sort_custom(func(a, b): return a.name < b.name)
|
|
||||||
3:
|
|
||||||
out.sort_custom(func(a, b):
|
|
||||||
var ra := Cards.RARITIES.find(a.rarity)
|
|
||||||
var rb := Cards.RARITIES.find(b.rarity)
|
|
||||||
return ra > rb or (ra == rb and a.cost < b.cost))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
func say(m: String, ok: bool) -> void:
|
|
||||||
msg_label.text = m
|
|
||||||
msg_label.add_theme_color_override("font_color", Style.GREEN if ok else Style.RED)
|
|
||||||
|
|
||||||
|
|
||||||
func add(card: Cards.Card) -> void:
|
|
||||||
var owned: int = app.profile.collection.get(card.id, 0)
|
|
||||||
var have := deck.count(card.id)
|
|
||||||
if owned == 0:
|
|
||||||
say("%s fehlt in deiner Sammlung" % card.title(), false)
|
|
||||||
elif deck.size() >= Game.DECK_SIZE:
|
|
||||||
say("Das Deck ist voll (%d)" % Game.DECK_SIZE, false)
|
|
||||||
elif have >= mini(owned, Game.MAX_COPIES):
|
|
||||||
say("Nicht mehr Kopien möglich (max. %d)" % mini(owned, Game.MAX_COPIES), false)
|
|
||||||
else:
|
|
||||||
var elems := Game.deck_elements(deck_cards())
|
|
||||||
for e in card.elements:
|
|
||||||
if not (e in elems):
|
|
||||||
elems.append(e)
|
|
||||||
if elems.size() > Game.MAX_DECK_ELEMENTS:
|
|
||||||
say("Höchstens %d Elemente im Deck" % Game.MAX_DECK_ELEMENTS, false)
|
|
||||||
else:
|
|
||||||
deck.append(card.id)
|
|
||||||
app.sfx.play("click")
|
|
||||||
say("", true)
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
|
|
||||||
func remove(card: Cards.Card) -> void:
|
|
||||||
deck.erase(card.id)
|
|
||||||
say("", true)
|
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
func clear_deck() -> void:
|
func clear_deck() -> void:
|
||||||
deck = []
|
app.profile.deck = []
|
||||||
|
app.save()
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
func save_deck() -> void:
|
## Sortierschlüssel einer Karte: im Stapel zuerst, dann höchste Stufen vorn, dann Kosten, dann ID.
|
||||||
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
|
func sort_key(c: Cards.Card) -> Array:
|
||||||
if ok[0]:
|
|
||||||
app.profile.decks[slot].cards = deck.duplicate()
|
|
||||||
app.save()
|
|
||||||
say("%s gespeichert" % app.profile.deck_name(slot), true)
|
|
||||||
else:
|
|
||||||
say(ok[1], false)
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
|
|
||||||
func open_code() -> void:
|
|
||||||
code_box.text = Profile.deck_code(deck)
|
|
||||||
code_dialog.visible = true
|
|
||||||
|
|
||||||
|
|
||||||
## Übernimmt einen Deck-Code; Karten, die in der Sammlung fehlen, werden gemeldet und weggelassen.
|
|
||||||
func apply_code(code: String) -> void:
|
|
||||||
var ids: Array = Profile.parse_deck_code(code)
|
|
||||||
if ids.is_empty():
|
|
||||||
say("Ungültiger Code", false)
|
|
||||||
return
|
|
||||||
var p: Profile = app.profile
|
var p: Profile = app.profile
|
||||||
var missing := {}
|
return [0 if c.id in p.deck else 1, -p.level(c.id), c.cost, c.id]
|
||||||
deck = []
|
|
||||||
for cid in ids:
|
|
||||||
var owned: int = p.collection.get(cid, 0)
|
|
||||||
if deck.count(cid) < owned and deck.size() < Game.DECK_SIZE:
|
|
||||||
deck.append(cid)
|
|
||||||
else:
|
|
||||||
missing[cid] = true
|
|
||||||
code_dialog.visible = false
|
|
||||||
if missing.is_empty():
|
|
||||||
say("Code übernommen", true)
|
|
||||||
else:
|
|
||||||
say(tr("Code übernommen, es fehlen: ") + ", ".join(missing.keys().map(func(cid): return Cards.by_id(cid).title())), false)
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
|
|
||||||
func deck_cards() -> Array:
|
## Eigene Karten nach Element- und Manafilter, sortiert (sort_key).
|
||||||
var out := []
|
func cards() -> Array:
|
||||||
for cid in deck:
|
var p: Profile = app.profile
|
||||||
out.append(Cards.by_id(cid))
|
var out: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
|
||||||
|
if filter != "":
|
||||||
|
out = out.filter(func(c): return filter in c.elements)
|
||||||
|
if cost_filter >= 0:
|
||||||
|
out = out.filter(func(c): return c.cost == cost_filter)
|
||||||
|
out.sort_custom(func(a, b): return sort_key(a) < sort_key(b))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
func show_preview(card: Cards.Card, entered: bool) -> void:
|
## Vorschau der gezeigten Karte in ihrer Stufe.
|
||||||
if entered:
|
func show_card(card: Cards.Card) -> void:
|
||||||
preview.card = card
|
var p: Profile = app.profile
|
||||||
preview.visible = true
|
shown_card = card
|
||||||
preview.queue_redraw()
|
Style.clear(preview_holder)
|
||||||
elif preview.card == card:
|
var v := CardView.new(card.leveled(p.level(card.id)), PREVIEW.size, false, p.rarity(card.id))
|
||||||
preview.visible = false
|
v.position = PREVIEW.position
|
||||||
|
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
preview_holder.add_child(v)
|
||||||
|
|
||||||
|
|
||||||
func refresh() -> void:
|
func refresh() -> void:
|
||||||
var p: Profile = app.profile
|
var p: Profile = app.profile
|
||||||
grid.fill(cards(),
|
count_label.text = tr("Stapel: %d Karten (%d bis %d)") % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX]
|
||||||
func(c):
|
count_label.add_theme_color_override("font_color", Style.RED if p.deck.size() < Game.DECK_MIN else Style.BLUE)
|
||||||
var owned: int = p.collection.get(c.id, 0)
|
strength_label.text = tr("Stärke %d") % DeckGen.strength(p.deck, p.collection)
|
||||||
return "fehlt" if owned == 0 else "%d/%d im Deck" % [deck.count(c.id), owned],
|
_mark(all_button, filter == "", Style.PANEL_LIGHT)
|
||||||
func(c):
|
var open: Array = p.unlocked_elements() # nur Elemente offener Kapitel
|
||||||
var owned: int = p.collection.get(c.id, 0)
|
for e in element_buttons:
|
||||||
return owned == 0 or deck.count(c.id) >= mini(owned, Game.MAX_COPIES))
|
var eb: Button = element_buttons[e]
|
||||||
for i in slot_buttons.size():
|
eb.visible = e in open
|
||||||
var b: Button = slot_buttons[i]
|
if eb.visible:
|
||||||
var col: Color = Style.GOLD if i == slot else (Style.GREEN if p.deck_valid(i)[0] else Style.MUTED)
|
eb.position = Vector2(108 + open.find(e) * 40, 86)
|
||||||
b.add_theme_stylebox_override("normal", Style.box(Style.PANEL_LIGHT if i != slot else Style.darker(Style.GOLD, 0.45), 6, col, 2))
|
_mark(eb, filter == e, Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
|
||||||
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Style.PANEL_LIGHT), 6, col, 2))
|
for c in cost_buttons.size():
|
||||||
active_label.text = "aktiv" if slot == p.active_deck else ""
|
_mark(cost_buttons[c], cost_filter == c, Style.PANEL_LIGHT)
|
||||||
# Deckliste
|
var counts := []
|
||||||
Style.clear(deck_list)
|
counts.resize(11)
|
||||||
var counts := {}
|
counts.fill(0)
|
||||||
for cid in deck:
|
var per_element := {}
|
||||||
counts[cid] = counts.get(cid, 0) + 1
|
for id in p.deck:
|
||||||
var listed: Array = counts.keys().map(func(cid): return Cards.by_id(cid))
|
var c := Cards.by_id(id)
|
||||||
listed.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
|
counts[clampi(c.cost, 0, 10)] += 1
|
||||||
for i in listed.size():
|
per_element[c.elements[0]] = int(per_element.get(c.elements[0], 0)) + 1
|
||||||
var c: Cards.Card = listed[i]
|
curve.set_counts(counts)
|
||||||
var row := DeckRow.new(c, counts[c.id], Vector2(400, 20))
|
share.set_counts(per_element)
|
||||||
row.position = Vector2(0, i * 21)
|
if msg_label.text == "":
|
||||||
row.clicked.connect(remove)
|
say("")
|
||||||
row.hover.connect(show_preview)
|
var shown := cards()
|
||||||
deck_list.add_child(row)
|
grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
|
||||||
var dc := deck_cards()
|
var views := grid.views()
|
||||||
if not name_box.has_focus():
|
for i in views.size():
|
||||||
name_box.text = p.deck_name(slot)
|
var in_deck: bool = shown[i].id in p.deck
|
||||||
deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE]
|
views[i].dim = not in_deck
|
||||||
deck_label.tooltip_text = tr("Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten")
|
views[i].marked = in_deck
|
||||||
deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT)
|
views[i].hover.connect(func(c, entered):
|
||||||
var elems := Game.deck_elements(dc)
|
if entered:
|
||||||
elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b))
|
show_card(c))
|
||||||
elems_label.text = tr("Elemente: ") + (", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else "–")
|
if shown_card != null and p.owns(shown_card.id):
|
||||||
if not dc.is_empty():
|
show_card(shown_card)
|
||||||
elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc)
|
elif shown.is_empty():
|
||||||
var ok: Array = Game.validate_deck(dc, p.collection)
|
Style.clear(preview_holder)
|
||||||
valid_label.text = "spielbar" if ok[0] else ok[1]
|
else:
|
||||||
valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE)
|
show_card(shown[0])
|
||||||
curve.cards = dc
|
|
||||||
curve.queue_redraw()
|
|
||||||
@@ -1 +1 @@
|
|||||||
uid://yg8pbj56mib5
|
uid://ctt36od26uyea
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
class_name ElementView
|
||||||
|
extends Control
|
||||||
|
## Elementpaare einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente mit offenen Wesen
|
||||||
|
## da: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je offenem Wesen, beide:
|
||||||
|
## Effekt aktiv; Neutral hat keinen) und die Zahl der offenen Wesen.
|
||||||
|
|
||||||
|
const ROW := 32.0
|
||||||
|
|
||||||
|
var own := {} # offene eigene Wesen je Element
|
||||||
|
|
||||||
|
|
||||||
|
func _init(rect: Rect2) -> void:
|
||||||
|
position = rect.position
|
||||||
|
size = rect.size
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
for e in all():
|
||||||
|
own[e] = 0
|
||||||
|
|
||||||
|
|
||||||
|
static func all() -> Array:
|
||||||
|
return Cards.ELEMENTS + [Cards.NEUTRAL]
|
||||||
|
|
||||||
|
|
||||||
|
func update_from(_g: Game, me: Game.Player) -> void:
|
||||||
|
for e in all():
|
||||||
|
own[e] = me.element_count(e) if me != null else 0
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func paired(e: String) -> bool:
|
||||||
|
return Cards.ELEMENT_KEYWORD.has(e) and int(own.get(e, 0)) >= Game.ELEMENT_PAIR
|
||||||
|
|
||||||
|
|
||||||
|
## Elemente mit offenen Wesen.
|
||||||
|
func shown() -> Array:
|
||||||
|
return all().filter(func(e): return int(own.get(e, 0)) > 0)
|
||||||
|
|
||||||
|
|
||||||
|
## Der Inhalt als Text (Tests, Protokoll): Element und offene Wesen, dazu der Effekt.
|
||||||
|
func text() -> String:
|
||||||
|
var parts: Array[String] = []
|
||||||
|
for e in shown():
|
||||||
|
var s := "%s %d" % [tr(e), int(own.get(e, 0))]
|
||||||
|
if paired(e):
|
||||||
|
s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e])
|
||||||
|
parts.append(s)
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var y := ROW / 2
|
||||||
|
for e in shown():
|
||||||
|
var col: Color = Cards.ELEMENT_COLORS[e]
|
||||||
|
var active := paired(e)
|
||||||
|
draw_style_box(Style.box(Color(col, 0.18 if active else 0.1), 6, Color(col, 0.6 if active else 0.0), 1 if active else 0), Rect2(0, y - ROW / 2 + 2, size.x, ROW - 4))
|
||||||
|
var px := 10.0
|
||||||
|
Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, y), "midleft", active)
|
||||||
|
px = 76
|
||||||
|
if Cards.ELEMENT_KEYWORD.has(e):
|
||||||
|
for k in Game.ELEMENT_PAIR:
|
||||||
|
var filled := k < int(own.get(e, 0))
|
||||||
|
Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, y), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3))
|
||||||
|
px += Game.ELEMENT_PAIR * 24 + 8
|
||||||
|
Style.text(self, str(int(own.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true)
|
||||||
|
y += ROW
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c2tr1bg65rhk6
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
class_name LobbyScreen
|
|
||||||
extends Control
|
|
||||||
## Netzwerkspiel im lokalen Netz: Spiel eröffnen oder beitreten.
|
|
||||||
|
|
||||||
var app
|
|
||||||
var net: Net
|
|
||||||
var status_label: Label
|
|
||||||
var list_label: Label
|
|
||||||
var ip_box: LineEdit
|
|
||||||
var b_host: Button
|
|
||||||
var b_join: Button
|
|
||||||
var b_watch: Button
|
|
||||||
var b_start: Button
|
|
||||||
var ai_buttons: Array = []
|
|
||||||
var n_ai := 0
|
|
||||||
var addr_label: Label
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
net = app.net
|
|
||||||
net.my_name = "Spieler"
|
|
||||||
net.my_deck = app.profile.deck.duplicate()
|
|
||||||
var col := Style.column(self, 760, 40, 10)
|
|
||||||
Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true)
|
|
||||||
Style.spacer(col, 10)
|
|
||||||
var hr := Style.row(col, 12)
|
|
||||||
hr.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
||||||
b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c"))
|
|
||||||
ip_box = LineEdit.new()
|
|
||||||
ip_box.custom_minimum_size = Vector2(220, 46)
|
|
||||||
ip_box.placeholder_text = "Adresse des Hosts"
|
|
||||||
ip_box.text = "127.0.0.1"
|
|
||||||
hr.add_child(ip_box)
|
|
||||||
b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join)
|
|
||||||
b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch)
|
|
||||||
addr_label = Style.clabel(col, "", 14, Style.MUTED)
|
|
||||||
status_label = Style.clabel(col, "", 16, Style.BLUE)
|
|
||||||
Style.spacer(col, 6)
|
|
||||||
Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true)
|
|
||||||
list_label = Style.clabel(col, "", 16, Style.TEXT)
|
|
||||||
Style.spacer(col, 6)
|
|
||||||
Style.clabel(col, "KI-Gegner", 16, Style.MUTED)
|
|
||||||
var ar := Style.row(col, 10)
|
|
||||||
ar.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
||||||
for n in range(0, 5):
|
|
||||||
ai_buttons.append(Style.cbutton(ar, "%d KI" % n if n > 0 else "keine KI", Vector2(120, 40), func(): _set_ai(n)))
|
|
||||||
Style.spacer(col, 10)
|
|
||||||
var br := Style.row(col, 20)
|
|
||||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
||||||
b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c"))
|
|
||||||
Style.cbutton(br, "Zurück", Vector2(160, 48), _back)
|
|
||||||
net.lobby_changed.connect(refresh)
|
|
||||||
net.started.connect(_started)
|
|
||||||
net.ended.connect(func(_reason): refresh())
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
|
||||||
if net.lobby_changed.is_connected(refresh):
|
|
||||||
net.lobby_changed.disconnect(refresh)
|
|
||||||
if net.started.is_connected(_started):
|
|
||||||
net.started.disconnect(_started)
|
|
||||||
|
|
||||||
|
|
||||||
func _host() -> void:
|
|
||||||
net.host()
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
|
|
||||||
func _join() -> void:
|
|
||||||
net.join(ip_box.text.strip_edges())
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
|
|
||||||
## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit.
|
|
||||||
func _watch() -> void:
|
|
||||||
net.spectator = true
|
|
||||||
net.join(ip_box.text.strip_edges())
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
|
|
||||||
func _set_ai(n: int) -> void:
|
|
||||||
n_ai = n
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
|
|
||||||
func _start() -> void:
|
|
||||||
net.start_battle(n_ai)
|
|
||||||
|
|
||||||
|
|
||||||
func _started(seat: int) -> void:
|
|
||||||
app.show_screen(BattleScreen.online(seat))
|
|
||||||
|
|
||||||
|
|
||||||
func _back() -> void:
|
|
||||||
net.leave()
|
|
||||||
app.show_screen(MenuScreen.new())
|
|
||||||
|
|
||||||
|
|
||||||
func refresh() -> void:
|
|
||||||
status_label.text = net.status
|
|
||||||
var lines: Array[String] = []
|
|
||||||
var ids: Array = net.names.keys()
|
|
||||||
ids.sort()
|
|
||||||
for id in ids:
|
|
||||||
lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""])
|
|
||||||
for id in net.spectators:
|
|
||||||
lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")])
|
|
||||||
list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
|
|
||||||
b_host.disabled = net.connected
|
|
||||||
b_join.disabled = net.connected
|
|
||||||
b_watch.disabled = net.connected
|
|
||||||
ip_box.editable = not net.connected
|
|
||||||
var humans := ids.size()
|
|
||||||
b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1)
|
|
||||||
for i in ai_buttons.size():
|
|
||||||
ai_buttons[i].disabled = not net.is_host or i == n_ai or humans + i > Game.MAX_OPPONENTS + 1
|
|
||||||
addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else ""
|
|
||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
|
||||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not ip_box.has_focus():
|
|
||||||
_back()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bhnnxjh6rsxnf
|
|
||||||
+31
-27
@@ -5,7 +5,6 @@ var profile: Profile
|
|||||||
var screen: Control
|
var screen: Control
|
||||||
var save_path: String = Profile.SAVE_PATH
|
var save_path: String = Profile.SAVE_PATH
|
||||||
var sfx: Sfx
|
var sfx: Sfx
|
||||||
var net: Net
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -17,13 +16,7 @@ func _ready() -> void:
|
|||||||
add_child(bg)
|
add_child(bg)
|
||||||
sfx = Sfx.new()
|
sfx = Sfx.new()
|
||||||
add_child(sfx)
|
add_child(sfx)
|
||||||
# Fester Pfad /root/Net: Netzwerkaufrufe werden über den Knotenpfad zugestellt,
|
|
||||||
# der bei Host und Mitspielern gleich sein muss.
|
|
||||||
net = Net.new()
|
|
||||||
net.name = "Net"
|
|
||||||
get_tree().root.add_child.call_deferred(net)
|
|
||||||
profile = Profile.load_or_new(save_path)
|
profile = Profile.load_or_new(save_path)
|
||||||
profile.refresh_daily()
|
|
||||||
apply_settings()
|
apply_settings()
|
||||||
show_screen(MenuScreen.new())
|
show_screen(MenuScreen.new())
|
||||||
|
|
||||||
@@ -33,29 +26,43 @@ func apply_settings() -> void:
|
|||||||
sfx.set_music_volume(profile.settings.music)
|
sfx.set_music_volume(profile.settings.music)
|
||||||
set_language(profile.settings.language)
|
set_language(profile.settings.language)
|
||||||
Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0
|
Style.font_scale = 1.2 if profile.settings.get("big_font", false) else 1.0
|
||||||
|
Style.animations = bool(profile.settings.get("animations", true))
|
||||||
theme = Style.make_theme()
|
theme = Style.make_theme()
|
||||||
|
|
||||||
|
|
||||||
## Neues Profil: Sammlung, Decks und Fortschritt werden verworfen.
|
## Neues Profil: Sammlung und Fortschritt werden verworfen, zurück bleiben die neutralen Karten.
|
||||||
func reset_profile() -> void:
|
func reset_profile() -> void:
|
||||||
profile = Profile.starter()
|
profile = Profile.starter()
|
||||||
profile.refresh_daily()
|
|
||||||
clear_battle_save()
|
|
||||||
save()
|
save()
|
||||||
apply_settings()
|
apply_settings()
|
||||||
|
show_screen(MenuScreen.new())
|
||||||
|
|
||||||
|
|
||||||
func set_language(code: String) -> void:
|
func set_language(code: String) -> void:
|
||||||
TranslationServer.set_locale(code)
|
TranslationServer.set_locale(code)
|
||||||
|
|
||||||
|
|
||||||
func show_screen(s: Control) -> void:
|
var kept: Array = [] # vorige Bildschirme, zu denen back() zurückführt (Einstellungen aus dem Kampf)
|
||||||
|
|
||||||
|
|
||||||
|
## keep: der aktuelle Bildschirm bleibt erhalten und kommt mit back() zurück.
|
||||||
|
func show_screen(s: Control, keep: bool = false) -> void:
|
||||||
if screen != null:
|
if screen != null:
|
||||||
screen.queue_free()
|
if keep:
|
||||||
|
remove_child(screen)
|
||||||
|
kept.append(screen)
|
||||||
|
else:
|
||||||
|
screen.queue_free()
|
||||||
|
for k in kept:
|
||||||
|
k.queue_free()
|
||||||
|
kept.clear()
|
||||||
screen = s
|
screen = s
|
||||||
s.app = self
|
s.app = self
|
||||||
s.set_anchors_preset(Control.PRESET_FULL_RECT)
|
s.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
add_child(s)
|
add_child(s)
|
||||||
|
if Style.animations:
|
||||||
|
s.modulate.a = 0.0
|
||||||
|
create_tween().tween_property(s, "modulate:a", 1.0, 0.15)
|
||||||
if pad_mode:
|
if pad_mode:
|
||||||
focus_first_button.call_deferred()
|
focus_first_button.call_deferred()
|
||||||
|
|
||||||
@@ -84,6 +91,7 @@ func _map_gamepad() -> void:
|
|||||||
if not InputMap.action_get_events(action).any(func(e): return e is InputEventJoypadButton):
|
if not InputMap.action_get_events(action).any(func(e): return e is InputEventJoypadButton):
|
||||||
var ev := InputEventJoypadButton.new()
|
var ev := InputEventJoypadButton.new()
|
||||||
ev.button_index = pair[1]
|
ev.button_index = pair[1]
|
||||||
|
ev.device = -1 # jedes Gamepad
|
||||||
InputMap.action_add_event(action, ev)
|
InputMap.action_add_event(action, ev)
|
||||||
|
|
||||||
|
|
||||||
@@ -105,21 +113,17 @@ func _first_button(node: Node) -> Button:
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Zurück: zum gemerkten Bildschirm, sonst ins Hauptmenü.
|
||||||
|
func back() -> void:
|
||||||
|
if kept.is_empty():
|
||||||
|
show_screen(MenuScreen.new())
|
||||||
|
return
|
||||||
|
var prev: Control = kept.pop_back()
|
||||||
|
screen.queue_free()
|
||||||
|
screen = prev
|
||||||
|
add_child(prev)
|
||||||
|
|
||||||
|
|
||||||
func save() -> void:
|
func save() -> void:
|
||||||
profile.save(save_path)
|
profile.save(save_path)
|
||||||
|
|
||||||
|
|
||||||
## Datei für einen unterbrochenen Kampf (neben dem Spielstand).
|
|
||||||
func battle_save_path() -> String:
|
|
||||||
return save_path.get_basename() + "_kampf.json"
|
|
||||||
|
|
||||||
|
|
||||||
func clear_battle_save() -> void:
|
|
||||||
if FileAccess.file_exists(battle_save_path()):
|
|
||||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(battle_save_path()))
|
|
||||||
|
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
|
||||||
if net != null and net.is_inside_tree():
|
|
||||||
net.leave()
|
|
||||||
net.queue_free()
|
|
||||||
+48
-63
@@ -1,85 +1,70 @@
|
|||||||
class_name MenuScreen
|
class_name MenuScreen
|
||||||
extends Control
|
extends Control
|
||||||
|
## Hauptmenü: links Titel und Spielarten (darunter Booster, Sammlung, Skilltree, dann Einstellungen und
|
||||||
|
## Beenden), rechts drei Karten der eigenen Sammlung als Schaufenster, unten rechts die Cheats.
|
||||||
|
|
||||||
var app
|
var app
|
||||||
var b_single: Button
|
|
||||||
var b_campaign: Button
|
|
||||||
var b_online: Button
|
|
||||||
var b_resume: Button
|
|
||||||
var coins_label: Label
|
var coins_label: Label
|
||||||
var coll_label: Label
|
var coll_label: Label
|
||||||
var deck_label: Label
|
|
||||||
var daily_label: Label
|
|
||||||
var warn_label: Label
|
|
||||||
var notice_label: Label
|
var notice_label: Label
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
var col := Style.column(self, 640, 24, 5)
|
var p: Profile = app.profile
|
||||||
Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true)
|
Style.label(self, "Dinger", 76, Style.GOLD, Rect2(80, 64, 600, 90), "left", true)
|
||||||
Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED)
|
Style.label(self, "Ein Kartenspiel: Duell um das Feld", 22, Style.MUTED, Rect2(84, 156, 600, 30), "left")
|
||||||
Style.spacer(col, 8)
|
coins_label = Style.chip(self, "", Rect2(930, 20, 310, 38))
|
||||||
coins_label = Style.clabel(col, "", 20, Style.GOLD)
|
coll_label = Style.label(self, "", 15, Style.MUTED, Rect2(700, 64, 540, 24), "right")
|
||||||
coll_label = Style.clabel(col, "", 16, Style.MUTED)
|
var y := 230
|
||||||
deck_label = Style.clabel(col, "", 16, Style.MUTED)
|
for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())],
|
||||||
daily_label = Style.clabel(col, "", 16, Style.BLUE)
|
["Kampagne", func(): app.show_screen(CampaignScreen.new())],
|
||||||
Style.spacer(col, 8)
|
["Deck", func(): app.show_screen(DeckScreen.new())]]:
|
||||||
var grid := GridContainer.new()
|
var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY)
|
||||||
grid.columns = 2
|
b.add_theme_font_size_override("font_size", 24)
|
||||||
grid.add_theme_constant_override("h_separation", 12)
|
y += 74
|
||||||
grid.add_theme_constant_override("v_separation", 8)
|
y += 18
|
||||||
var center := CenterContainer.new()
|
var x := 80
|
||||||
center.add_child(grid)
|
for entry in [["Booster öffnen", func(): app.show_screen(BoosterScreen.new())],
|
||||||
col.add_child(center)
|
["Sammlung", func(): app.show_screen(CollectionScreen.new())],
|
||||||
var sz := Vector2(270, 44)
|
["Skilltree", func(): app.show_screen(SkillScreen.new())]]:
|
||||||
if FileAccess.file_exists(app.battle_save_path()):
|
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
|
||||||
b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628"))
|
x += 176
|
||||||
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
|
y += 56
|
||||||
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
|
x = 80
|
||||||
b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
|
for entry in [["Einstellungen", func(): app.show_screen(SettingsScreen.new())],
|
||||||
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
|
["Beenden", func(): get_tree().quit()]]:
|
||||||
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new()))
|
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
|
||||||
Style.cbutton(grid, "Sammlung", sz, func(): app.show_screen(CollectionScreen.new()))
|
x += 176
|
||||||
Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new()))
|
var cheats := Style.button(self, "Cheats", Rect2(1120, 744, 120, 36), func(): app.show_screen(CheatScreen.new()))
|
||||||
Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit())
|
cheats.add_theme_font_size_override("font_size", 14)
|
||||||
Style.spacer(col, 10)
|
notice_label = Style.label(self, "", 16, Style.GREEN, Rect2(80, y + 70, 760, 60), "left")
|
||||||
warn_label = Style.clabel(col, "", 16, Style.RED)
|
|
||||||
notice_label = Style.clabel(col, "", 16, Style.GREEN)
|
|
||||||
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
|
||||||
|
_showcase(p)
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
## Unterbrochenen Kampf laden; eine unlesbare Datei wird verworfen.
|
## Drei eigene Karten, die höchsten Stufen zuerst, leicht gefächert.
|
||||||
func _resume() -> void:
|
func _showcase(p: Profile) -> void:
|
||||||
var json := JSON.new()
|
var cards: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
|
||||||
var text := FileAccess.get_file_as_string(app.battle_save_path())
|
cards.sort_custom(func(a, b):
|
||||||
if json.parse(text) != OK or not (json.data is Dictionary) or not json.data.has("game"):
|
if p.level(a.id) != p.level(b.id):
|
||||||
app.clear_battle_save()
|
return p.level(a.id) > p.level(b.id)
|
||||||
app.show_screen(MenuScreen.new())
|
return hash(a.id) % 97 < hash(b.id) % 97)
|
||||||
return
|
var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]]
|
||||||
app.show_screen(BattleScreen.resume(json.data))
|
for i in 3:
|
||||||
|
var c: Cards.Card = cards[i]
|
||||||
|
var v := CardView.new(c.leveled(p.level(c.id)), Vector2(180, 252), false, p.rarity(c.id))
|
||||||
|
v.position = spots[i][0]
|
||||||
|
v.rotation = spots[i][1]
|
||||||
|
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(v)
|
||||||
|
|
||||||
|
|
||||||
func refresh() -> void:
|
func refresh() -> void:
|
||||||
var p: Profile = app.profile
|
var p: Profile = app.profile
|
||||||
var ok: Array = p.deck_valid()
|
|
||||||
b_single.disabled = not ok[0]
|
|
||||||
b_campaign.disabled = not ok[0]
|
|
||||||
b_online.disabled = not ok[0]
|
|
||||||
var total := 0
|
|
||||||
for cid in p.collection:
|
|
||||||
total += p.collection[cid]
|
|
||||||
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
|
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
|
||||||
coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [
|
coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d · Level %d") % [
|
||||||
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()]
|
p.collection.size(), p.unlocked_cards().size(), p.campaign_stage, Campaign.total(), p.player_level()]
|
||||||
var elems := Game.deck_elements(p.deck_cards())
|
|
||||||
deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else tr("leer")]
|
|
||||||
var task := p.daily_task()
|
|
||||||
if p.daily.done:
|
|
||||||
daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text
|
|
||||||
else:
|
|
||||||
daily_label.text = tr("Tagesaufgabe: %s (%d/%d) · +%d Münzen") % [task.text, p.daily.progress, task.goal, task.reward]
|
|
||||||
warn_label.text = "" if ok[0] else tr("Aktives Deck nicht spielbar: ") + tr(ok[1])
|
|
||||||
var notes := p.take_notices()
|
var notes := p.take_notices()
|
||||||
notice_label.text = " · ".join(notes)
|
notice_label.text = " · ".join(notes)
|
||||||
if not notes.is_empty():
|
if not notes.is_empty():
|
||||||
|
|||||||
@@ -1,79 +1,58 @@
|
|||||||
class_name OpponentSelectScreen
|
class_name OpponentSelectScreen
|
||||||
extends Control
|
extends Control
|
||||||
## Einzelspieler: Schwierigkeit wählen und die Gegnerzahl schieben. Die Gegnerdecks
|
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Der Gegner spielt ein Element aus den
|
||||||
## werden erst beim Start zufällig gezogen (siehe BattleScreen und DeckGen).
|
## offenen Kapiteln in einer Stufe relativ zu deiner Deckstärke (Profile.single_opponent).
|
||||||
|
|
||||||
var app
|
var app
|
||||||
var rating_label: Label
|
|
||||||
var diff_buttons := {}
|
var diff_buttons := {}
|
||||||
var count_slider: HSlider
|
|
||||||
var count_label: Label
|
|
||||||
var info_label: Label
|
var info_label: Label
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
var col := Style.column(self, 1000, 60, 14)
|
Style.header(self, "Einzelspieler", func(): app.show_screen(MenuScreen.new()))
|
||||||
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true)
|
var col := Style.column(self, 1000, 170, 12)
|
||||||
Style.spacer(col, 10)
|
|
||||||
Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true)
|
Style.clabel(col, "Schwierigkeit", 20, Style.TEXT, "center", true)
|
||||||
var dr := Style.row(col, 10)
|
var dr := Style.row(col, 10)
|
||||||
dr.alignment = BoxContainer.ALIGNMENT_CENTER
|
dr.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
for key in ["leicht", "normal", "schwer"]:
|
for key in Profile.DIFFICULTIES:
|
||||||
var k: String = key
|
var k: String = key
|
||||||
diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k))
|
diff_buttons[k] = Style.cbutton(dr, Profile.DIFFICULTIES[k].name, Vector2(150, 40), func(): set_difficulty(k))
|
||||||
rating_label = Style.clabel(col, "", 15, Style.MUTED)
|
|
||||||
Style.spacer(col, 24)
|
|
||||||
count_label = Style.clabel(col, "", 20, Style.TEXT, "center", true)
|
|
||||||
var sr := Style.row(col, 12)
|
|
||||||
sr.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
||||||
Style.clabel(sr, "1", 15, Style.MUTED, "right", false, 20)
|
|
||||||
count_slider = HSlider.new()
|
|
||||||
count_slider.min_value = 1
|
|
||||||
count_slider.max_value = Game.MAX_OPPONENTS
|
|
||||||
count_slider.step = 1
|
|
||||||
count_slider.value = 1
|
|
||||||
count_slider.tick_count = Game.MAX_OPPONENTS
|
|
||||||
count_slider.ticks_on_borders = true
|
|
||||||
count_slider.custom_minimum_size = Vector2(420, 32)
|
|
||||||
count_slider.value_changed.connect(func(_v): refresh())
|
|
||||||
sr.add_child(count_slider)
|
|
||||||
Style.clabel(sr, str(Game.MAX_OPPONENTS), 15, Style.MUTED, "left", false, 20)
|
|
||||||
info_label = Style.clabel(col, "", 15, Style.MUTED)
|
info_label = Style.clabel(col, "", 15, Style.MUTED)
|
||||||
Style.spacer(col, 30)
|
Style.spacer(col, 24)
|
||||||
var br := Style.row(col, 16)
|
var br := Style.row(col, 16)
|
||||||
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
br.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
Style.cbutton(br, "Starten", Vector2(190, 46), start, Color("285a3c"))
|
var b := Style.cbutton(br, "Starten", Vector2(260, 56), start, Style.PRIMARY)
|
||||||
Style.cbutton(br, "Zurück", Vector2(150, 46), func(): app.show_screen(MenuScreen.new()))
|
b.add_theme_font_size_override("font_size", 22)
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
app.show_screen(MenuScreen.new())
|
||||||
|
|
||||||
|
|
||||||
func set_difficulty(key: String) -> void:
|
func set_difficulty(key: String) -> void:
|
||||||
app.profile.settings.difficulty = key
|
app.profile.settings.difficulty = key
|
||||||
app.save()
|
app.save()
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
func opponents() -> int:
|
|
||||||
return int(count_slider.value)
|
|
||||||
|
|
||||||
|
|
||||||
func start() -> void:
|
func start() -> void:
|
||||||
app.show_screen(BattleScreen.new(opponents()))
|
app.show_screen(BattleScreen.new())
|
||||||
|
|
||||||
|
|
||||||
func refresh() -> void:
|
func refresh() -> void:
|
||||||
var p: Profile = app.profile
|
var p: Profile = app.profile
|
||||||
for key in diff_buttons:
|
for key in diff_buttons:
|
||||||
var b: Button = diff_buttons[key]
|
_mark(diff_buttons[key], key == p.settings.difficulty)
|
||||||
var active: bool = key == p.settings.difficulty
|
var d := p.difficulty()
|
||||||
b.add_theme_stylebox_override("normal", Style.box(Color("285a3c") if active else Style.PANEL_LIGHT, 8, Style.GOLD if active else Style.MUTED, 2))
|
var parts: Array[String] = [tr("Gegnerkarten: %s") % tr(Cards.RARITIES[p.opponent_level()]), tr("Vorausschau-KI") if bool(d.smart) else tr("einfache KI")]
|
||||||
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Color("285a3c") if active else Style.PANEL_LIGHT), 8, Style.GOLD if active else Style.MUTED, 2))
|
if bool(d.perk):
|
||||||
rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f") % [p.deck_rating(), p.target_rating()]
|
parts.append(tr("ein Merkmal"))
|
||||||
var n := opponents()
|
parts.append(tr("Münzen %s") % Style.factor(float(d.reward)))
|
||||||
count_label.text = tr("1 Gegner") if n == 1 else tr("%d Gegner") % n
|
info_label.text = " · ".join(parts)
|
||||||
info_label.text = (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n]
|
|
||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void:
|
||||||
if event.is_action_pressed("ui_cancel"):
|
var base: Color = col if active else Style.PANEL_LIGHT
|
||||||
app.show_screen(MenuScreen.new())
|
b.add_theme_stylebox_override("normal", Style.box(base, 8, Style.GOLD if active else Style.MUTED, 2))
|
||||||
@@ -16,9 +16,8 @@ var speed_label: Label
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
var s: Dictionary = app.profile.settings
|
var s: Dictionary = app.profile.settings
|
||||||
var col := Style.column(self, 700, 50, 24)
|
Style.header(self, "Einstellungen", func(): app.back())
|
||||||
Style.clabel(col, "Einstellungen", 36, Style.GOLD, "center", true)
|
var col := Style.column(self, 700, 110, 24)
|
||||||
Style.spacer(col, 30)
|
|
||||||
var grid := GridContainer.new()
|
var grid := GridContainer.new()
|
||||||
grid.columns = 3
|
grid.columns = 3
|
||||||
grid.add_theme_constant_override("h_separation", 24)
|
grid.add_theme_constant_override("h_separation", 24)
|
||||||
@@ -76,20 +75,17 @@ func _ready() -> void:
|
|||||||
grid.add_child(language)
|
grid.add_child(language)
|
||||||
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
|
||||||
Style.spacer(col, 20)
|
Style.spacer(col, 20)
|
||||||
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
|
|
||||||
Style.spacer(col, 30)
|
Style.spacer(col, 30)
|
||||||
var r := Style.row(col, 16)
|
var r := Style.row(col, 16)
|
||||||
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
r.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
|
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
|
||||||
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
|
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.",
|
||||||
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Decks, Münzen und Fortschritt gehen verloren.",
|
|
||||||
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
|
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
|
||||||
_labels()
|
_labels()
|
||||||
|
|
||||||
|
|
||||||
func _reset() -> void:
|
func _reset() -> void:
|
||||||
app.reset_profile()
|
app.reset_profile()
|
||||||
app.show_screen(MenuScreen.new())
|
|
||||||
|
|
||||||
|
|
||||||
func _on_big_font(on: bool) -> void:
|
func _on_big_font(on: bool) -> void:
|
||||||
@@ -136,9 +132,10 @@ func _on_speed(v: float) -> void:
|
|||||||
|
|
||||||
func _on_anim(on: bool) -> void:
|
func _on_anim(on: bool) -> void:
|
||||||
app.profile.settings.animations = on
|
app.profile.settings.animations = on
|
||||||
|
Style.animations = on
|
||||||
app.save()
|
app.save()
|
||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||||
app.show_screen(MenuScreen.new())
|
app.back()
|
||||||
+7
-7
@@ -2,25 +2,25 @@ class_name Sfx
|
|||||||
extends Node
|
extends Node
|
||||||
## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig).
|
## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig).
|
||||||
|
|
||||||
static var inst: Sfx
|
|
||||||
|
|
||||||
var streams := {}
|
var streams := {}
|
||||||
var players: Array[AudioStreamPlayer] = []
|
var players: Array[AudioStreamPlayer] = []
|
||||||
var volume := 0.8
|
var volume := 0.0 # Ton ist standardmäßig aus
|
||||||
|
var muted := false # Tests: nie etwas abspielen, egal was die Regler sagen
|
||||||
var music_player: AudioStreamPlayer
|
var music_player: AudioStreamPlayer
|
||||||
var music_volume := 0.5
|
var music_volume := 0.0
|
||||||
var music_task := -1 # die Musik wird im Hintergrund berechnet (spart ~150 ms beim Start)
|
var music_task := -1 # die Musik wird im Hintergrund berechnet (spart ~150 ms beim Start)
|
||||||
var music_box := {}
|
var music_box := {}
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
inst = self
|
|
||||||
streams["click"] = _tone(880, 0.05, "sine")
|
streams["click"] = _tone(880, 0.05, "sine")
|
||||||
streams["play"] = _tone(440, 0.14, "sine", 660)
|
streams["play"] = _tone(440, 0.14, "sine", 660)
|
||||||
streams["attack"] = _tone(220, 0.16, "square", 90)
|
streams["attack"] = _tone(220, 0.16, "square", 90)
|
||||||
streams["damage"] = _tone(140, 0.12, "square")
|
streams["damage"] = _tone(140, 0.12, "square")
|
||||||
streams["heal"] = _tone(523, 0.22, "sine", 784)
|
streams["heal"] = _tone(523, 0.22, "sine", 784)
|
||||||
streams["combo"] = _tone(660, 0.2, "sine", 1100)
|
streams["combo"] = _tone(660, 0.2, "sine", 1100)
|
||||||
|
streams["buy"] = _tone(1200, 0.08, "sine", 1700)
|
||||||
|
streams["turn"] = _tone(520, 0.3, "sine", 1040)
|
||||||
streams["win"] = _tone(400, 0.6, "sine", 1200)
|
streams["win"] = _tone(400, 0.6, "sine", 1200)
|
||||||
streams["lose"] = _tone(320, 0.7, "square", 90)
|
streams["lose"] = _tone(320, 0.7, "square", 90)
|
||||||
for i in 4:
|
for i in 4:
|
||||||
@@ -79,7 +79,7 @@ func _music() -> AudioStreamWAV:
|
|||||||
|
|
||||||
func set_music_volume(v: float) -> void:
|
func set_music_volume(v: float) -> void:
|
||||||
music_volume = clampf(v, 0.0, 1.0)
|
music_volume = clampf(v, 0.0, 1.0)
|
||||||
if music_volume <= 0.0:
|
if music_volume <= 0.0 or muted:
|
||||||
music_player.stop()
|
music_player.stop()
|
||||||
else:
|
else:
|
||||||
music_player.volume_db = linear_to_db(music_volume * 0.6)
|
music_player.volume_db = linear_to_db(music_volume * 0.6)
|
||||||
@@ -92,7 +92,7 @@ func set_volume(v: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func play(kind: String) -> void:
|
func play(kind: String) -> void:
|
||||||
if volume <= 0.0 or not streams.has(kind):
|
if volume <= 0.0 or muted or not streams.has(kind):
|
||||||
return
|
return
|
||||||
for p in players:
|
for p in players:
|
||||||
if not p.playing:
|
if not p.playing:
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
class_name SkillScreen
|
||||||
|
extends Control
|
||||||
|
## Skilltree: oben Level, Erfahrung bis zum nächsten Level und freie Skillpunkte; darunter drei
|
||||||
|
## Äste (Skills.BRANCHES) mit ihren Skills als Karten: Name, Wirkung, aktuelle und nächste Stufe
|
||||||
|
## in Zahlen (Skills.effect), Stufe und „Lernen“.
|
||||||
|
|
||||||
|
var app
|
||||||
|
var level_label: Label
|
||||||
|
var xp_label: Label
|
||||||
|
var points_label: Label
|
||||||
|
var msg_label: Label
|
||||||
|
var learn_buttons := {} # Skill-ID -> Knopf
|
||||||
|
var rank_labels := {} # Skill-ID -> Label
|
||||||
|
var now_labels := {} # Skill-ID -> Wirkung jetzt
|
||||||
|
var next_labels := {} # Skill-ID -> Wirkung der nächsten Stufe
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
Style.header(self, "Skilltree", func(): app.show_screen(MenuScreen.new()))
|
||||||
|
level_label = Style.chip(self, "", Rect2(40, 86, 220, 38))
|
||||||
|
xp_label = Style.chip(self, "", Rect2(276, 86, 400, 38), Style.BLUE)
|
||||||
|
points_label = Style.chip(self, "", Rect2(692, 86, 300, 38), Style.GREEN)
|
||||||
|
Style.label(self, "Erfahrung sind deine verdienten Münzen. Jedes Level bringt einen Skillpunkt.", 14, Style.MUTED, Rect2(40, 134, 1200, 22), "left")
|
||||||
|
for b in Skills.BRANCHES.size():
|
||||||
|
var branch: String = Skills.BRANCHES[b]
|
||||||
|
var x := 40 + b * 410
|
||||||
|
Style.panel(self, Rect2(x, 170, 390, 580))
|
||||||
|
Style.label(self, branch, 22, Style.GOLD, Rect2(x, 184, 390, 32), "center", true)
|
||||||
|
var y := 236
|
||||||
|
for id in Skills.in_branch(branch):
|
||||||
|
var sk: Dictionary = Skills.SKILLS[id]
|
||||||
|
Style.panel(self, Rect2(x + 16, y, 358, 176), Style.PANEL_LIGHT, 8)
|
||||||
|
Style.label(self, sk.name, 18, Style.TEXT, Rect2(x + 32, y + 10, 326, 26), "left", true)
|
||||||
|
Style.label(self, sk.text, 14, Style.MUTED, Rect2(x + 32, y + 38, 326, 22), "left")
|
||||||
|
now_labels[id] = Style.label(self, "", 14, Style.TEXT, Rect2(x + 32, y + 64, 326, 22), "left")
|
||||||
|
next_labels[id] = Style.label(self, "", 14, Style.GREEN, Rect2(x + 32, y + 86, 326, 22), "left")
|
||||||
|
rank_labels[id] = Style.label(self, "", 15, Style.GOLD, Rect2(x + 32, y + 126, 150, 30), "left", true)
|
||||||
|
learn_buttons[id] = Style.button(self, "Lernen", Rect2(x + 214, y + 122, 144, 40), learn.bind(id), Style.PRIMARY)
|
||||||
|
y += 192
|
||||||
|
msg_label = Style.label(self, "", 15, Style.RED, Rect2(0, 760, 1280, 24), "center")
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
app.show_screen(MenuScreen.new())
|
||||||
|
|
||||||
|
|
||||||
|
func learn(id: String) -> void:
|
||||||
|
var err: String = app.profile.learn(id)
|
||||||
|
msg_label.text = tr(err) if err != "" else ""
|
||||||
|
if err == "":
|
||||||
|
app.sfx.play("combo")
|
||||||
|
app.save()
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func refresh() -> void:
|
||||||
|
var p: Profile = app.profile
|
||||||
|
var lvl := p.player_level()
|
||||||
|
var base := Skills.xp_for_level(lvl)
|
||||||
|
level_label.text = tr("Level %d") % lvl
|
||||||
|
xp_label.text = tr("%d / %d EP bis Level %d") % [p.xp - base, Skills.xp_for_level(lvl + 1) - base, lvl + 1]
|
||||||
|
points_label.text = tr("Skillpunkte: %d") % p.skill_points()
|
||||||
|
for id in Skills.SKILLS:
|
||||||
|
var sk: Dictionary = Skills.SKILLS[id]
|
||||||
|
rank_labels[id].text = tr("Stufe %d / %d") % [p.skill(id), int(sk.max)]
|
||||||
|
now_labels[id].text = tr("Jetzt: %s") % Skills.effect(id, p.skill(id))
|
||||||
|
next_labels[id].text = tr("Nächste Stufe: %s") % Skills.effect(id, p.skill(id) + 1) if p.skill(id) < int(sk.max) else tr("Höchste Stufe erreicht")
|
||||||
|
learn_buttons[id].disabled = p.skill_points() <= 0 or p.skill(id) >= int(sk.max)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b0dgryn2kpic5
|
||||||
+396
-56
@@ -2,12 +2,9 @@ class_name Style
|
|||||||
## Farben, Schriften, Theme und Zeichenhilfen.
|
## Farben, Schriften, Theme und Zeichenhilfen.
|
||||||
|
|
||||||
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
|
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
|
||||||
static var font_scale := 1.0 # Einstellung „Große Schrift“
|
|
||||||
|
|
||||||
|
|
||||||
static func fs(size: int) -> int:
|
|
||||||
return int(round(size * font_scale))
|
|
||||||
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
|
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
|
||||||
|
static var font_scale := 1.0 # Einstellung „Große Schrift“
|
||||||
|
static var animations := true # Einstellung „Animationen“: der Glanz seltener Karten bewegt sich
|
||||||
|
|
||||||
const BG := Color("1a1c24")
|
const BG := Color("1a1c24")
|
||||||
const PANEL := Color("282b36")
|
const PANEL := Color("282b36")
|
||||||
@@ -20,6 +17,22 @@ const RED := Color("e65555")
|
|||||||
const BLUE := Color("6eaaf0")
|
const BLUE := Color("6eaaf0")
|
||||||
const YELLOW := Color("ffe65a")
|
const YELLOW := Color("ffe65a")
|
||||||
const ORANGE := Color("ff963c")
|
const ORANGE := Color("ff963c")
|
||||||
|
const PURPLE := Color("8c78dc")
|
||||||
|
const AMBER := Color("f0b429") # Angriff
|
||||||
|
const LIFE := Color("4a86d8") # Leben
|
||||||
|
const MANA := Color("3a78d2") # Kostenkristall
|
||||||
|
const PRIMARY := Color("285a3c") # Hauptaktion
|
||||||
|
const DANGER := Color("5a2828")
|
||||||
|
|
||||||
|
|
||||||
|
## Schriftgröße mit der Einstellung „Große Schrift“.
|
||||||
|
## Faktor als Text: ×2, ×1,5 (Dezimaltrenner der Sprache).
|
||||||
|
static func factor(f: float) -> String:
|
||||||
|
return "×" + String.num(f, 1).trim_suffix(".0").replace(".", TranslationServer.translate(","))
|
||||||
|
|
||||||
|
|
||||||
|
static func fs(size: int) -> int:
|
||||||
|
return int(round(size * font_scale))
|
||||||
|
|
||||||
|
|
||||||
static func darker(c: Color, f: float = 0.65) -> Color:
|
static func darker(c: Color, f: float = 0.65) -> Color:
|
||||||
@@ -78,7 +91,11 @@ static func panel(parent: Node, rect: Rect2, bg: Color = PANEL, radius: int = 8,
|
|||||||
var p := Panel.new()
|
var p := Panel.new()
|
||||||
p.position = rect.position
|
p.position = rect.position
|
||||||
p.size = rect.size
|
p.size = rect.size
|
||||||
p.add_theme_stylebox_override("panel", box(bg, radius, border_col, border_w))
|
var sb := box(bg, radius, border_col, border_w)
|
||||||
|
sb.shadow_color = Color(0, 0, 0, 0.35)
|
||||||
|
sb.shadow_size = 6
|
||||||
|
sb.shadow_offset = Vector2(0, 3)
|
||||||
|
p.add_theme_stylebox_override("panel", sb)
|
||||||
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
p.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
parent.add_child(p)
|
parent.add_child(p)
|
||||||
return p
|
return p
|
||||||
@@ -113,10 +130,37 @@ static func button(parent: Node, text: String, rect: Rect2, on_pressed: Callable
|
|||||||
b.add_theme_stylebox_override("normal", box(color, 8, MUTED, 2))
|
b.add_theme_stylebox_override("normal", box(color, 8, MUTED, 2))
|
||||||
b.add_theme_stylebox_override("hover", box(lighter(color), 8, MUTED, 2))
|
b.add_theme_stylebox_override("hover", box(lighter(color), 8, MUTED, 2))
|
||||||
b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2))
|
b.add_theme_stylebox_override("pressed", box(darker(color), 8, MUTED, 2))
|
||||||
|
juice(b)
|
||||||
parent.add_child(b)
|
parent.add_child(b)
|
||||||
return b
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
## Knöpfe federn beim Drücken kurz ein.
|
||||||
|
static func juice(b: Button) -> void:
|
||||||
|
b.button_down.connect(func():
|
||||||
|
if animations:
|
||||||
|
b.pivot_offset = b.size / 2
|
||||||
|
b.create_tween().tween_property(b, "scale", Vector2(0.96, 0.96), 0.05))
|
||||||
|
b.button_up.connect(func():
|
||||||
|
if animations:
|
||||||
|
b.pivot_offset = b.size / 2
|
||||||
|
b.create_tween().tween_property(b, "scale", Vector2.ONE, 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT))
|
||||||
|
|
||||||
|
|
||||||
|
## Kopfzeile eines Unterbildschirms: Titel links, Zurück rechts oben.
|
||||||
|
static func header(parent: Node, title: String, on_back: Callable) -> Button:
|
||||||
|
label(parent, title, 34, GOLD, Rect2(40, 22, 700, 46), "left", true)
|
||||||
|
return button(parent, "Zurück", Rect2(1120, 26, 120, 40), on_back)
|
||||||
|
|
||||||
|
|
||||||
|
## Kleine Kachel mit Text, etwa für Münzen und Staub.
|
||||||
|
static func chip(parent: Node, text: String, rect: Rect2, color: Color = GOLD) -> Label:
|
||||||
|
panel(parent, rect, darker(PANEL, 0.9), rect.size.y / 2, PANEL_LIGHT, 1)
|
||||||
|
var l := label(parent, text, 17, color, rect, "center", true)
|
||||||
|
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
return l
|
||||||
|
|
||||||
|
|
||||||
## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme).
|
## Vertikal zentrierte Spalte (Container-Layout für Menübildschirme).
|
||||||
static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer:
|
static func column(parent: Node, width: float, top: float, sep: int = 12) -> VBoxContainer:
|
||||||
var v := VBoxContainer.new()
|
var v := VBoxContainer.new()
|
||||||
@@ -228,6 +272,33 @@ static func text(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vec
|
|||||||
ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
|
ci.draw_string(font, Vector2(x, y + font.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
|
||||||
|
|
||||||
|
|
||||||
|
## Text mit dunklem Rand: bleibt auf jeder Farbe lesbar (Werte, Kosten).
|
||||||
|
static func text_outlined(ci: CanvasItem, s: String, size_in: int, color: Color, pos: Vector2, anchor: String = "center", outline: int = 4) -> void:
|
||||||
|
var size := fs(size_in)
|
||||||
|
var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
|
||||||
|
var x := pos.x
|
||||||
|
var y := pos.y
|
||||||
|
match anchor:
|
||||||
|
"center":
|
||||||
|
x -= dim.x / 2
|
||||||
|
y -= dim.y / 2
|
||||||
|
"midtop":
|
||||||
|
x -= dim.x / 2
|
||||||
|
"midleft":
|
||||||
|
y -= dim.y / 2
|
||||||
|
var at := Vector2(x, y + FONT_BOLD.get_ascent(size))
|
||||||
|
ci.draw_string_outline(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, outline, Color(0.05, 0.05, 0.08, 0.95))
|
||||||
|
ci.draw_string(FONT_BOLD, at, s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
|
||||||
|
|
||||||
|
|
||||||
|
## Schriftgröße, mit der ein Text in eine Breite passt (bis zur Mindestgröße).
|
||||||
|
static func fit_size(s: String, bold: bool, size: int, max_w: float, min_size: int = 9) -> int:
|
||||||
|
var font: Font = FONT_BOLD if bold else FONT
|
||||||
|
while size > min_size and font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, fs(size)).x > max_w:
|
||||||
|
size -= 1
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
static func fit(s: String, bold: bool, size: int, max_w: float) -> String:
|
static func fit(s: String, bold: bool, size: int, max_w: float) -> String:
|
||||||
var font: Font = FONT_BOLD if bold else FONT
|
var font: Font = FONT_BOLD if bold else FONT
|
||||||
while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3:
|
while font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x > max_w and s.length() > 3:
|
||||||
@@ -244,65 +315,329 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius
|
|||||||
ci.draw_style_box(sb, rect)
|
ci.draw_style_box(sb, rect)
|
||||||
|
|
||||||
|
|
||||||
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
|
# --- Seltenheit als Aussehen ------------------------------------------------------
|
||||||
var a: Color = Cards.ELEMENT_COLORS[elements[0]]
|
# Common matt; Rare silbern glänzender Name; Super Rare Holo-Bild (wandernder Regenbogen im
|
||||||
if elements.size() == 1:
|
# Bildkreis); Ultra Rare goldener Name und Holo-Bild; Ultimate Rare goldener Name, goldenes
|
||||||
ci.draw_style_box(box(a, radius), rect)
|
# Bild, glänzende Werte, Schimmer über die ganze Karte, Funkeln und schillernder Rahmen.
|
||||||
elif elements[0] == elements[1]:
|
# Rahmen und Schein tragen ab Rare die Stufenfarbe.
|
||||||
# Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen
|
|
||||||
ci.draw_style_box(box(a, radius), rect)
|
## Ab Rare bewegt sich der Glanz; dann zeichnet die Karte jeden Frame neu.
|
||||||
border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3))
|
static func rarity_animated(rar: String) -> bool:
|
||||||
|
return animations and Cards.RARITIES.find(rar) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
## Farbe einer Stufe; Ultimate Rare schillert über die Zeit durch alle Farben.
|
||||||
|
static func rarity_color(rar: String, seed: int = 0) -> Color:
|
||||||
|
if rar == "Ultimate Rare" and animations:
|
||||||
|
return Color.from_hsv(fmod(Time.get_ticks_msec() / 1000.0 * 0.12 + (seed % 97) / 97.0, 1.0), 0.55, 1.0)
|
||||||
|
return Cards.RARITY_COLORS[rar]
|
||||||
|
|
||||||
|
|
||||||
|
## Rahmenfarbe einer Stufe: Common bleibt schlicht, alles darüber trägt seine Farbe.
|
||||||
|
static func rarity_frame(rar: String, seed: int = 0) -> Color:
|
||||||
|
return Color("3a3e4c") if rar == "Common" else rarity_color(rar, seed)
|
||||||
|
|
||||||
|
|
||||||
|
## Farbe des Kartennamens: Rare silbern, Ultra und Ultimate Rare golden, sonst weiß.
|
||||||
|
static func rarity_name_color(rar: String) -> Color:
|
||||||
|
match rar:
|
||||||
|
"Rare":
|
||||||
|
return Cards.RARITY_COLORS["Rare"]
|
||||||
|
"Ultra Rare", "Ultimate Rare":
|
||||||
|
return Cards.RARITY_COLORS["Ultra Rare"]
|
||||||
|
return TEXT
|
||||||
|
|
||||||
|
|
||||||
|
## Der Name glänzt bei Rare (silbern), Ultra und Ultimate Rare (golden).
|
||||||
|
static func rarity_name_shiny(rar: String) -> bool:
|
||||||
|
return rar in ["Rare", "Ultra Rare", "Ultimate Rare"]
|
||||||
|
|
||||||
|
|
||||||
|
## Ultimate Rare: auch Kosten, Angriff und Leben glänzen.
|
||||||
|
static func rarity_stats_shiny(rar: String) -> bool:
|
||||||
|
return rar == "Ultimate Rare"
|
||||||
|
|
||||||
|
|
||||||
|
## Ultimate Rare zeigt das Bild als Relief (Licht- und Schattenkante).
|
||||||
|
static func rarity_relief(rar: String) -> bool:
|
||||||
|
return rar == "Ultimate Rare"
|
||||||
|
|
||||||
|
|
||||||
|
## Kartenname links in rect, immer eine Zeile, nie abgeschnitten: die Schrift schrumpft bis
|
||||||
|
## drei Punkte unter die Vorgabe, danach wird der Text schmaler gezeichnet (wie bei langen
|
||||||
|
## Yu-Gi-Oh-Namen).
|
||||||
|
static func card_name(ci: CanvasItem, s: String, size_in: int, rar: String, rect: Rect2, seed: int) -> void:
|
||||||
|
var fsize := fit_size(s, true, size_in, rect.size.x, maxi(8, size_in - 3))
|
||||||
|
var w := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, fs(fsize)).x
|
||||||
|
var squeeze := minf(1.0, rect.size.x / maxf(w, 1.0))
|
||||||
|
var y := rect.position.y + (rect.size.y - FONT_BOLD.get_height(fs(fsize))) / 2
|
||||||
|
ci.draw_set_transform(Vector2(rect.position.x, y), 0.0, Vector2(squeeze, 1.0))
|
||||||
|
if rarity_name_shiny(rar):
|
||||||
|
shiny_text(ci, s, fsize, rarity_name_color(rar), Vector2.ZERO, "topleft", seed)
|
||||||
else:
|
else:
|
||||||
var b: Color = Cards.ELEMENT_COLORS[elements[1]]
|
text(ci, s, fsize, rarity_name_color(rar), Vector2.ZERO, "topleft", true)
|
||||||
var half := floorf(rect.size.x / 2)
|
ci.draw_set_transform(Vector2.ZERO)
|
||||||
var left := StyleBoxFlat.new()
|
|
||||||
left.bg_color = a
|
|
||||||
left.corner_radius_top_left = radius
|
|
||||||
left.corner_radius_bottom_left = radius
|
|
||||||
var right := StyleBoxFlat.new()
|
|
||||||
right.bg_color = b
|
|
||||||
right.corner_radius_top_right = radius
|
|
||||||
right.corner_radius_bottom_right = radius
|
|
||||||
ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y)))
|
|
||||||
ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y)))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Kartensymbole (prozedural, je Element und Kartenart) --------------------------
|
## 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
|
||||||
|
|
||||||
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
|
||||||
|
## Ultimate Rare: schwacher schillernder Schimmer über die ganze Karte.
|
||||||
|
static func card_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> void:
|
||||||
|
if rar != "Ultimate Rare":
|
||||||
|
return
|
||||||
|
var phase := (seed % 100) / 100.0
|
||||||
|
if animations:
|
||||||
|
phase = fmod(Time.get_ticks_msec() / 2500.0 + phase, 1.0)
|
||||||
|
var soft := []
|
||||||
|
for i in 3:
|
||||||
|
soft.append([1.0 - i * 0.3, Color.from_hsv(fmod(phase + 0.5 + i * 0.2, 1.0), 0.5, 1.0, 0.07)])
|
||||||
|
_sheen_bands(ci, rect_poly(rect), rect, fmod(phase + 0.5, 1.0), soft)
|
||||||
|
|
||||||
|
|
||||||
|
static func rect_poly(rect: Rect2) -> PackedVector2Array:
|
||||||
|
return PackedVector2Array([rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)])
|
||||||
|
|
||||||
|
|
||||||
|
static func circle_poly(c: Vector2, r: float, steps: int = 40) -> PackedVector2Array:
|
||||||
|
var pts := PackedVector2Array()
|
||||||
|
for i in steps:
|
||||||
|
var a := TAU * i / steps
|
||||||
|
pts.append(c + Vector2(cos(a), sin(a)) * r)
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
## Rechteck mit runden Ecken als Polygon (für Beschnitt).
|
||||||
|
static func rounded_poly(rect: Rect2, radius: float) -> PackedVector2Array:
|
||||||
|
var pts := PackedVector2Array()
|
||||||
|
var r := minf(radius, minf(rect.size.x, rect.size.y) / 2)
|
||||||
|
var corners := [[rect.end - Vector2(r, r), 0.0], [Vector2(rect.position.x + r, rect.end.y - r), PI / 2],
|
||||||
|
[rect.position + Vector2(r, r), PI], [Vector2(rect.end.x - r, rect.position.y + r), PI * 1.5]]
|
||||||
|
for corner in corners:
|
||||||
|
for k in 7:
|
||||||
|
var a: float = corner[1] + PI / 2 * k / 6
|
||||||
|
pts.append(corner[0] + Vector2(cos(a), sin(a)) * r)
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
## Schräge Streifen über bounds, auf clip beschnitten. layers: [Breitenanteil, Farbe].
|
||||||
|
static func _sheen_bands(ci: CanvasItem, clip: PackedVector2Array, bounds: Rect2, phase: float, layers: Array) -> void:
|
||||||
|
var w := bounds.size.x * 0.34
|
||||||
|
var k := bounds.size.y * 0.75 # Schräge
|
||||||
|
var x0 := bounds.position.x - w + (bounds.size.x + w + k) * phase
|
||||||
|
for layer in layers:
|
||||||
|
var lw: float = w * layer[0]
|
||||||
|
var lx := x0 + (w - lw) / 2
|
||||||
|
var band := PackedVector2Array([Vector2(lx, bounds.position.y), Vector2(lx + lw, bounds.position.y), Vector2(lx + lw - k, bounds.end.y), Vector2(lx - k, bounds.end.y)])
|
||||||
|
for poly in Geometry2D.intersect_polygons(band, clip):
|
||||||
|
if poly.size() >= 3 and _area(poly) > 2.0: # Splitter am Rand lassen sich nicht zeichnen
|
||||||
|
ci.draw_colored_polygon(poly, layer[1])
|
||||||
|
|
||||||
|
|
||||||
|
static func _area(poly: PackedVector2Array) -> float:
|
||||||
|
var a := 0.0
|
||||||
|
for i in poly.size():
|
||||||
|
var p := poly[i]
|
||||||
|
var q := poly[(i + 1) % poly.size()]
|
||||||
|
a += p.x * q.y - q.x * p.y
|
||||||
|
return absf(a) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
## Funkeln bei Ultimate Rare: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen.
|
||||||
|
static func rarity_sparkle(ci: CanvasItem, rect: Rect2, rar: String, seed: int) -> void:
|
||||||
|
if rar != "Ultimate Rare":
|
||||||
|
return
|
||||||
|
var t := Time.get_ticks_msec() / 1000.0
|
||||||
|
for i in 9:
|
||||||
|
var h := absi(hash(Vector2i(seed, i)))
|
||||||
|
var p := rect.position + Vector2(8 + (h % 1000) / 1000.0 * (rect.size.x - 16), 8 + ((h / 1000) % 1000) / 1000.0 * (rect.size.y - 16))
|
||||||
|
var tw := 0.5 + 0.5 * sin(t * 2.5 + i * 1.9 + seed % 7) if animations else 0.35 + 0.65 * ((i * 7 + seed) % 5) / 4.0
|
||||||
|
if tw < 0.15:
|
||||||
|
continue
|
||||||
|
var s := 2.0 + 4.0 * tw
|
||||||
|
var col := Color(1, 1, 1, 0.3 + 0.65 * tw)
|
||||||
|
ci.draw_line(p - Vector2(s, 0), p + Vector2(s, 0), col, 1.4)
|
||||||
|
ci.draw_line(p - Vector2(0, s), p + Vector2(0, s), col, 1.4)
|
||||||
|
ci.draw_circle(p, 1.3, col)
|
||||||
|
|
||||||
|
|
||||||
|
## Weicher Schein um eine Karte höherer Stufe; vor dem Hintergrund zeichnen.
|
||||||
|
static func glow(ci: CanvasItem, rect: Rect2, color: Color, radius: int = 8, spread: int = 8) -> void:
|
||||||
|
var sb := StyleBoxFlat.new()
|
||||||
|
sb.bg_color = Color(0, 0, 0, 0)
|
||||||
|
sb.set_corner_radius_all(radius)
|
||||||
|
sb.shadow_color = Color(color, 0.6)
|
||||||
|
sb.shadow_size = spread
|
||||||
|
ci.draw_style_box(sb, rect)
|
||||||
|
|
||||||
|
|
||||||
|
## Hintergrund in den Farben der Elemente: ein Element füllt die Karte, verschmolzene Wesen
|
||||||
|
## tragen je Element einen senkrechten Streifen.
|
||||||
|
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
|
||||||
|
ci.draw_style_box(box(Cards.ELEMENT_COLORS[elements[0]], radius), rect)
|
||||||
|
if elements.size() < 2:
|
||||||
|
return
|
||||||
|
var clip := rounded_poly(rect, radius)
|
||||||
|
var w := rect.size.x / elements.size()
|
||||||
|
for i in range(1, elements.size()):
|
||||||
|
var strip := Rect2(rect.position.x + w * i, rect.position.y, w, rect.size.y)
|
||||||
|
for poly in Geometry2D.intersect_polygons(rect_poly(strip), clip):
|
||||||
|
if poly.size() >= 3:
|
||||||
|
ci.draw_colored_polygon(poly, Cards.ELEMENT_COLORS[elements[i]])
|
||||||
|
ci.draw_line(strip.position + Vector2(0, 2), Vector2(strip.position.x, strip.end.y - 2), Color(0.05, 0.05, 0.08, 0.5), 1.5)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kartenteile ------------------------------------------------------
|
||||||
|
|
||||||
|
## Bild der Karte: dunkler Kreis mit Ring in der Elementfarbe, darin das Symbol der Bildkarte
|
||||||
|
## (bei Verschmelzungen die des stärkeren Elternteils). Ab Super Rare glänzt der Kreis (Holo),
|
||||||
|
## bei Ultimate Rare golden und als Relief.
|
||||||
|
static func picture(ci: CanvasItem, card, c: Vector2, r: float, rar: String, seed: int) -> void:
|
||||||
|
var src = card.art_card()
|
||||||
|
var ring := lighter(Cards.ELEMENT_COLORS[src.elements[0]], 0.3)
|
||||||
|
ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.8))
|
||||||
|
ci.draw_circle(c, r, Color(0.09, 0.09, 0.13, 0.9))
|
||||||
|
glyph(ci, src, c, r * 1.3, 0.95, rarity_relief(rar))
|
||||||
|
var lvl := Cards.RARITIES.find(rar)
|
||||||
|
if lvl >= 2:
|
||||||
|
sheen(ci, circle_poly(c, r), Rect2(c - Vector2(r, r), Vector2(r * 2, r * 2)), seed, lvl >= 4)
|
||||||
|
ci.draw_arc(c, r + 1.5, 0, TAU, 48, Color(1.0, 0.86, 0.45) if lvl >= 4 else ring, 3.0, true)
|
||||||
|
|
||||||
|
|
||||||
|
## Kostenkristall: sechseckig, blau, Zahl in der Mitte; shiny: goldene, glänzende Zahl.
|
||||||
|
static func cost_crystal(ci: CanvasItem, c: Vector2, r: float, cost: int, font: int, shiny: bool = false) -> void:
|
||||||
|
var pts := PackedVector2Array()
|
||||||
|
var outer := PackedVector2Array()
|
||||||
|
for i in 6:
|
||||||
|
var a := -PI / 2 + i * PI / 3
|
||||||
|
pts.append(c + Vector2(cos(a), sin(a)) * r)
|
||||||
|
outer.append(c + Vector2(cos(a), sin(a)) * (r + 2))
|
||||||
|
ci.draw_colored_polygon(outer, Color(1.0, 0.86, 0.45) if shiny else Color("14141c"))
|
||||||
|
ci.draw_colored_polygon(pts, MANA)
|
||||||
|
var shine := PackedVector2Array([pts[5], pts[0], c + Vector2(0, -r * 0.1), c + Vector2(-r * 0.55, r * 0.15)])
|
||||||
|
ci.draw_colored_polygon(shine, Color(1, 1, 1, 0.22))
|
||||||
|
_stat_text(ci, str(cost), font, Color.WHITE, c, shiny)
|
||||||
|
|
||||||
|
|
||||||
|
## Wertkreis (Angriff gelb, Leben blau) mit Zahl; shiny: goldener Ring, glänzende Zahl.
|
||||||
|
static func stat_circle(ci: CanvasItem, c: Vector2, r: float, value: String, color: Color, font: int, text_col: Color = Color.WHITE, shiny: bool = false) -> void:
|
||||||
|
ci.draw_circle(c, r + 2, Color(1.0, 0.86, 0.45) if shiny else Color("14141c"))
|
||||||
|
ci.draw_circle(c, r, color)
|
||||||
|
ci.draw_circle(c + Vector2(-r * 0.25, -r * 0.3), r * 0.45, Color(1, 1, 1, 0.16))
|
||||||
|
_stat_text(ci, value, font, text_col, c, shiny)
|
||||||
|
|
||||||
|
|
||||||
|
static func _stat_text(ci: CanvasItem, s: String, font: int, col: Color, c: Vector2, shiny: bool) -> void:
|
||||||
|
if shiny and col == Color.WHITE:
|
||||||
|
var size := fs(font)
|
||||||
|
var dim := FONT_BOLD.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
|
||||||
|
ci.draw_string_outline(FONT_BOLD, c - dim / 2 + Vector2(0, FONT_BOLD.get_ascent(size)), s, HORIZONTAL_ALIGNMENT_LEFT, -1, size, 3, Color(0.05, 0.05, 0.08, 0.95))
|
||||||
|
shiny_text(ci, s, font, Color(1.0, 0.86, 0.45), c, "center", int(c.x + c.y))
|
||||||
|
else:
|
||||||
|
text_outlined(ci, s, font, col, c, "center", 3)
|
||||||
|
|
||||||
|
|
||||||
|
## Eigenschaft als Symbol auf dunklem Kreis: Spott Ausrufezeichen, Flink Doppelpfeil,
|
||||||
|
## Regeneration Kreuz, Doppelschlag gekreuzte Klingen.
|
||||||
|
static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: Color = Color("f2f2f6")) -> void:
|
||||||
|
ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.7))
|
||||||
|
var w := maxf(1.5, r * 0.22)
|
||||||
|
match kw:
|
||||||
|
"Spott":
|
||||||
|
ci.draw_line(c + Vector2(0, -r * 0.7), c + Vector2(0, r * 0.2), col, w * 1.6)
|
||||||
|
ci.draw_circle(c + Vector2(0, r * 0.6), w * 0.95, col)
|
||||||
|
"Regeneration":
|
||||||
|
ci.draw_line(c + Vector2(0, -r * 0.7), c + Vector2(0, r * 0.7), col, w * 1.5)
|
||||||
|
ci.draw_line(c + Vector2(-r * 0.7, 0), c + Vector2(r * 0.7, 0), col, w * 1.5)
|
||||||
|
"Flink":
|
||||||
|
for i in 2:
|
||||||
|
var x := c.x - r * 0.55 + i * r * 0.55
|
||||||
|
ci.draw_polyline(PackedVector2Array([Vector2(x, c.y - r * 0.6), Vector2(x + r * 0.5, c.y), Vector2(x, c.y + r * 0.6)]), col, w)
|
||||||
|
"Doppelschlag":
|
||||||
|
ci.draw_line(c + Vector2(-r * 0.65, r * 0.65), c + Vector2(r * 0.6, -r * 0.6), col, w)
|
||||||
|
ci.draw_line(c + Vector2(r * 0.65, r * 0.65), c + Vector2(-r * 0.6, -r * 0.6), col, w)
|
||||||
|
ci.draw_line(c + Vector2(-r * 0.7, r * 0.35), c + Vector2(-r * 0.35, r * 0.7), col, w)
|
||||||
|
ci.draw_line(c + Vector2(r * 0.7, r * 0.35), c + Vector2(r * 0.35, r * 0.7), col, w)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kartensymbole (prozedural, je Element) --------------------------
|
||||||
|
|
||||||
|
## relief: Schatten- und Lichtkante unter dem Symbol (Ultimate Rare).
|
||||||
|
static func glyph(ci: CanvasItem, card, center: Vector2, size: float, alpha: float = 0.95, relief: bool = false) -> void:
|
||||||
|
if relief:
|
||||||
|
ci.draw_set_transform(Vector2(2, 2))
|
||||||
|
_glyph_plain(ci, card, center, size, Color(0, 0, 0, alpha * 0.6))
|
||||||
|
ci.draw_set_transform(Vector2(-1, -1))
|
||||||
|
_glyph_plain(ci, card, center, size, Color(1, 1, 1, alpha * 0.5))
|
||||||
|
ci.draw_set_transform(Vector2.ZERO)
|
||||||
|
_glyph_plain(ci, card, center, size, Color(0, 0, 0, 0), alpha)
|
||||||
|
|
||||||
|
|
||||||
|
## Symbol in einer festen Farbe (Relief) oder, bei durchsichtigem tint, in Elementfarben.
|
||||||
|
static func _glyph_plain(ci: CanvasItem, card, center: Vector2, size: float, tint: Color, alpha: float = 0.95) -> void:
|
||||||
var variant := absi(hash(card.id)) % 3
|
var variant := absi(hash(card.id)) % 3
|
||||||
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
|
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
|
||||||
col.a = 0.95
|
col.a = alpha
|
||||||
if card.is_trap():
|
if tint.a > 0:
|
||||||
# Falle: Kreuz im Kreis
|
col = tint
|
||||||
ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08))
|
|
||||||
var d := size * 0.28
|
|
||||||
ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1))
|
|
||||||
ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1))
|
|
||||||
return
|
|
||||||
if card.kind == "Zauber":
|
|
||||||
_star(ci, center, size * 0.55, Color(col, 0.35))
|
|
||||||
var elems: Array = card.elements
|
var elems: Array = card.elements
|
||||||
if elems.size() >= 2 and elems[0] != elems[1]:
|
if elems.size() == 1:
|
||||||
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
|
|
||||||
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
|
|
||||||
col2.a = 0.95
|
|
||||||
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
|
|
||||||
else:
|
|
||||||
_element_glyph(ci, elems[0], center, size, variant, col)
|
_element_glyph(ci, elems[0], center, size, variant, col)
|
||||||
if elems.size() >= 2:
|
return
|
||||||
ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2)
|
# Verschmolzen: die ersten beiden Elemente nebeneinander, ab dem dritten ein Ring
|
||||||
if elems.size() >= 3:
|
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
|
||||||
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
|
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
|
||||||
|
col2.a = alpha
|
||||||
|
if tint.a > 0:
|
||||||
|
col2 = tint
|
||||||
|
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
|
||||||
|
if elems.size() >= 3:
|
||||||
|
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
|
||||||
|
|
||||||
|
|
||||||
static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void:
|
## Elementsymbol in einer Farbe (Filterknöpfe, Listen), Grundvariante.
|
||||||
var pts := PackedVector2Array()
|
static func element_icon(ci: CanvasItem, element: String, c: Vector2, size: float, col: Color) -> void:
|
||||||
for i in 8:
|
_element_glyph(ci, element, c, size, 0, col)
|
||||||
var a := TAU * i / 8 - PI / 2
|
|
||||||
var rr := r if i % 2 == 0 else r * 0.45
|
|
||||||
pts.append(c + Vector2(cos(a), sin(a)) * rr)
|
|
||||||
ci.draw_colored_polygon(pts, col)
|
|
||||||
|
|
||||||
|
|
||||||
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
|
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
|
||||||
@@ -332,6 +667,11 @@ static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: fl
|
|||||||
for i in 2 + variant:
|
for i in 2 + variant:
|
||||||
var r := h * (1.0 - i * 0.28)
|
var r := h * (1.0 - i * 0.28)
|
||||||
ci.draw_arc(c + Vector2(i * size * 0.06, 0), r, PI * 0.2 + i * 0.4, PI * 1.5 + i * 0.4, 20, col, maxf(2, size * 0.09))
|
ci.draw_arc(c + Vector2(i * size * 0.06, 0), r, PI * 0.2 + i * 0.4, PI * 1.5 + i * 0.4, 20, col, maxf(2, size * 0.09))
|
||||||
|
_: # Neutral: ein schlichter Schild
|
||||||
|
var w := h * 0.8
|
||||||
|
var pts := PackedVector2Array([c + Vector2(-w, -h * 0.8), c + Vector2(w, -h * 0.8), c + Vector2(w, h * 0.1), c + Vector2(0, h), c + Vector2(-w, h * 0.1)])
|
||||||
|
ci.draw_polyline(pts + PackedVector2Array([pts[0]]), col, maxf(2, size * 0.09))
|
||||||
|
ci.draw_line(c + Vector2(0, -h * 0.5), c + Vector2(0, h * 0.6), col, maxf(2, size * 0.07))
|
||||||
|
|
||||||
|
|
||||||
static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
|
static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
|
||||||
@@ -342,7 +682,7 @@ static func _flame(ci: CanvasItem, c: Vector2, h: float, col: Color) -> void:
|
|||||||
ci.draw_colored_polygon(pts, col)
|
ci.draw_colored_polygon(pts, col)
|
||||||
var inner := PackedVector2Array([
|
var inner := PackedVector2Array([
|
||||||
c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)])
|
c + Vector2(0, -h * 0.35), c + Vector2(w * 0.25, h * 0.35), c + Vector2(0, h * 0.75), c + Vector2(-w * 0.25, h * 0.35)])
|
||||||
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75))
|
ci.draw_colored_polygon(inner, Color(1.0, 0.9, 0.5, 0.75 * col.a))
|
||||||
|
|
||||||
|
|
||||||
## Dezenter Hintergrund: Farbverlauf und schräge Linien.
|
## Dezenter Hintergrund: Farbverlauf und schräge Linien.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen (5 Gegner).
|
## Fensterprobe: Kosten von refresh() und längste Bildzeit während KI-Zügen.
|
||||||
|
|
||||||
var main: Node
|
var main: Node
|
||||||
var max_dt := 0.0
|
var max_dt := 0.0
|
||||||
@@ -14,19 +14,19 @@ func _ready() -> void:
|
|||||||
add_child(main)
|
add_child(main)
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
var scr: BattleScreen = BattleScreen.new(5)
|
var scr: BattleScreen = BattleScreen.new()
|
||||||
main.show_screen(scr)
|
main.show_screen(scr)
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
var h: Game.Player = scr.game.human
|
var h: Game.Player = scr.game.human
|
||||||
while h.hand.size() < 8 and not h.deck.is_empty():
|
h.energy = 10
|
||||||
|
h.hand.pop_back()
|
||||||
|
if scr.game.current() == h:
|
||||||
scr.game.draw(h)
|
scr.game.draw(h)
|
||||||
var t0 := Time.get_ticks_usec()
|
var t0 := Time.get_ticks_usec()
|
||||||
for i in 30:
|
for i in 30:
|
||||||
scr.refresh()
|
scr.refresh()
|
||||||
print("refresh() mit 5 Gegnern und 8 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
|
print("refresh() mit 5 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
|
||||||
scr.mulligan.visible = false
|
|
||||||
h.mulligan_done = true
|
|
||||||
scr.ai_delay = 0.2
|
scr.ai_delay = 0.2
|
||||||
measuring = true
|
measuring = true
|
||||||
var start := Time.get_ticks_msec()
|
var start := Time.get_ticks_msec()
|
||||||
|
|||||||
@@ -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,101 +0,0 @@
|
|||||||
extends Node
|
|
||||||
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge; mit --host eröffnet er
|
|
||||||
## selbst und startet, sobald ein zweiter Mensch am Tisch sitzt (ohne KI).
|
|
||||||
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
|
|
||||||
## godot --path . tests/net_bot.tscn -- --host --name=BotHost
|
|
||||||
|
|
||||||
var net: Net
|
|
||||||
var seat := -1
|
|
||||||
var game: Game
|
|
||||||
var t := 0.0
|
|
||||||
var hosting := false
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
var ip := "127.0.0.1"
|
|
||||||
var bot_name := "Bot"
|
|
||||||
for a in OS.get_cmdline_user_args():
|
|
||||||
if a.begins_with("--ip="):
|
|
||||||
ip = a.substr(5)
|
|
||||||
if a.begins_with("--name="):
|
|
||||||
bot_name = a.substr(7)
|
|
||||||
if a == "--host":
|
|
||||||
hosting = true
|
|
||||||
net = Net.new()
|
|
||||||
net.name = "Net"
|
|
||||||
get_tree().root.add_child.call_deferred(net)
|
|
||||||
net.my_name = bot_name
|
|
||||||
net.my_deck = Profile.starter().deck.duplicate()
|
|
||||||
net.started.connect(func(s):
|
|
||||||
seat = s
|
|
||||||
if hosting:
|
|
||||||
net.broadcast() # erster Zustand für alle, wie es sonst der Kampfbildschirm tut
|
|
||||||
_act_host.call_deferred())
|
|
||||||
net.snapshot_received.connect(_on_snapshot)
|
|
||||||
net.ended.connect(func(_r): get_tree().quit())
|
|
||||||
await get_tree().process_frame
|
|
||||||
if hosting:
|
|
||||||
net.host()
|
|
||||||
net.lobby_changed.connect(func():
|
|
||||||
if net.game == null and net.names.size() >= 2:
|
|
||||||
net.start_battle(0))
|
|
||||||
net.action_applied.connect(func():
|
|
||||||
net.broadcast()
|
|
||||||
_act_host.call_deferred())
|
|
||||||
else:
|
|
||||||
net.join(ip)
|
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
|
||||||
t += delta
|
|
||||||
if hosting and net.game != null:
|
|
||||||
net.tick()
|
|
||||||
if t > 180.0 or FileAccess.file_exists("user://bot_quit"):
|
|
||||||
net.leave()
|
|
||||||
get_tree().quit()
|
|
||||||
|
|
||||||
|
|
||||||
func _on_snapshot(snap: Dictionary) -> void:
|
|
||||||
game = Game.from_snapshot(snap)
|
|
||||||
if game.winner != "":
|
|
||||||
await get_tree().create_timer(1.0).timeout
|
|
||||||
get_tree().quit()
|
|
||||||
return
|
|
||||||
if game.current_idx != seat:
|
|
||||||
return
|
|
||||||
_decide(game.players[seat])
|
|
||||||
|
|
||||||
|
|
||||||
func _act_host() -> void:
|
|
||||||
if not hosting or net.game == null or net.game.winner != "" or seat < 0 or net.game.current_idx != seat:
|
|
||||||
return
|
|
||||||
game = net.game
|
|
||||||
_decide(game.players[seat])
|
|
||||||
|
|
||||||
|
|
||||||
## Einfache Züge mit kurzer Bedenkzeit: Mulligan überspringen, erstes spielbares Wesen ohne Ziel,
|
|
||||||
## dann angreifen, sonst Zug beenden.
|
|
||||||
func _decide(me: Game.Player) -> void:
|
|
||||||
var g := game
|
|
||||||
await get_tree().create_timer(0.25).timeout
|
|
||||||
if game != g or (hosting and (net.game == null or net.game.current_idx != seat)):
|
|
||||||
return # inzwischen ein neuer Zustand
|
|
||||||
if not me.mulligan_done and me.turns == 1:
|
|
||||||
net.send_action({"type": "mulligan", "hand": []})
|
|
||||||
return
|
|
||||||
for i in me.hand.size():
|
|
||||||
var card: Cards.Card = me.hand[i]
|
|
||||||
if game.can_play(me, card)[0] and card.target_type() == "":
|
|
||||||
net.send_action({"type": "play", "hand": i, "target": null})
|
|
||||||
return
|
|
||||||
for cr in me.board:
|
|
||||||
if cr.can_attack():
|
|
||||||
for e in game.enemies_of(me):
|
|
||||||
if game.can_attack(cr, e)[0]:
|
|
||||||
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "player", "index": game.players.find(e)}})
|
|
||||||
return
|
|
||||||
for c in e.board:
|
|
||||||
if game.can_attack(cr, c)[0]:
|
|
||||||
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}})
|
|
||||||
return
|
|
||||||
net.send_action({"type": "end"})
|
|
||||||
@@ -1 +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")
|
|
||||||
+7
-15
@@ -14,37 +14,29 @@ func _initialize() -> void:
|
|||||||
print("Kartenpool laden: %.1f ms" % t)
|
print("Kartenpool laden: %.1f ms" % t)
|
||||||
var r := RandomNumberGenerator.new()
|
var r := RandomNumberGenerator.new()
|
||||||
r.seed = 3
|
r.seed = 3
|
||||||
var starter := Profile.starter().deck_cards()
|
print("Stapel würfeln: %.2f ms" % ms(func(): DeckGen.random_deck(r), 20))
|
||||||
print("Gegnerdeck (Zielstärke): %.1f ms" % ms(func(): DeckGen.build_opponent_deck_rated(r, ["Feuer", "Erde"], 1.5), 10))
|
print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20))
|
||||||
print("Vorschau 1..5 Gegner: %.1f ms" % ms(func():
|
for n in [1]:
|
||||||
for n in range(1, 6):
|
var g := Game.new_battle(r)
|
||||||
for i in n:
|
|
||||||
Game.random_opponent(r, i + 1, Game.START_LIFE, 1.3, "")))
|
|
||||||
for n in [1, 3, 5]:
|
|
||||||
var g := Game.new_battle(starter, n, r, {"target_rating": 1.5})
|
|
||||||
# ein volles Brett für alle, damit die Entscheidung teuer wird
|
# ein volles Brett für alle, damit die Entscheidung teuer wird
|
||||||
var uid := 5000
|
var uid := 5000
|
||||||
for p in g.players:
|
for p in g.players:
|
||||||
p.mulligan_done = true
|
|
||||||
for k in 4:
|
for k in 4:
|
||||||
var c := Game.Creature.new(Cards.by_id("f_wolf"), p, uid)
|
var c := Game.Creature.new(Cards.by_id("f_3_2"), p, uid)
|
||||||
uid += 1
|
uid += 1
|
||||||
c.ready = true
|
c.ready = true
|
||||||
c.attacks_left = 1
|
|
||||||
p.board.append(c)
|
p.board.append(c)
|
||||||
var h: Game.Player = g.players[0]
|
var h: Game.Player = g.players[0]
|
||||||
h.energy = 10
|
h.energy = 10
|
||||||
h.max_energy = 10
|
h.max_energy = 10
|
||||||
for k in 6:
|
for k in 6:
|
||||||
h.hand.append(Cards.by_id("f_funke"))
|
h.hand.append(Cards.by_id("f_4_3"))
|
||||||
AI.time_budget_ms = 100000
|
AI.time_budget_ms = 100000
|
||||||
var t_full := ms(func(): AI.choose_action(g, h))
|
var t_full := ms(func(): AI.choose_action(g, h))
|
||||||
AI.time_budget_ms = 150
|
AI.time_budget_ms = 150
|
||||||
var t_budget := ms(func(): AI.choose_action(g, h))
|
var t_budget := ms(func(): AI.choose_action(g, h))
|
||||||
var cands := AI.candidate_actions(g, h).size()
|
var cands := AI.candidate_actions(g, h).size()
|
||||||
print("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget])
|
print("KI-Entscheidung, %d Gegner, %d Kandidaten: ohne Budget %.0f ms, mit Budget %.0f ms" % [n, cands, t_full, t_budget])
|
||||||
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(starter, 5, r).clone(), 20))
|
print("Spielkopie: %.3f ms" % ms(func(): Game.new_battle(r).clone(), 20))
|
||||||
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
|
print("Musik erzeugen (läuft im Hintergrund-Thread): %.0f ms" % ms(func(): Sfx.new()._music()))
|
||||||
print("Deckstärke berechnen: %.3f ms" % ms(func(): Game.deck_rating(starter), 100))
|
|
||||||
print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(starter, 5, r).snapshot(0), 10))
|
|
||||||
quit()
|
quit()
|
||||||
+25
-18
@@ -1,6 +1,6 @@
|
|||||||
extends SceneTree
|
extends SceneTree
|
||||||
## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln
|
## Balance-Simulation, die KI spielt auch den Menschen. Bei gleichen Regeln
|
||||||
## ist etwa 1/(Gegner+1) Siege zu erwarten:
|
## sind etwa 50 % Siege zu erwarten:
|
||||||
## godot --headless --path . -s tests/simulate.gd
|
## godot --headless --path . -s tests/simulate.gd
|
||||||
|
|
||||||
|
|
||||||
@@ -8,31 +8,38 @@ func _initialize() -> void:
|
|||||||
var games := 100
|
var games := 100
|
||||||
var r := RandomNumberGenerator.new()
|
var r := RandomNumberGenerator.new()
|
||||||
r.seed = 0
|
r.seed = 0
|
||||||
var starter := Profile.starter().deck_cards()
|
|
||||||
var random_deck := func(rr: RandomNumberGenerator) -> Array:
|
|
||||||
var pool: Array = Cards.ELEMENTS.duplicate()
|
|
||||||
Game.shuffle(pool, rr)
|
|
||||||
return Game.build_opponent_deck(rr, [pool[0], pool[1]])
|
|
||||||
var t0 := Time.get_ticks_msec()
|
var t0 := Time.get_ticks_msec()
|
||||||
print("Alle gierig (Erwartung etwa 1/(Gegner+1)):")
|
print("Beide gierig (Erwartung etwa 50 %):")
|
||||||
for n in range(1, Game.MAX_OPPONENTS + 1):
|
_run(games, r, true, true)
|
||||||
_run("Zufallsdeck", random_deck, n, games, r, true, true)
|
|
||||||
print("Du mit Vorausschau, Gegner gierig:")
|
print("Du mit Vorausschau, Gegner gierig:")
|
||||||
for n in [1, 2, 3]:
|
_run(games / 2, r, false, true)
|
||||||
_run("Zufallsdeck", random_deck, n, games / 2, r, false, true)
|
print("Beide mit Vorausschau:")
|
||||||
print("Alle mit Vorausschau:")
|
_run(games / 2, r, false, false)
|
||||||
_run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false)
|
print("Kampagne (du mit Vorausschau), je Stufe:")
|
||||||
_run("Zufallsdeck", random_deck, 2, games / 4, r, false, false)
|
for stage in [0, 4, 9, 19, 29, 39, 49]:
|
||||||
|
_campaign(stage, games / 4, r)
|
||||||
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
|
print("Dauer %.1f s" % ((Time.get_ticks_msec() - t0) / 1000.0))
|
||||||
quit()
|
quit()
|
||||||
|
|
||||||
|
|
||||||
func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNumberGenerator,
|
func _campaign(stage: int, games: int, r: RandomNumberGenerator) -> void:
|
||||||
human_greedy: bool, opp_greedy: bool) -> void:
|
var wins := 0
|
||||||
|
for i in games:
|
||||||
|
var g := Game.new_battle_with(Campaign.opponent(stage), r, false, {"deck": DeckGen.element_deck(Cards.NEUTRAL)}) # du mit der Startsammlung
|
||||||
|
while g.winner == "":
|
||||||
|
AI.take_turn(g, g.current(), false)
|
||||||
|
assert(g.turn < 300, "Spiel endet nicht")
|
||||||
|
if g.winner == "human":
|
||||||
|
wins += 1
|
||||||
|
var st := Campaign.stage(stage)
|
||||||
|
print("Stufe %2d %-12s %2d Leben, %d Merkmale: Siege %3d%%" % [stage + 1, st.name, int(st.life), st.perks.size(), wins * 100 / games])
|
||||||
|
|
||||||
|
|
||||||
|
func _run(games: int, r: RandomNumberGenerator, human_greedy: bool, opp_greedy: bool) -> void:
|
||||||
var wins := 0
|
var wins := 0
|
||||||
var turns := 0
|
var turns := 0
|
||||||
for i in games:
|
for i in games:
|
||||||
var g := Game.new_battle(deck_fn.call(r), n_opp, r)
|
var g := Game.new_battle(r)
|
||||||
while g.winner == "":
|
while g.winner == "":
|
||||||
var p := g.current()
|
var p := g.current()
|
||||||
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy)
|
AI.take_turn(g, p, human_greedy if p.is_human else opp_greedy)
|
||||||
@@ -40,4 +47,4 @@ func _run(label: String, deck_fn: Callable, n_opp: int, games: int, r: RandomNum
|
|||||||
if g.winner == "human":
|
if g.winner == "human":
|
||||||
wins += 1
|
wins += 1
|
||||||
turns += g.turn
|
turns += g.turn
|
||||||
print("%-26s vs %d: Siege %3d%% Ø Züge %.1f" % [label, n_opp, wins * 100 / games, float(turns) / games])
|
print("Siege %3d%% Ø Züge %.1f" % [wins * 100 / games, float(turns) / games])
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
extends SceneTree
|
|
||||||
## Archetyp-Stärke: jede Elementkombination gegen zufällige andere, gleiche Zielstärke, beide KI.
|
|
||||||
|
|
||||||
|
|
||||||
func archetypes() -> Array:
|
|
||||||
var out := []
|
|
||||||
for e in Cards.ELEMENTS:
|
|
||||||
out.append([e])
|
|
||||||
for i in Cards.ELEMENTS.size():
|
|
||||||
for j in range(i + 1, Cards.ELEMENTS.size()):
|
|
||||||
out.append([Cards.ELEMENTS[i], Cards.ELEMENTS[j]])
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
|
||||||
var r := RandomNumberGenerator.new()
|
|
||||||
r.seed = 21
|
|
||||||
var arts := archetypes()
|
|
||||||
var n := int(OS.get_environment("N")) if OS.get_environment("N") != "" else 60
|
|
||||||
var results := {}
|
|
||||||
for a in arts:
|
|
||||||
var wins := 0
|
|
||||||
for i in n:
|
|
||||||
var b: Array = arts[r.randi_range(0, arts.size() - 1)]
|
|
||||||
var deck_a := Game.build_opponent_deck_rated(r, a, 1.6)
|
|
||||||
var deck_b := Game.build_opponent_deck_rated(r, b, 1.6)
|
|
||||||
var opp := Game.Player.new("Gegner 1", deck_b, false, Game.START_LIFE, "x")
|
|
||||||
var g := Game.new_battle_with(deck_a, [opp], r)
|
|
||||||
var guard := 0
|
|
||||||
while g.winner == "" and guard < 300:
|
|
||||||
AI.take_turn(g, g.current())
|
|
||||||
guard += 1
|
|
||||||
if g.winner == "human":
|
|
||||||
wins += 1
|
|
||||||
results["+".join(a)] = wins * 100 / n
|
|
||||||
print("%-14s %3d%%" % ["+".join(a), wins * 100 / n])
|
|
||||||
var f := FileAccess.open(DeckGen.ARCHETYPE_PATH, FileAccess.WRITE)
|
|
||||||
f.store_string(JSON.stringify(results, "\t"))
|
|
||||||
f.close()
|
|
||||||
print("nach %s geschrieben" % DeckGen.ARCHETYPE_PATH)
|
|
||||||
quit()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://d4m21t7od3kdm
|
|
||||||
+753
-1058
File diff suppressed because it is too large.
Load diff
+605
-575
File diff suppressed because it is too large.
Load diff
+167
-117
@@ -1,156 +1,194 @@
|
|||||||
keys,en
|
keys,en
|
||||||
Dinger,Dinger
|
Dinger,Dinger
|
||||||
Ein Kartenspiel: jeder gegen jeden,A card game: free-for-all
|
Ein Kartenspiel: Duell um das Feld,A card game: duel for the field
|
||||||
Einzelspieler,Single player
|
Einzelspieler,Single player
|
||||||
Kampagne,Campaign
|
Kampagne,Campaign
|
||||||
Netzwerkspiel,Network game
|
Deck,Deck
|
||||||
Booster öffnen,Open boosters
|
Booster öffnen,Open boosters
|
||||||
Deck bauen,Build deck
|
|
||||||
Einstellungen,Settings
|
Einstellungen,Settings
|
||||||
Beenden,Quit
|
Beenden,Quit
|
||||||
Zurück,Back
|
Zurück,Back
|
||||||
Start,Start
|
Start,Start
|
||||||
Speichern,Save
|
|
||||||
Leeren,Clear
|
|
||||||
Suche,Search
|
Suche,Search
|
||||||
Fehlende,Missing
|
|
||||||
Alle,All
|
Alle,All
|
||||||
Aktiv setzen,Set active
|
|
||||||
Auto-Ausfüllen,Auto-fill
|
|
||||||
Umbenennen,Rename
|
|
||||||
Zug beenden,End turn
|
Zug beenden,End turn
|
||||||
Aufgeben,Surrender
|
Aufgeben,Surrender
|
||||||
Weiterspielen,Keep playing
|
Weiterspielen,Keep playing
|
||||||
"Ja, aufgeben","Yes, surrender"
|
"Ja, aufgeben","Yes, surrender"
|
||||||
Wirklich aufgeben?,Really surrender?
|
Wirklich aufgeben?,Really surrender?
|
||||||
Der Kampf endet ohne Münzen.,The battle ends without coins.
|
|
||||||
Zurück zum Menü,Back to menu
|
Zurück zum Menü,Back to menu
|
||||||
Lupe,Zoom
|
Lupe,Zoom
|
||||||
Schließen,Close
|
Schließen,Close
|
||||||
Held als Ziel,Target hero
|
Held als Ziel,Target hero
|
||||||
Verlauf,Log
|
Verlauf,Log
|
||||||
Karte überfahren für Details,Hover a card for details
|
|
||||||
Lautstärke,Volume
|
Lautstärke,Volume
|
||||||
Musik,Music
|
Musik,Music
|
||||||
KI-Tempo,AI speed
|
KI-Tempo,AI speed
|
||||||
Animationen,Animations
|
Animationen,Animations
|
||||||
Sprache,Language
|
Sprache,Language
|
||||||
Booster,Boosters
|
Booster,Boosters
|
||||||
Booster kaufen (100 Münzen),Buy booster (100 coins)
|
%d neu · %d aufgewertet · +%d Staub,%d new · %d upgraded · +%d dust
|
||||||
Alle aufdecken,Reveal all
|
Alle aufdecken,Reveal all
|
||||||
Zerlegen,Dismantle
|
Zerlegen,Dismantle
|
||||||
Herstellen,Craft
|
|
||||||
Kämpfen,Fight
|
Kämpfen,Fight
|
||||||
Wiederholen,Replay
|
Wiederholen,Replay
|
||||||
Gesperrt,Locked
|
Gesperrt,Locked
|
||||||
Spiel eröffnen,Host game
|
Spiel eröffnen,Host game
|
||||||
Beitreten,Join
|
Beitreten,Join
|
||||||
Behalten,Keep
|
|
||||||
Tauschen,Swap
|
|
||||||
Hand tauschen?,Swap cards?
|
|
||||||
Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game.
|
|
||||||
Fallen,Traps
|
|
||||||
Du bist dran,Your turn
|
Du bist dran,Your turn
|
||||||
Antreten,Enter
|
|
||||||
Gegnerzüge überspringen,Skip AI turns
|
|
||||||
Rückgängig,Undo
|
Rückgängig,Undo
|
||||||
Kapitel 1,Chapter 1
|
Kapitel 1,Chapter 1
|
||||||
Kapitel 2,Chapter 2
|
Kapitel 2,Chapter 2
|
||||||
|
Kapitel 3,Chapter 3
|
||||||
|
Kapitel 4,Chapter 4
|
||||||
|
Kapitel 5,Chapter 5
|
||||||
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
|
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
|
||||||
"Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d","Collection: %d of %d cards (%d total) · Campaign: %d/%d"
|
|
||||||
Aktives Deck: %s (%s),Active deck: %s (%s)
|
|
||||||
leer,empty
|
|
||||||
Tagesaufgabe erledigt: %s ✓,Daily task done: %s ✓
|
|
||||||
Tagesaufgabe: %s (%d/%d) · +%d Münzen,Daily task: %s (%d/%d) · +%d coins
|
|
||||||
"Aktives Deck nicht spielbar: ","Active deck not playable: "
|
|
||||||
Zug %d · Du bist dran,Turn %d · Your turn
|
|
||||||
Zug %d · %s ist dran …,Turn %d · %s is playing …
|
|
||||||
Wähle ein Ziel,Choose a target
|
|
||||||
Wähle ein Angriffsziel,Choose an attack target
|
Wähle ein Angriffsziel,Choose an attack target
|
||||||
Abgebrochen,Cancelled
|
Abgebrochen,Cancelled
|
||||||
%s kann nicht angreifen,%s cannot attack
|
%s kann nicht angreifen,%s cannot attack
|
||||||
Nicht dein Zug,Not your turn
|
Nicht dein Zug,Not your turn
|
||||||
Nicht genug Energie,Not enough energy
|
Nicht auf der Hand,Not in your hand
|
||||||
Dein Feld ist voll,Your board is full
|
Dein Feld ist voll,Your board is full
|
||||||
Kein gültiges Ziel,No valid target
|
Kein gültiges Ziel,No valid target
|
||||||
Ungültiges Ziel,Invalid target
|
Ungültiges Ziel,Invalid target
|
||||||
Höchstens 2 Fallen,At most 2 traps
|
|
||||||
Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first
|
Ein Wächter muss zuerst angegriffen werden,A guardian must be attacked first
|
||||||
Sieg!,Victory!
|
Sieg!,Victory!
|
||||||
Niederlage,Defeat
|
Niederlage,Defeat
|
||||||
+%d Münzen,+%d coins
|
+%d Münzen,+%d coins
|
||||||
"Deck braucht genau %d Karten (%d)","Deck needs exactly %d cards (%d)"
|
|
||||||
Höchstens %dx dieselbe Karte,At most %dx the same card
|
|
||||||
Karte nicht oft genug in der Sammlung,Not enough copies in your collection
|
|
||||||
Höchstens %d Elemente im Deck,At most %d elements in a deck
|
|
||||||
Schwierigkeit,Difficulty
|
Schwierigkeit,Difficulty
|
||||||
Leicht,Easy
|
Leicht,Easy
|
||||||
Normal,Normal
|
Normal,Normal
|
||||||
Schwer,Hard
|
Schwer,Hard
|
||||||
Stärke %.1f,Strength %.1f
|
|
||||||
"Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten","Deck strength: average of rarity, keywords and effects"
|
|
||||||
Neu würfeln,Reroll
|
|
||||||
Deck verbessern: %s ins aktive Deck (%s),Improve deck: add %s to the active deck (%s)
|
|
||||||
Profil zurücksetzen,Reset profile
|
Profil zurücksetzen,Reset profile
|
||||||
Große Schrift,Large text
|
Große Schrift,Large text
|
||||||
Verwerfen,Discard
|
|
||||||
Weiter bearbeiten,Keep editing
|
|
||||||
Ungespeicherte Änderungen,Unsaved changes
|
|
||||||
Duell,Duel
|
|
||||||
%d am Tisch,%d at the table
|
|
||||||
%d Spieler am Tisch,%d players at the table
|
|
||||||
%d Münzen,%d coins
|
%d Münzen,%d coins
|
||||||
Abbrechen,Cancel
|
Abbrechen,Cancel
|
||||||
"Ja, zurücksetzen","Yes, reset"
|
"Ja, zurücksetzen","Yes, reset"
|
||||||
"Speichern und zurück","Save and go back"
|
|
||||||
"Kampf fortsetzen","Resume battle"
|
|
||||||
Aufgeben beendet den Kampf ohne Münzen.,Surrendering ends the battle without coins.
|
|
||||||
Speichern hebt ihn für später auf.,Saving keeps it for later.
|
|
||||||
%s Schaden an den Angreifer,%s damage to the attacker
|
|
||||||
%s Schaden an dieses Wesen,%s damage to that creature
|
|
||||||
Der Angreifer wird eingefroren,The attacker is frozen
|
|
||||||
Es wird eingefroren,It is frozen
|
|
||||||
Es geht zurück auf die Hand,It returns to its owner's hand
|
|
||||||
Der Angriff verpufft,The attack fizzles
|
|
||||||
Beschwöre einen %s,Summon a %s
|
|
||||||
%s Schaden an ein gegnerisches Wesen,%s damage to an enemy creature
|
|
||||||
%s Schaden an ein Ziel,%s damage to a target
|
|
||||||
%s Schaden an alle gegnerischen Wesen,%s damage to all enemy creatures
|
|
||||||
%s Schaden an alle Gegner,%s damage to all opponents
|
|
||||||
%s Schaden an alle Gegner und ihre Wesen,%s damage to all opponents and their creatures
|
|
||||||
Heile dich um %s,Heal yourself for %s
|
|
||||||
Ziehe 1 Karte,Draw 1 card
|
|
||||||
Ziehe %s Karten,Draw %s cards
|
|
||||||
Friere ein gegnerisches Wesen ein,Freeze an enemy creature
|
|
||||||
Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature
|
|
||||||
Friere alle gegnerischen Wesen ein,Freeze all enemy creatures
|
|
||||||
Ein gegnerisches Wesen zurück auf die Hand,Return an enemy creature to its owner's hand
|
|
||||||
Alle gegnerischen Wesen zurück auf die Hand,Return all enemy creatures to their owners' hands
|
|
||||||
Ein befreundetes Wesen erhält %s,A friendly creature gets %s
|
|
||||||
Alle befreundeten Wesen erhalten %s,All friendly creatures get %s
|
|
||||||
Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield
|
|
||||||
Erhält Schild,Gets Shield
|
|
||||||
Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield
|
|
||||||
Falle. %s: %s,Trap. %s: %s
|
|
||||||
Beim Ausspielen: %s,On play: %s
|
|
||||||
Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift,When an enemy creature attacks you or one of your creatures
|
|
||||||
Wenn ein Gegner ein Wesen ausspielt,When an opponent plays a creature
|
|
||||||
Wächter,Guardian
|
|
||||||
Flink,Swift
|
Flink,Swift
|
||||||
Schild,Shield
|
|
||||||
Doppelschlag,Double Strike
|
Doppelschlag,Double Strike
|
||||||
Wesen,Creature
|
Common,Common
|
||||||
Zauber,Spell
|
Rare,Rare
|
||||||
Falle,Trap
|
Super Rare,Super Rare
|
||||||
gewöhnlich,common
|
Ultra Rare,Ultra Rare
|
||||||
selten,rare
|
Ultimate Rare,Ultimate Rare
|
||||||
episch,epic
|
|
||||||
legendär,legendary
|
|
||||||
mythisch,mythic
|
|
||||||
Feuer,Fire
|
Feuer,Fire
|
||||||
Wasser,Water
|
Wasser,Water
|
||||||
Erde,Earth
|
Erde,Earth
|
||||||
Luft,Air
|
Luft,Air
|
||||||
|
%s zerlegt (+%d Staub),%s dismantled (+%d dust)
|
||||||
|
Kapitel %d,Chapter %d
|
||||||
|
Zuschauen,Watch
|
||||||
|
Zuschauer,Spectator
|
||||||
|
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden"
|
||||||
|
Starten,Start
|
||||||
|
Sammlung,Collection
|
||||||
|
Verschmelzen,Fuse
|
||||||
|
Nur eigene Wesen,Only your own creatures
|
||||||
|
Nur eigene Karten,Only your own cards
|
||||||
|
Sammlung: %d von %d Karten · Kampagne: %d/%d · Level %d,Collection: %d of %d cards · Campaign: %d/%d · Level %d
|
||||||
|
Skilltree,Skill tree
|
||||||
|
Level %d,Level %d
|
||||||
|
%d / %d EP bis Level %d,%d / %d XP to level %d
|
||||||
|
Skillpunkte: %d,Skill points: %d
|
||||||
|
Lernen,Learn
|
||||||
|
Stufe %d / %d,Rank %d / %d
|
||||||
|
Sammeln,Collecting
|
||||||
|
Wirtschaft,Economy
|
||||||
|
Kampf,Battle
|
||||||
|
Packglück,Pack luck
|
||||||
|
Staubsammler,Dust collector
|
||||||
|
Münzsegen,Coin blessing
|
||||||
|
Händler,Merchant
|
||||||
|
Freies Ziehen,Free draw
|
||||||
|
Starker Start,Strong start
|
||||||
|
Seltenere Karten in Boostern,Rarer cards in boosters
|
||||||
|
Mehr Staub aus Doppelten und Zerlegen,More dust from duplicates and dismantling
|
||||||
|
Mehr Münzen nach jedem Kampf,More coins after every battle
|
||||||
|
Booster werden billiger,Boosters get cheaper
|
||||||
|
Das zweite Ziehen je Zug ist auch gratis,The second draw each turn is free as well
|
||||||
|
Mehr Mana im ersten Zug,More mana in the first turn
|
||||||
|
Chancen %d/%d/%d/%d/%d %%,Odds %d/%d/%d/%d/%d %%
|
||||||
|
+%d %% Staub,+%d %% dust
|
||||||
|
+%d %% Münzen,+%d %% coins
|
||||||
|
"Neutral %d, Element %d Münzen","Neutral %d, element %d coins"
|
||||||
|
zwei Karten je Zug gratis,two cards per turn for free
|
||||||
|
eine Karte je Zug gratis,one card per turn for free
|
||||||
|
erster Zug: %d Mana,first turn: %d mana
|
||||||
|
Jetzt: %s,Now: %s
|
||||||
|
Nächste Stufe: %s,Next rank: %s
|
||||||
|
Höchste Stufe erreicht,Highest rank reached
|
||||||
|
Cheats,Cheats
|
||||||
|
Währung,Currency
|
||||||
|
Erfahrung,Experience
|
||||||
|
+1.000 Münzen,"+1,000 coins"
|
||||||
|
+10.000 Münzen,"+10,000 coins"
|
||||||
|
+1.000 Staub,"+1,000 dust"
|
||||||
|
+10.000 Staub,"+10,000 dust"
|
||||||
|
+1 Level,+1 level
|
||||||
|
+5 Level,+5 levels
|
||||||
|
Skills zurücksetzen,Reset skills
|
||||||
|
Erfahrung auf 0,Experience to 0
|
||||||
|
Kapitel 2 frei,Unlock chapter 2
|
||||||
|
Kapitel 3 frei,Unlock chapter 3
|
||||||
|
Kapitel 4 frei,Unlock chapter 4
|
||||||
|
Kapitel 5 frei,Unlock chapter 5
|
||||||
|
Kampagne zurücksetzen,Reset campaign
|
||||||
|
Alle Karten (Common),All cards (Common)
|
||||||
|
Alle Karten Ultimate Rare,All cards Ultimate Rare
|
||||||
|
Eigene Karten +1 Stufe,Own cards +1 rank
|
||||||
|
Sammlung zurücksetzen,Reset collection
|
||||||
|
Gespeichert,Saved
|
||||||
|
"Münzen %d · Staub %d · EP %d (Level %d, %d Skillpunkte frei) · Kampagne %d/%d · Sammlung %d Karten, Stapel %d","Coins %d · Dust %d · XP %d (level %d, %d skill points free) · Campaign %d/%d · Collection %d cards, deck %d"
|
||||||
|
Schon auf der höchsten Stufe,Already at the highest rank
|
||||||
|
Kein Skillpunkt frei,No skill point available
|
||||||
|
Erfahrung sind deine verdienten Münzen. Jedes Level bringt einen Skillpunkt.,Experience is the coins you earn. Every level grants one skill point.
|
||||||
|
%s ist jetzt %s,%s is now %s
|
||||||
|
Alle Kapitel abgeschlossen,All chapters completed
|
||||||
|
Elemente,Elements
|
||||||
|
Aufwerten,Upgrade
|
||||||
|
Aufwerten zu %s (%d Staub),Upgrade to %s (%d dust)
|
||||||
|
Höchste Stufe,Top tier
|
||||||
|
Keine Karte gewählt,No card selected
|
||||||
|
Zerlegen (+%d Staub),Dismantle (+%d dust)
|
||||||
|
Nicht genug Staub (%d nötig),Not enough dust (%d needed)
|
||||||
|
Schon auf der höchsten Stufe,Already at the top tier
|
||||||
|
Common-Karten lassen sich nicht zerlegen,Common cards cannot be dismantled
|
||||||
|
Aufgewertet,Upgraded
|
||||||
|
+%d Staub,+%d dust
|
||||||
|
Dein Zug,Your turn
|
||||||
|
Nicht genug Mana,Not enough mana
|
||||||
|
Profil wirklich zurücksetzen?,Really reset your profile?
|
||||||
|
"Sammlung, Münzen und Fortschritt gehen verloren.","Collection, coins and progress are lost."
|
||||||
|
%d Leben,%d life
|
||||||
|
Gegnerkarten: %s,Opponent cards: %s
|
||||||
|
Vorausschau-KI,lookahead AI
|
||||||
|
einfache KI,simple AI
|
||||||
|
ein Merkmal,one perk
|
||||||
|
Münzen %s,coins %s
|
||||||
|
",","."
|
||||||
|
%d Züge,%d turns
|
||||||
|
%d Karten,%d cards
|
||||||
|
%d schnelle Züge,%d fast turns
|
||||||
|
Sieg ×2,Victory ×2
|
||||||
|
Kampagne +%d,Campaign +%d
|
||||||
|
Start gegen die KI,Start against the AI
|
||||||
|
Ziehen,Draw
|
||||||
|
Verdeckt legen ◆ %d,Set face-down ◆ %d
|
||||||
|
Aufdecken,Reveal
|
||||||
|
verdeckt,face-down
|
||||||
|
Aufgedeckt!,Revealed!
|
||||||
|
Merkmal +%d,Perk +%d
|
||||||
|
Erst die Wesen,Creatures first
|
||||||
|
%s liegt verdeckt,%s is face-down
|
||||||
|
%s liegt schon offen,%s is already face-up
|
||||||
|
Unbekannte Eigenschaft,Unknown trait
|
||||||
|
%s hat schon %s,%s already has %s
|
||||||
|
Wesen kommen mit +1 Angriff,Creatures enter with +1 attack
|
||||||
|
Hat im ersten Zug 2 Mana mehr,Has 2 extra mana on the first turn
|
||||||
|
Wesen haben keinen Elementnachteil,Creatures have no element weakness
|
||||||
|
Beginnt mit 5 Leben mehr,Starts with 5 extra life
|
||||||
|
Greift zweimal je Zug,Attacks twice per turn
|
||||||
|
Verdeckt,Face-down
|
||||||
Inferno,Inferno
|
Inferno,Inferno
|
||||||
Tiefsee,Deep Sea
|
Tiefsee,Deep Sea
|
||||||
Berg,Mountain
|
Berg,Mountain
|
||||||
@@ -161,33 +199,45 @@ Blitz,Lightning
|
|||||||
Schlamm,Mud
|
Schlamm,Mud
|
||||||
Sturm,Storm
|
Sturm,Storm
|
||||||
Sand,Sand
|
Sand,Sand
|
||||||
Sonne,Sun
|
Feuerwesen haben +1 Angriff,Fire creatures have +1 attack
|
||||||
Ozean,Ocean
|
Wesen kommen mit +1 Leben,Creatures enter with +1 health
|
||||||
Gebirge,Mountains
|
Bekommt jeden Zug 1 Mana mehr,Gets 1 extra mana each turn
|
||||||
Himmel,Sky
|
Ziehen kostet kein Mana,Drawing costs no mana
|
||||||
"Code übernommen, es fehlen: ","Code applied, missing: "
|
Spott,Taunt
|
||||||
Elemente: ,Elements:
|
Regeneration,Regeneration
|
||||||
%s zerlegt (+%d Staub),%s dismantled (+%d dust)
|
Muss zuerst angegriffen werden,Must be attacked first
|
||||||
|
Greift sofort an,Attacks right away
|
||||||
|
Heilt sich am Zugende ganz,Heals fully at the end of the turn
|
||||||
|
Wesen greifen sofort an,Creatures attack right away
|
||||||
|
Verschmelzen mit …,Fuse with …
|
||||||
|
Ziel wählen,Pick the partner
|
||||||
|
Erst die Wesen mit Spott,Taunting creatures first
|
||||||
|
%s kann erst im nächsten Zug aufgedeckt werden,%s can be revealed next turn
|
||||||
|
%s greift erst im nächsten Zug an,%s attacks next turn
|
||||||
|
greift erst im nächsten Zug an,attacks next turn
|
||||||
|
%s durch das Element,%s from its element
|
||||||
|
Zwei verschiedene Karten wählen,Pick two different cards
|
||||||
|
stirbt,dies
|
||||||
|
Abwerfen +%d ◆,Discard +%d ◆
|
||||||
|
Überspringen,Skip
|
||||||
|
Zug %d,Turn %d
|
||||||
|
Stapel: %d Karten (%d bis %d),Deck: %d cards (%d to %d)
|
||||||
|
Stärke %d,Strength %d
|
||||||
|
Herstellen (%d Staub),Craft (%d dust)
|
||||||
|
Nicht in der Sammlung,Not in the collection
|
||||||
%s hergestellt,%s crafted
|
%s hergestellt,%s crafted
|
||||||
%s ist jetzt im Deck %s,%s is now in deck %s
|
Neu!,New!
|
||||||
Deck-Code,Deck code
|
Auffüllen,Fill up
|
||||||
In Zwischenablage,To clipboard
|
Leeren,Clear
|
||||||
Aus Zwischenablage,From clipboard
|
Dein Stapel ist leer,Your deck is empty
|
||||||
Code,Code
|
Klick auf eine Karte: rein oder raus.,Click a card: in or out.
|
||||||
Ungültiger Code,Invalid code
|
Manakurve,Mana curve
|
||||||
Code übernommen,Code applied
|
Optionen,Options
|
||||||
Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.,Copy to share or paste a code. Cards you do not own are skipped.
|
Hauptmenü,Main menu
|
||||||
Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos,Element booster (%d coins): only cards of that element and its combos
|
Neutral,Neutral
|
||||||
"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks.","Chapter 3: three perks, full tables and the strongest decks."
|
Nie im Vorteil oder Nachteil,Never at an advantage or disadvantage
|
||||||
Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!,All three chapters completed. The Archmage is defeated for good!
|
Veteran,Veteran
|
||||||
Kapitel %d,Chapter %d
|
Feuerritter,Fire Knight
|
||||||
Zuschauen,Watch
|
Wasserritter,Water Knight
|
||||||
Zuschauer,Spectator
|
Erdritter,Earth Knight
|
||||||
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden"
|
Luftritter,Air Knight
|
||||||
Deine Deckstärke %.1f · Gegnerdecks %.1f,Your deck strength %.1f · Opponent decks %.1f
|
|
||||||
Starten,Start
|
|
||||||
1 Gegner,1 opponent
|
|
||||||
%d Gegner,%d opponents
|
|
||||||
Übernehmen,Apply
|
|
||||||
KI-Gegner,AI opponents
|
|
||||||
Sammlung,Collection
|
|
||||||
|
Binary file not shown.
Reference in new issue
Block a user