update
Some checks failed
Tests / headless (push) Has been cancelled
Tests / fenster-schnell (push) Has been cancelled
Tests / fenster-voll (push) Has been cancelled
Tests / balance (push) Has been cancelled

This commit is contained in:
Team3
2026-09-22 13:23:02 +02:00
parent 8944fdd017
commit dfcd3cb713
46 changed files with 1525 additions and 4141 deletions

View File

@@ -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

View File

@@ -4,75 +4,58 @@ Kartenspiel in 2D (Godot 4.7): du und 1 bis 5 KI-Magier, jeder gegen jeden, alle
## 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 - 77 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos entstehen erst im Spiel (Verschmelzen, Feldkombos)
- Resonanz: gleiche Elemente auf dem Feld verstärken sich. Feldkombos: verschiedene Elemente lösen beim Ausspielen Effekte aus - Kein Deck und keine Starthand: jeder hat einen eigenen Laden mit fünf Angeboten. Kaufen kostet Mana, Ausspielen nichts, neu würfeln kostet 1 Mana
- Fallen liegen verdeckt und lösen im gegnerischen Zug aus (die KI kennt fremde Fallen nicht) - Mana wächst um 1 je Zug (bis 10) und bleibt liegen, wenn du es nicht ausgibst — sparen ist eine echte Entscheidung
- Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner (KI mit Vorausschau), Kampagne mit 10 Magiern je Kapitel, Tagesaufgabe - Der Laden zeigt nur Karten, die höchstens dein Einkommen plus eins kosten: teure Wesen tauchen erst später auf
- Booster, fünf Deck-Slots, Sammlung (Zerlegen und Herstellen aus Staub), Einstellungen (Ton, KI-Tempo, Animationen) - Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik: sie ändert weder Werte noch Text
- Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`) - Booster (100 Münzen): fünf gewürfelte Stufen, höhere werten die Karte auf, der Rest wird zu Staub. Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück
- Kampagnen-Bosse mit Merkmalen, Kartenstatistik, zwölf Tagesaufgaben, Pity-Zähler für mythische Karten - Resonanz: gleiche Elemente auf dem Feld verstärken sich (+1, +2, +3, +3)
- Netzwerkspiel im lokalen Netz (Host führt das Spiel, Mitspieler sehen nur ihre Hand) - Feldkombos: ein neues Wesen neben einem Wesen mit anderem Element löst einen Effekt aus
- Deck-Namen, Auto-Ausfüllen, Hintergrundmusik und Oberfläche wahlweise auf Englisch - Karteneffekte lösen beim Ausspielen aus (Schaden, Einfrieren, Heilen, Zurückwerfen, Verstärken, Schild, Mana); Kartenvorteil ist hier Manavorteil
- Zwei Kampagnenkapitel (Bosse mit ein bis drei Merkmalen), wählbares eigenes Merkmal nach Kapitel 1 - Elementvorteil im Kreis (Wasser > Feuer > Luft > Erde > Wasser): 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, auch für Karteneffekte gegen Wesen
- Bestenliste (Serie, schnellster Sieg) im Hintergrund: sie steuert den Schwierigkeitstipp - Gemeinsames Feld: vier gespielte Karten eines Elements kippen es (Vulkan, Flut, Fels, Wirbel), Wesen dieses Elements bekommen +1 Angriff
- Zehn Fallenkarten, Netzwerk mit Wiederverbinden (Platz bleibt 60 s frei) - Verschmelzen: zwei eigene Wesen werden eins (Elemente und Schlüsselwörter beider, Werte Durchschnitt plus 1, Kosten der Durchschnitt der Karten); drei oder mehr Elemente ergeben eine Chimäre
- Deckstärke aus Seltenheit, Schlüsselwörtern und Effekten; Gegnerdecks werden auf die eigene Stärke gebaut - Jeder gegen jeden mit ausgeloster Zugreihenfolge, 1 bis 5 KI-Gegner; die Hand fasst 8 Karten, das Feld 5 Wesen
- 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 - Schwierigkeit Leicht/Normal/Schwer: Gegner mit 25/30/35 Leben, 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 - KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread
- Deckstärke mit Kurvenabzug und Gewichtung durch die eigene Kartenstatistik; Booster schlagen passende Karten fürs Deck vor - Kampagne: drei Kapitel mit je zehn Magiern, Bosse mit ein bis drei Merkmalen, eigenes Merkmal nach Kapitel 1
- Resonanz gedeckelt (+1, +2, +3, +3), günstige Wasser-Antworten (Ebbe, Tiefenstoß, Strudel), Wasser-Wesen angehoben, Gischtläufer für Sturm - Netzwerkspiel im lokalen Netz: Host führt das Spiel, Mitspieler sehen nur ihre Hand, Zuschauer keine. Fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung, die anderen verbinden neu (Platz bleibt 60 s frei)
- 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 - 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 - Spielstand versioniert mit Sicherungskopie (`spielstand.json.bak`)
- Englisch auch für Kartennamen und Kartentexte (`name_en` in `data/karten.json`, Textbausteine über `tr()`) - Oberfläche und Kartentexte wahlweise auf Englisch (`translations/ui.csv`, `name_en` in `data/karten.json`)
- Deck-Codes zum Teilen (`DINGER1:id*anzahl,…`) mit Zwischenablage im Deckbau - Gamepad in Menü, Sammlung, Booster und Kampf; große Schrift und Animationen in den Einstellungen
- 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). `DINGER_FAST=1` lässt den Fenster-Test ohne die langen Kämpfe laufen (etwa ein Drittel der Zeit).
``` ```
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) DINGER_NETZ=1 godot --path . tests/ui_test.tscn # zusätzlich die langsamen Netzwerktests (Bot-Prozesse)
N=200 godot --headless --path . -s tests/strength_probe.gd # Messlauf mit 200 statt 60 Partien je Archetyp godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Ladenangebot, Musik
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 godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern
``` ```
@@ -88,11 +71,11 @@ 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, Kampf, Feldkombos, Resonanz, Feld, Verschmelzen
- `scripts/deckgen.gd` – Deckstärke, Gegnerdecks entlang der Energiekurve, Archetypen - `scripts/deckgen.gd` – Ladenangebot und Gegner
- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk - `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, Booster, 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/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden

View File

@@ -1,25 +1,30 @@
# 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 - Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl - Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
- Kampagne gegen benannte Magier und Tagesaufgaben als Fortschritt - Kampagne gegen benannte Magier als Fortschritt
- Im lokalen Netz zu mehreren spielbar, auch als Zuschauer - Im lokalen Netz zu mehreren spielbar, auch als Zuschauer
- Ein unterbrochener Kampf lässt sich später fortsetzen - Ein unterbrochener Kampf lässt sich später fortsetzen
- Bestenliste als Langzeitziel
## Sammeln ## Sammeln
- Booster öffnen - Jede Karte gehört jedem von Anfang an: wer zum ersten Mal spielt, kann so gut sein wie wer tausend Partien hat
- Karten haben Seltenheiten - Gesammelt wird die Seltenheitsstufe einer Karte, und die ist reine Optik (auch im Netzwerkspiel sichtbar)
- Aus der Sammlung werden Decks gebaut (mehrere Slots) und als Code geteilt - Booster öffnen: höhere Stufen werten Karten auf, der Rest wird zu Staub
- Überschüssige Karten lassen sich zerlegen und neue herstellen - Staub wertet gezielt auf, eine Stufe abgeben bringt Staub zurück
- Kein Deckbau und kein Nachziehstapel: fünf Angebote im Laden, Kaufen kostet Mana, Ausspielen nichts
- Nicht ausgegebenes Mana bleibt liegen, neu würfeln kostet 1 Mana (wie in TFT)
- Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen
## Elemente ## Elemente
- Karten gehören zu Elementen - Jede Karte gehört zu genau einem Element
- Elemente sind kombinierbar, daraus entstehen viele Varianten - Kombos entstehen erst im Spiel: beim Verschmelzen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) und als Feldkombo
- Gleiche Elemente sind auch eine Kombo (Feuer + Feuer)
- Jedes Element und jede Kombo hat eine eigene Spielweise
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus - Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus
- Verdeckte Fallen bringen Bluff und Reaktion im gegnerischen Zug - Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (1 Schaden mehr im Vorteil, 1 weniger im Nachteil; Mehrfachelemente nehmen den für sie besseren Wert)
- Das Feld gehört allen am Tisch: vier gespielte Karten eines Elements kippen es, Wesen dieses Elements bekommen +1 Angriff
- Zwei eigene Wesen lassen sich für Energie verschmelzen: das neue erbt Elemente und Fähigkeiten beider, seine Werte sind der Durchschnitt plus 1
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus

View File

@@ -1,12 +0,0 @@
{
"Erde": 50,
"Erde+Luft": 56,
"Feuer": 40,
"Feuer+Erde": 64,
"Feuer+Luft": 42,
"Feuer+Wasser": 57,
"Luft": 45,
"Wasser": 43,
"Wasser+Erde": 50,
"Wasser+Luft": 41
}

File diff suppressed because it is too large Load Diff

View File

@@ -30,13 +30,21 @@ 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, Feld, Hand und übriges Mana zählen positiv,
## die der Gegner negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden
## bremsen). Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, 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.02
var alive_enemies := 0 var alive_enemies := 0
var threat := 0.0 var threat := 0.0
var leader := 0.0 var leader := 0.0
@@ -117,6 +125,18 @@ static func _top(list: Array, rank: Callable, n: int) -> Array:
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 := {} var valid_cache := {}
var afford := false
var seen := {}
for i in p.shop.size():
var offer = p.shop[i]
if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND:
afford = true
if not seen.has(offer.id): # dasselbe Angebot zweimal zu prüfen bringt nichts
seen[offer.id] = true
out.append({"type": "buy", "slot": i, "card": offer})
# Neu würfeln lohnt nur, wenn ein Laden da ist und nichts Bezahlbares darin liegt.
if not afford and not p.shop.is_empty() and p.energy >= Game.REROLL_COST and p.hand.size() < Game.MAX_HAND:
out.append({"type": "reroll"})
for card in p.hand: for card in p.hand:
if not game.can_play(p, card)[0]: if not game.can_play(p, card)[0]:
continue continue
@@ -144,6 +164,14 @@ static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -
valid = valid_cache[key] valid = valid_cache[key]
for t in valid: for t in valid:
out.append({"type": "play", "card": card, "target": t}) out.append({"type": "play", "card": card, "target": t})
var pairs := []
for i in p.board.size():
for j in range(i + 1, p.board.size()):
if game.can_fuse(p, p.board[i], p.board[j])[0]:
pairs.append({"type": "fuse", "creature": p.board[i], "other": p.board[j]})
if prune: # Verschmelzen lohnt vor allem bei schwachen Wesen
pairs = _top(pairs, func(a): return -float(_value(a.creature) + _value(a.other)), MAX_TARGETS)
out.append_array(pairs)
for cr in p.board: for cr in p.board:
if not cr.can_attack(): if not cr.can_attack():
continue continue
@@ -165,10 +193,17 @@ static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
var target = null var target = null
if action.get("target") != null: if action.get("target") != null:
target = map_target(g2, game, action.target) target = map_target(g2, game, action.target)
if action.type == "play": match action.type:
g2.play_card(p2, action.card, target) "play":
else: g2.play_card(p2, action.card, target)
g2.attack(g2.find_creature(action.creature.uid), target) "buy":
g2.buy(p2, int(action.slot))
"reroll":
g2.reroll(p2)
"fuse":
g2.fuse(p2, g2.find_creature(action.creature.uid), g2.find_creature(action.other.uid))
_:
g2.attack(g2.find_creature(action.creature.uid), target)
static func map_target(g2: Game, game: Game, t): static func map_target(g2: Game, game: Game, t):
@@ -182,31 +217,26 @@ 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"] = game.find_creature(action.creature.uid)
if action.has("other"):
out["other"] = game.find_creature(action.other.uid)
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.
static func mulligan_cards(p: Game.Player) -> Array:
return p.hand.filter(func(c): return c.cost >= 5)
## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer ## 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 ## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug. ## (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():
return {"type": "mulligan", "cards": mulligan_cards(p)}
var t0 := Time.get_ticks_msec() var t0 := Time.get_ticks_msec()
var base := evaluate(game, p) + 0.01 var base := evaluate(game, p) + 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(game, p, 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 := game.clone()
apply_on_clone(g2, game, action) apply_on_clone(g2, game, 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[game.current_idx])})
firsts.sort_custom(func(a, b): return a.score > b.score) firsts.sort_custom(func(a, b): return a.score > b.score)
@@ -223,7 +253,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
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[game.current_idx]) - 0.001)
if score > best_score: if score > best_score:
@@ -240,7 +270,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
# --- Gierige Vergleichs-KI ------------------------------------------------------- # --- Gierige Vergleichs-KI -------------------------------------------------------
static func _kills(attacker, defender) -> bool: static func _kills(attacker, defender) -> bool:
return not defender.shield and defender.health <= attacker.power() return not defender.shield and defender.health <= attacker.power() + Game.matchup_bonus(attacker, defender)
static func _value(c) -> int: static func _value(c) -> int:
@@ -276,8 +306,15 @@ static func _weakest(heroes: Array):
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: erst das teuerste Bezahlbare kaufen, dann ausspielen, dann angreifen.
return {"type": "mulligan", "cards": mulligan_cards(p)} var best := -1
for k in p.shop.size():
var offer = p.shop[k]
if offer != null and p.energy >= offer.cost and p.hand.size() < Game.MAX_HAND:
if best < 0 or offer.cost > p.shop[best].cost:
best = k
if best >= 0:
return {"type": "buy", "slot": best}
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)
@@ -360,10 +397,14 @@ 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, action.target)
"buy":
game.buy(p, int(action.slot))
"reroll":
game.reroll(p)
"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()

View File

@@ -6,8 +6,8 @@ const STAGES := [
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "reward": 100, "perk": "leben_35"}, {"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "extra": 0, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "reward": 150, "perk": "waechter_leben"}, {"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "extra": 1, "reward": 150, "perk": "waechter_leben"},
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "reward": 150, "perk": "flink_angriff"}, {"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "extra": 1, "reward": 150, "perk": "flink_angriff"},
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "zauber_schaden"}, {"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "extra": 2, "reward": 200, "perk": "wesen_leben"},
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_karten"}, {"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "extra": 2, "reward": 200, "perk": "extra_mana"},
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "reward": 250, "perk": "resonanz_doppelt"}, {"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "extra": 3, "reward": 250, "perk": "resonanz_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "reward": 250, "perk": "schild_wesen"}, {"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "extra": 3, "reward": 250, "perk": "schild_wesen"},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 300, "perk": "alle_flink"}, {"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 300, "perk": "alle_flink"},
@@ -18,46 +18,34 @@ const STAGES := [
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück. ## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Gefolge zurück.
const STAGES2 := [ const STAGES2 := [
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "extra": 1, "reward": 300, "perks": ["feuer_angriff", "start_energie"]}, {"name": "Fenn", "title": "Flammenzähmer", "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": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "extra": 1, "reward": 300, "perks": ["leben_35", "extra_mana"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "extra": 2, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]}, {"name": "Borin", "title": "Fels der Ewigkeit", "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": "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": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "extra": 3, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_karten", "flink_angriff"]}, {"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "extra": 3, "reward": 400, "perks": ["extra_mana", "flink_angriff"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "zauber_schaden"]}, {"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "extra": 4, "reward": 450, "perks": ["resonanz_doppelt", "wesen_leben"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "extra": 4, "reward": 450, "perks": ["schild_wesen", "leben_35"]}, {"name": "Thalassa", "title": "Kaiserin der Tiefe", "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": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 500, "perks": ["alle_flink", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 5, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_karten"]}, {"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "extra": 4, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_mana"]},
] ]
## Kapitel 3: drei Merkmale, volle Tische, die stärksten Decks. ## Kapitel 3: drei Merkmale und volle Tische.
const STAGES3 := [ const STAGES3 := [
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "zauber_schaden"]}, {"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_karten", "schild_wesen"]}, {"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]},
{"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "extra": 3, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]}, {"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": "Liska", "title": "Windbraut", "elements": ["Luft"], "extra": 3, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_mana"]},
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["zauber_schaden", "feuer_angriff", "waechter_leben"]}, {"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "waechter_leben"]},
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_karten", "flink_angriff", "resonanz_doppelt"]}, {"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_mana", "flink_angriff", "resonanz_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 5, "reward": 900, "perks": ["resonanz_doppelt", "zauber_schaden", "start_energie"]}, {"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 4, "reward": 900, "perks": ["resonanz_doppelt", "wesen_leben", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 5, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]}, {"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 4, "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": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 4, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]},
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 5, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_karten", "leben_35"]}, {"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 4, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_mana", "leben_35"]},
] ]
const CHAPTERS := [STAGES, STAGES2, STAGES3] const CHAPTERS := [STAGES, STAGES2, STAGES3]
# Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks.
const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6]
const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4]
const MIN_RATING3 := [3.2, 3.3, 3.5, 3.6, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4]
const MIN_RATINGS := [MIN_RATING, MIN_RATING2, MIN_RATING3]
static func target_rating(stage_index: int, player_rating: float) -> float:
var base: float = MIN_RATINGS[chapter(stage_index) - 1][stage_index % STAGES.size()]
return maxf(base, player_rating)
static func total() -> int: static func total() -> int:
return STAGES.size() * CHAPTERS.size() return STAGES.size() * CHAPTERS.size()
@@ -75,15 +63,14 @@ static func stage(i: int) -> Dictionary:
return CHAPTERS[chapter(i) - 1][i % STAGES.size()] return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist). ## Gegner einer Stufe: der Boss handelt nur mit seinen Elementen, Begleiter mit allen.
static func opponents(stage_index: int, r: RandomNumberGenerator, player_rating: float = 0.0) -> Array: static func opponents(stage_index: int) -> Array:
var st := stage(stage_index) var st := stage(stage_index)
var target := target_rating(stage_index, player_rating)
var life: int = 35 if "leben_35" in st.perks else Game.START_LIFE 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) var boss := Game.Player.new(st.name, false, life, st.title, st.elements)
boss.perks = Array(st.perks) boss.perks = Array(st.perks)
var out := [boss] var out := [boss]
var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1) var extra: int = mini(st.extra, Game.MAX_OPPONENTS - 1)
for k in extra: for k in extra:
out.append(Game.random_opponent(r, k + 1, Game.START_LIFE, target)) out.append(DeckGen.random_opponent(k + 1))
return out return out

View File

@@ -8,7 +8,8 @@ const ELEMENT_COLORS := {
"Erde": Color("73552d"), "Erde": Color("73552d"),
"Luft": Color("78aac8"), "Luft": Color("78aac8"),
} }
# Jede Paarung ist eine Kombo, auch gleiche Elemente. # Jede Karte hat genau ein Element. Kombos entstehen erst im Spiel: beim Verschmelzen
# (der Name des neuen Wesens) und als Feldkombo zwischen benachbarten Elementen.
const COMBOS := { const COMBOS := {
"Feuer+Feuer": "Inferno", "Feuer+Feuer": "Inferno",
"Wasser+Wasser": "Tiefsee", "Wasser+Wasser": "Tiefsee",
@@ -20,11 +21,6 @@ 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. # Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird.
const FIELD_COMBO_TEXT := { const FIELD_COMBO_TEXT := {
@@ -32,10 +28,21 @@ const FIELD_COMBO_TEXT := {
"Lava": "1 Schaden an das stärkste gegnerische Wesen", "Lava": "1 Schaden an das stärkste gegnerische Wesen",
"Blitz": "2 Schaden an den schwächsten Gegner", "Blitz": "2 Schaden an den schwächsten Gegner",
"Schlamm": "Heile dich um 3", "Schlamm": "Heile dich um 3",
"Sturm": "Ziehe 1 Karte", "Sturm": "Erhalte 1 Mana",
"Sand": "Das neue Wesen erhält +1/+1", "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." # Elementkreis: Wasser löscht Feuer, Feuer verbrennt Luft, Luft trägt Erde ab, Erde saugt Wasser auf.
const BEATS := {"Wasser": "Feuer", "Feuer": "Luft", "Luft": "Erde", "Erde": "Wasser"}
# Das Feld gehört allen am Tisch: jede gespielte Karte lädt ihre Elemente, bei FIELD_CHARGE kippt es.
const FIELD_CHARGE := 4
const FIELD_NAMES := {"Feuer": "Vulkan", "Wasser": "Flut", "Erde": "Fels", "Luft": "Wirbel"}
const FIELD_TEXT := {
"Feuer": "Feuerwesen haben +1 Angriff",
"Wasser": "Wasserwesen haben +1 Angriff",
"Erde": "Erdwesen haben +1 Angriff",
"Luft": "Luftwesen haben +1 Angriff",
}
# Seltenheit ist reine Optik: jede Karte gibt es in jeder Stufe, die Werte bleiben gleich.
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"] const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
const RARITY_COLORS := { const RARITY_COLORS := {
"gewöhnlich": Color("aaaaaa"), "gewöhnlich": Color("aaaaaa"),
@@ -45,15 +52,7 @@ const RARITY_COLORS := {
"mythisch": Color("ff5a8c"), "mythisch": Color("ff5a8c"),
} }
const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"] const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"]
const KEYWORD_HELP := {
"Wächter": "Muss zuerst angegriffen werden.",
"Flink": "Kann sofort angreifen.",
"Schild": "Blockt den nächsten Schaden.",
"Doppelschlag": "Greift zweimal pro Zug an.",
}
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"] const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"]
# 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"
@@ -63,10 +62,54 @@ static func combo_key(elements: Array) -> String:
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")
## Elementvorteil aus Sicht von own: +1 im Vorteil, -1 im Nachteil, sonst 0.
## Mehrfachelemente nehmen den für sie besseren Wert.
static func matchup(own: Array, other: Array) -> int:
for e in own:
if BEATS.get(e, "") in other:
return 1
for e in other:
if BEATS.get(e, "") in own:
return -1
return 0
## Mana für eine Verschmelzung: Durchschnitt der Kartenkosten, aufgerundet.
static func fusion_cost(a: Card, b: Card) -> int:
return ceili((a.cost + b.cost) / 2.0)
## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt).
static func fusion_name(elements: Array) -> String:
return combo_name(elements if elements.size() >= 2 else [elements[0], elements[0]])
## Karte des verschmolzenen Wesens: Elemente und Schlüsselwörter beider Karten,
## Werte kommen vom Spiel (Durchschnitt plus 1, siehe Game.fuse).
static func fused(a: Card, b: Card, attack: int, health: int) -> Card:
var els := []
for e in a.elements + b.elements:
if not (e in els):
els.append(e)
els.sort_custom(func(x, y): return ELEMENTS.find(x) < ELEMENTS.find(y))
var c := Card.new()
c.id = "fus_%s_%s" % [a.id, b.id]
c.name = fusion_name(els)
c.elements = els
c.cost = fusion_cost(a, b)
c.attack = attack
c.health = health
for k in KEYWORDS:
if k in a.keywords or k in b.keywords:
c.keywords.append(k)
return c
static func field_combo_effect(name: String) -> Effect: static func field_combo_effect(name: String) -> Effect:
@@ -80,31 +123,23 @@ static func field_combo_effect(name: String) -> Effect:
"Schlamm": "Schlamm":
return Effect.new("heal", "self_hero", 3) return Effect.new("heal", "self_hero", 3)
"Sturm": "Sturm":
return Effect.new("draw", "none", 1) return Effect.new("mana", "none", 1)
"Sand": "Sand":
return Effect.new("buff", "self_creature", 1, 1) return Effect.new("buff", "self_creature", 1, 1)
return null return null
const TRIGGER_TEXT := {
"attacked": "Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift",
"creature_played": "Wenn ein Gegner ein Wesen ausspielt",
}
class Effect extends RefCounted: class Effect extends RefCounted:
var kind: String # damage, heal, draw, freeze, bounce, buff, shield, summon, cancel var kind: String # damage, heal, mana, freeze, bounce, buff, shield
var target: String # siehe CHOSEN_TARGETS und Game.resolve() var target: String # siehe CHOSEN_TARGETS und Game.resolve()
var amount: int var amount: int
var amount2: int var amount2: int
var card_id: String # für summon
func _init(k: String, t: String, a: int = 0, a2: int = 0, cid: String = "") -> void: func _init(k: String, t: String, a: int = 0, a2: int = 0) -> void:
kind = k kind = k
target = t target = t
amount = a amount = a
amount2 = a2 amount2 = a2
card_id = cid
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
var d := {"kind": kind, "target": target} var d := {"kind": kind, "target": target}
@@ -112,34 +147,15 @@ class Effect extends RefCounted:
d["amount"] = amount d["amount"] = amount
if amount2 != 0: if amount2 != 0:
d["amount2"] = amount2 d["amount2"] = amount2
if card_id != "":
d["card_id"] = card_id
return d return d
static func from_dict(d: Dictionary) -> Effect: 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", ""))) return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)))
## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl). ## Kartentext in der aktuellen Sprache.
func text(trigger: String = "") -> String: func text() -> String:
var n := str(amount) var n := str(amount)
if target == "trigger_source":
var played := trigger == "creature_played"
match kind:
"damage":
if played:
return tr("%s Schaden an dieses Wesen") % n
return tr("%s Schaden an den Angreifer") % n
"freeze":
if played:
return tr("Es wird eingefroren")
return tr("Der Angreifer wird eingefroren")
"bounce":
return tr("Es geht zurück auf die Hand")
match kind: match kind:
"cancel":
return tr("Der Angriff verpufft")
"summon":
return tr("Beschwöre einen %s") % Cards.by_id(card_id).title()
"damage": "damage":
match target: match target:
"enemy_creature": "enemy_creature":
@@ -154,10 +170,10 @@ class Effect extends RefCounted:
return tr("%s Schaden an alle Gegner und ihre Wesen") % n return tr("%s Schaden an alle Gegner und ihre Wesen") % n
"heal": "heal":
return tr("Heile dich um %s") % n return tr("Heile dich um %s") % n
"draw": "mana":
if amount == 1: if amount == 1:
return tr("Ziehe 1 Karte") return tr("Erhalte 1 Mana")
return tr("Ziehe %s Karten") % n return tr("Erhalte %s Mana") % n
"freeze": "freeze":
if target == "enemy_creature": if target == "enemy_creature":
return tr("Friere ein gegnerisches Wesen ein") return tr("Friere ein gegnerisches Wesen ein")
@@ -188,31 +204,18 @@ class Card extends RefCounted:
var name_en := "" # englischer Name var name_en := "" # englischer Name
var elements: Array var elements: Array
var cost: int 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 keywords: Array = []
var effects: Array = [] var effects: Array = []
var trigger: String = "" # nur Fallen: attacked, creature_played
func is_creature() -> bool:
return kind == "Wesen"
func is_trap() -> bool:
return kind == "Falle"
func to_dict() -> Dictionary: 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, "cost": cost,
if is_creature(): "attack": attack, "health": health}
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 not effects.is_empty():
d["effects"] = effects.map(func(e): return e.to_dict()) d["effects"] = effects.map(func(e): return e.to_dict())
if trigger != "":
d["trigger"] = trigger
return d return d
static func from_dict(d: Dictionary) -> Card: static func from_dict(d: Dictionary) -> Card:
@@ -222,14 +225,11 @@ class Card extends RefCounted:
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.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.keywords = Array(d.get("keywords", [])) c.keywords = Array(d.get("keywords", []))
for e in d.get("effects", []): for e in d.get("effects", []):
c.effects.append(Effect.from_dict(e)) c.effects.append(Effect.from_dict(e))
c.trigger = str(d.get("trigger", ""))
return c return c
## Anzeigename in der aktuellen Sprache. ## Anzeigename in der aktuellen Sprache.
@@ -261,14 +261,8 @@ class Card extends RefCounted:
if not effects.is_empty(): if not effects.is_empty():
var fx: Array[String] = [] var fx: Array[String] = []
for f in effects: for f in effects:
fx.append(f.text(trigger)) fx.append(f.text())
var s := ", ".join(fx) + "." parts.append(tr("Beim Ausspielen: %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) return " ".join(parts)
@@ -294,14 +288,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()

View File

@@ -1,142 +1,35 @@
class_name DeckGen class_name DeckGen
## Deckstärke, Gegnerdecks und Archetypen (ausgelagert aus den Spielregeln). ## Ladenangebot und Gegner (ausgelagert aus den Spielregeln).
## Es gibt keine Decks: jeder kauft jede Runde aus seinem eigenen Laden.
const OPPONENT_RARITY_WEIGHT := {"gewöhnlich": 6.0, "selten": 3.0, "episch": 2.0, "legendär": 1.0, "mythisch": 0.3}
# Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner). # Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner).
const RESONANCE_STEPS := [0, 1, 2, 3, 3] const RESONANCE_STEPS := [0, 1, 2, 3, 3]
# Gemessene Stärke der Elementkombinationen (Siegquote gegen das Feld, KI gegen KI, 60 Partien je Archetyp, tests/strength_probe.gd).
const ARCHETYPE_STRENGTH := {"Erde": 50, "Erde+Luft": 56, "Feuer": 40, "Feuer+Erde": 64, "Feuer+Luft": 42, "Feuer+Wasser": 57, "Luft": 45, "Wasser": 43, "Wasser+Erde": 50, "Wasser+Luft": 41} ## Karten, die im Laden auftauchen dürfen: höchstens Einkommen plus eins teuer,
## bei gesetzten Elementen nur aus diesen.
static func pool(income: int, elements: Array = []) -> Array:
var limit := income + 1
return Cards.all().filter(func(c):
return c.cost <= limit and (elements.is_empty() or c.elements.all(func(e): return e in elements)))
## Angebot des Ladens: n zufällige Karten (Wiederholungen erlaubt, wie in TFT).
const ARCHETYPE_PATH := "res://data/archetypen.json" ## Mindestens eine Karte ist vom Einkommen allein bezahlbar, sonst wäre der Zug tot.
static var _strength: Dictionary = {} static func shop_cards(r: RandomNumberGenerator, income: int, elements: Array = [], n: int = Game.SHOP_SIZE) -> Array:
var p := pool(income, elements)
if p.is_empty():
p = pool(Game.MAX_ENERGY, elements)
assert(not p.is_empty(), "kein Angebot für " + str(elements))
var out := []
for i in n:
out.append(p[r.randi_range(0, p.size() - 1)])
var cheap := p.filter(func(c): return c.cost <= income)
if not cheap.is_empty() and not out.any(func(c): return c.cost <= income):
out[r.randi_range(0, out.size() - 1)] = cheap[r.randi_range(0, cheap.size() - 1)]
return out
## Gemessene Werte aus data/archetypen.json (vom Messlauf geschrieben), sonst die eingebaute Tabelle. ## Gegner mit eigenem Laden über alle Elemente.
static func strength() -> Dictionary: static func random_opponent(index: int, life: int = Game.START_LIFE) -> Game.Player:
if _strength.is_empty(): return Game.Player.new("Gegner %d" % index, false, life, "Magier")
_strength = ARCHETYPE_STRENGTH.duplicate()
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
counts[c.id] = counts.get(c.id, 0) + 1
deck.append(c)
return deck
## Karte nahe den gewünschten Kosten, gewichtet nach Seltenheit (bias) und Kostenabstand.
## Bei extremer Seltenheitsvorgabe darf die Kurve nachgeben, sonst wäre das Ziel unerreichbar.
static func _pick_for_cost(r: RandomNumberGenerator, pool: Array, counts: Dictionary, cost: int, bias: float):
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.
static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array:
var bias := 1.0
var best := []
var best_diff := 99.0
for attempt in 24:
var deck := build_opponent_deck(r, elements, bias)
var diff := deck_rating(deck) - target
if absf(diff) < best_diff:
best = deck
best_diff = absf(diff)
if absf(diff) <= tolerance:
break
bias = clampf(bias * (1.35 if diff < 0 else 1.0 / 1.35), 0.05, 20.0)
return best
## Zufälliger Gegner. target > 0: Deck auf diese Stärke gebaut. tier: Archetypen der Schwierigkeit.
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = Game.START_LIFE, target: float = 0.0, tier: String = "") -> Game.Player:
var options := archetypes(tier)
var elements: Array = options[r.randi_range(0, options.size() - 1)].split("+")
var deck := build_opponent_deck_rated(r, elements, target) if target > 0.0 else build_opponent_deck(r, elements)
return Game.Player.new("Gegner %d" % index, deck, false, life, Cards.combo_name(elements) + "-Magier")
## Kartenwert, bei genug Partien mit der Siegquote der Karte gewichtet (Statistik aus Spieltests).
static func card_value(c: Cards.Card, stats: Dictionary = {}) -> float:
var v := Cards.card_value(c)
if stats.has(c.id):
var st: Dictionary = stats[c.id]
if int(st.games) >= 10:
v *= 0.5 + float(st.wins) / float(st.games)
return v
## Abzug für schlechte Energiekurven: zu wenig günstige oder zu viele teure Karten.
static func curve_penalty(cards: Array) -> float:
var cheap := 0
var expensive := 0
for c in cards:
if c.cost <= 2:
cheap += 1
if c.cost >= 5:
expensive += 1
return minf(0.05 * maxi(0, 6 - cheap) + 0.05 * maxi(0, expensive - 5), 0.4)
## Deckstärke: Durchschnitt der Kartenwerte minus Kurvenabzug (1 = nur gewöhnliche Karten, bis etwa 3,5).
static func deck_rating(cards: Array, stats: Dictionary = {}) -> float:
if cards.is_empty():
return 0.0
var sum := 0.0
for c in cards:
sum += card_value(c, stats)
return sum / cards.size() - curve_penalty(cards)

View File

@@ -4,26 +4,24 @@ extends RefCounted
const MAX_BOARD := 5 const MAX_BOARD := 5
const MAX_HAND := 8 const MAX_HAND := 8
const MAX_ENERGY := 10 const MAX_ENERGY := 10 # Einkommen je Zug, höher steigt es nicht
const START_HAND := 4 const SHOP_SIZE := 5 # so viele Karten liegen im Laden
const REROLL_COST := 1
# Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben. # Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben.
const START_LIFE := 30 const START_LIFE := 30
const MAX_OPPONENTS := 5 const MAX_OPPONENTS := 5
const RESONANCE_MAX := 4
const MAX_TRAPS := 2
# Merkmale der Kampagnen-Bosse (Player.perks). # Merkmale der Kampagnen-Bosse (Player.perks).
const PERKS := { const PERKS := {
"feuer_angriff": "Feuerwesen haben +1 Angriff", "feuer_angriff": "Feuerwesen haben +1 Angriff",
"flink_angriff": "Flinke Wesen haben +1 Angriff", "flink_angriff": "Flinke Wesen haben +1 Angriff",
"waechter_leben": "Wächter kommen mit +2 Leben", "waechter_leben": "Wächter kommen mit +2 Leben",
"extra_karten": "Zieht 2 Karten pro Zug", "wesen_leben": "Wesen kommen mit +1 Leben",
"extra_mana": "Bekommt jeden Zug 1 Mana mehr",
"leben_35": "Beginnt mit 35 Leben", "leben_35": "Beginnt mit 35 Leben",
"zauber_schaden": "Schadenszauber machen +1 Schaden",
"schild_wesen": "Wesen kommen mit Schild", "schild_wesen": "Wesen kommen mit Schild",
"alle_flink": "Alle Wesen sind flink", "alle_flink": "Alle Wesen sind flink",
"start_energie": "Beginnt mit 3 Energie", "start_energie": "Beginnt mit 3 Mana",
"resonanz_doppelt": "Resonanz zählt doppelt", "resonanz_doppelt": "Resonanz zählt doppelt",
} }
@@ -40,9 +38,6 @@ static func make_rng() -> RandomNumberGenerator:
r.randomize() r.randomize()
return r return r
const DECK_SIZE := 20
const MAX_COPIES := 3
const MAX_DECK_ELEMENTS := 2
class Creature extends RefCounted: class Creature extends RefCounted:
@@ -52,6 +47,7 @@ class Creature extends RefCounted:
var uid: int var uid: int
var attack: int var attack: int
var resonance := 0 # +1, wenn ein anderes eigenes Wesen ein Element teilt var resonance := 0 # +1, wenn ein anderes eigenes Wesen ein Element teilt
var field_bonus := 0 # +1, wenn das Feld auf dem eigenen Element steht
var health: int var health: int
var max_health: int var max_health: int
var shield: bool var shield: bool
@@ -74,9 +70,9 @@ class Creature extends RefCounted:
func owner(): func owner():
return owner_ref.get_ref() return owner_ref.get_ref()
## Angriffswert inklusive Resonanz und Boss-Merkmalen. ## Angriffswert inklusive Resonanz, Feld und Boss-Merkmalen.
func power() -> int: func power() -> int:
var v := attack + resonance var v := attack + resonance + field_bonus
var o = owner() var o = owner()
if o != null: if o != null:
if "feuer_angriff" in o.perks and "Feuer" in card.elements: if "feuer_angriff" in o.perks and "Feuer" in card.elements:
@@ -98,6 +94,7 @@ class Creature extends RefCounted:
var c := Creature.new(card, new_owner, uid) var c := Creature.new(card, new_owner, uid)
c.attack = attack c.attack = attack
c.resonance = resonance c.resonance = resonance
c.field_bonus = field_bonus
c.health = health c.health = health
c.max_health = max_health c.max_health = max_health
c.shield = shield c.shield = shield
@@ -113,43 +110,41 @@ class Player extends RefCounted:
var is_human: bool var is_human: bool
var life: int var life: int
var max_life: int var max_life: int
var deck: Array = [] var shop: Array = [] # SHOP_SIZE Plätze, gekaufte sind null
var hand: Array = [] var hand: Array = []
var board: Array = [] var board: Array = []
var energy := 0 var energy := 0 # Mana; nicht ausgegebenes bleibt liegen
var max_energy := 0 var max_energy := 0 # Einkommen je Zug
var fatigue := 0 var start_bonus := 0 # einmaliges Extra-Mana für den späteren Start
var start_bonus := 0 # einmalige Extra-Energie im ersten Zug (Ausgleich fürs Nachziehen)
var traps: Array = [] # verdeckt liegende Fallenkarten
var order := 0 # Platz in der Zugreihenfolge var order := 0 # Platz in der Zugreihenfolge
var perks: Array = [] # Boss-Merkmale (siehe PERKS) var perks: Array = [] # Boss-Merkmale (siehe PERKS)
var elements: Array = [] # Laden nur aus diesen Elementen (leer = alle)
var looks := {} # Karten-ID -> Seltenheitsstufe, reine Optik (siehe Profile)
var turns := 0 # begonnene eigene Züge var turns := 0 # begonnene eigene Züge
var mulligan_done := false
func _init(n: String, d: Array, h: bool, l: int, t: String = "") -> void: func _init(n: String, h: bool, l: int, t: String = "", els: Array = []) -> void:
name = n name = n
title = t title = t
is_human = h is_human = h
life = l life = l
max_life = l max_life = l
deck = d.duplicate() elements = els.duplicate()
func alive() -> bool: func alive() -> bool:
return life > 0 return life > 0
func clone() -> Player: func clone() -> Player:
var p := Player.new(name, deck, is_human, life, title) var p := Player.new(name, is_human, life, title, elements)
p.max_life = max_life p.max_life = max_life
p.shop = shop.duplicate()
p.hand = hand.duplicate() p.hand = hand.duplicate()
p.energy = energy p.energy = energy
p.max_energy = max_energy p.max_energy = max_energy
p.fatigue = fatigue
p.start_bonus = start_bonus p.start_bonus = start_bonus
p.traps = traps.duplicate()
p.order = order p.order = order
p.perks = perks.duplicate() p.perks = perks.duplicate()
p.looks = looks
p.turns = turns p.turns = turns
p.mulligan_done = mulligan_done
for c in board: for c in board:
p.board.append(c.clone(p)) p.board.append(c.clone(p))
return p return p
@@ -168,6 +163,8 @@ var events: Array = [] # strukturierte Ereignisse für Animationen
var winner := "" # "", "human" oder "opponents" var winner := "" # "", "human" oder "opponents"
var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse
var next_uid := 1 var next_uid := 1
var field_element := "" # gemeinsames Feld aller am Tisch, "" = neutral
var field_charge := {} # Element -> gespielte Karten seit dem letzten Wechsel
func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void: func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void:
@@ -178,6 +175,8 @@ func _init(h: Player, opps: Array, r: RandomNumberGenerator = null) -> void:
if r == null: if r == null:
r = make_rng() r = make_rng()
rng = r rng = r
for e in Cards.ELEMENTS:
field_charge[e] = 0
func current() -> Player: func current() -> Player:
@@ -225,20 +224,12 @@ func clone() -> Game:
g.current_idx = current_idx g.current_idx = current_idx
g.winner = winner g.winner = winner
g.next_uid = next_uid g.next_uid = next_uid
g.field_element = field_element
g.field_charge = field_charge.duplicate()
g.quiet = true g.quiet = true
return g return g
## Kopie aus Sicht von p: fremde Fallen sind unbekannt und fehlen (die KI schummelt nicht).
func clone_for(p: Player) -> Game:
var g := clone()
var p2: Player = g.players[players.find(p)]
for q in g.players:
if q != p2:
q.traps = []
return g
# --- Zugablauf --------------------------------------------------------------- # --- Zugablauf ---------------------------------------------------------------
func start(fixed_order: bool = false) -> void: func start(fixed_order: bool = false) -> void:
@@ -247,13 +238,9 @@ func start(fixed_order: bool = false) -> void:
for i in players.size(): for i in players.size():
var p: Player = players[i] var p: Player = players[i]
p.order = i p.order = i
if "start_energie" in p.perks: # Wer nicht beginnt, bekommt im ersten Zug einmalig 1 Mana extra.
p.max_energy = 2
shuffle(p.deck, rng)
# Wer nicht beginnt, startet mit einer Karte mehr und hat im ersten Zug
# einmalig 1 Energie extra (Ausgleich für den Startvorteil).
draw(p, START_HAND + (0 if i == 0 else 1))
p.start_bonus = 0 if i == 0 else 1 p.start_bonus = 0 if i == 0 else 1
roll_shop(p)
turn = 1 turn = 1
current_idx = 0 current_idx = 0
say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name))) say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name)))
@@ -263,28 +250,54 @@ func start(fixed_order: bool = false) -> void:
begin_turn(current()) begin_turn(current())
func draw(p: Player, n: int = 1) -> void: ## Füllt den Laden neu: SHOP_SIZE Karten, höchstens so teuer wie das Einkommen plus eins.
for i in n: func roll_shop(p: Player) -> void:
if p.deck.is_empty(): p.shop = DeckGen.shop_cards(rng, p.max_energy, p.elements)
p.fatigue += 1
say("%s hat keine Karten mehr und erleidet %d Schaden." % [p.name, p.fatigue])
damage(p, p.fatigue) ## Kauft eine Karte aus dem Laden auf die Hand. Gibt "" zurück oder eine Fehlermeldung.
continue func buy(p: Player, slot: int) -> String:
var card = p.deck.pop_back() if winner != "" or p != current():
if p.hand.size() >= MAX_HAND: return "Nicht dein Zug"
say("%s: Hand voll, %s verbrennt." % [p.name, card.name]) if slot < 0 or slot >= p.shop.size() or p.shop[slot] == null:
else: return "Kein Angebot"
p.hand.append(card) var card: Cards.Card = p.shop[slot]
check_end() if p.energy < card.cost:
return "Nicht genug Mana"
if p.hand.size() >= MAX_HAND:
return "Hand voll"
p.energy -= card.cost
p.shop[slot] = null
p.hand.append(card)
say("%s kauft %s." % [p.name, card.name])
emit({"type": "buy", "player": players.find(p), "card": card})
return ""
## Würfelt den Laden neu. Gibt "" zurück oder eine Fehlermeldung.
func reroll(p: Player) -> String:
if winner != "" or p != current():
return "Nicht dein Zug"
if p.energy < REROLL_COST:
return "Nicht genug Mana"
p.energy -= REROLL_COST
roll_shop(p)
say("%s würfelt den Laden neu." % p.name)
emit({"type": "reroll", "player": players.find(p)})
return ""
func begin_turn(p: Player) -> void: func begin_turn(p: Player) -> void:
p.max_energy = mini(MAX_ENERGY, p.max_energy + 1) p.max_energy = mini(MAX_ENERGY, p.max_energy + 1)
p.energy = p.max_energy # Nicht ausgegebenes Mana bleibt liegen.
p.energy += p.max_energy + (1 if "extra_mana" in p.perks else 0)
if p.turns == 0 and "start_energie" in p.perks:
p.energy += 2 # einmalige Starthilfe, kein dauerhafter Vorsprung
if p.start_bonus > 0: if p.start_bonus > 0:
p.energy += p.start_bonus p.energy += p.start_bonus
say("%s erhält %d Energie extra für den späteren Start." % [p.name, p.start_bonus]) say("%s erhält %d Mana extra für den späteren Start." % [p.name, p.start_bonus])
p.start_bonus = 0 p.start_bonus = 0
roll_shop(p)
for c in p.board: for c in p.board:
c.ready = true c.ready = true
if c.frozen: if c.frozen:
@@ -296,28 +309,6 @@ func begin_turn(p: Player) -> void:
p.turns += 1 p.turns += 1
say("--- Zug %d: %s ---" % [turn, p.name]) say("--- Zug %d: %s ---" % [turn, p.name])
emit({"type": "turn", "player": players.find(p)}) emit({"type": "turn", "player": players.find(p)})
draw(p, 2 if "extra_karten" in p.perks else 1)
## Tauscht einmalig im ersten eigenen Zug Karten der Hand: zurück ins Deck, mischen, nachziehen.
func mulligan(p: Player, cards: Array) -> String:
if p.mulligan_done:
return "Schon getauscht"
if p != current() or p.turns != 1:
return "Nur im ersten eigenen Zug"
p.mulligan_done = true
var n := 0
for c in cards:
if c in p.hand:
p.hand.erase(c)
p.deck.append(c)
n += 1
if n > 0:
shuffle(p.deck, rng)
draw(p, n)
say(("%s tauscht 1 Karte." % p.name if n == 1 else "%s tauscht %d Karten." % [p.name, n]) if n > 0 else "%s behält die Hand." % p.name)
emit({"type": "mulligan", "player": players.find(p), "count": n})
return ""
func end_turn() -> void: func end_turn() -> void:
@@ -336,18 +327,14 @@ func end_turn() -> void:
# --- Karten spielen ---------------------------------------------------------- # --- Karten spielen ----------------------------------------------------------
## Ausspielen kostet nichts mehr: bezahlt wird beim Kauf im Laden.
func can_play(p: Player, card: Cards.Card) -> Array: func can_play(p: Player, card: Cards.Card) -> Array:
if winner != "" or p != current(): if winner != "" or p != current():
return [false, "Nicht dein Zug"] return [false, "Nicht dein Zug"]
if p.energy < card.cost: if not (card in p.hand):
return [false, "Nicht genug Energie"] return [false, "Nicht auf der Hand"]
if card.is_creature() and p.board.size() >= MAX_BOARD: if p.board.size() >= MAX_BOARD:
return [false, "Dein Feld ist voll"] return [false, "Dein Feld ist voll"]
if card.is_trap() and p.traps.size() >= MAX_TRAPS:
return [false, "Höchstens %d Fallen" % MAX_TRAPS]
var tt := card.target_type()
if tt != "" and not card.is_creature() and valid_targets(p, tt).is_empty():
return [false, "Kein gültiges Ziel"]
return [true, ""] return [true, ""]
@@ -379,37 +366,40 @@ func play_card(p: Player, card: Cards.Card, target = null) -> String:
if valid.is_empty(): if valid.is_empty():
target = null target = null
p.hand.erase(card) p.hand.erase(card)
p.energy -= card.cost
if card.is_trap():
p.traps.append(card)
say("%s legt eine Falle." % p.name)
emit({"type": "trap_set", "player": players.find(p)})
check_end()
return ""
say("%s spielt %s." % [p.name, card.name]) say("%s spielt %s." % [p.name, card.name])
var cr: Creature = null var cr := _enter(p, card)
var bonus := 0
if card.is_creature():
cr = _enter(p, card)
else:
emit({"type": "spell", "player": players.find(p), "card": card})
if "zauber_schaden" in p.perks:
bonus = 1
for fx in card.effects: for fx in card.effects:
resolve(p, fx, target, cr, bonus) resolve(p, fx, target, cr, card.elements)
if cr != null and cr in p.board: if cr in p.board:
for e in enemies_of(p):
if not (cr in p.board):
break
trigger_trap(e, "creature_played", cr)
if cr != null and cr in p.board:
trigger_field_combos(p, cr) trigger_field_combos(p, cr)
charge_field(card)
update_resonance() update_resonance()
cleanup() cleanup()
check_end() check_end()
return "" return ""
## Jede gespielte Karte lädt ihre Elemente. Wer FIELD_CHARGE erreicht, kippt das
## gemeinsame Feld auf sein Element; danach beginnen alle Zähler von vorn.
func charge_field(card: Cards.Card) -> void:
for e in card.elements:
field_charge[e] = int(field_charge.get(e, 0)) + 1
var best := ""
for e in Cards.ELEMENTS:
if e == field_element or int(field_charge.get(e, 0)) < Cards.FIELD_CHARGE:
continue
if best == "" or int(field_charge[e]) > int(field_charge[best]):
best = e
if best == "":
return
field_element = best
for e in Cards.ELEMENTS:
field_charge[e] = 0
say("Das Feld wird zu %s: %s." % [Cards.FIELD_NAMES[best], Cards.FIELD_TEXT[best]])
emit({"type": "field", "element": best})
update_resonance() # der Feldbonus gilt sofort
## Feldkombo: das neue Wesen bildet mit dem ersten eigenen Wesen aus anderem ## Feldkombo: das neue Wesen bildet mit dem ersten eigenen Wesen aus anderem
## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus. ## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus.
func trigger_field_combos(p: Player, cr: Creature) -> void: func trigger_field_combos(p: Player, cr: Creature) -> void:
@@ -427,26 +417,6 @@ func trigger_field_combos(p: Player, cr: Creature) -> void:
return return
## Löst die erste passende Falle von owner aus. Gibt true zurück, wenn der
## auslösende Angriff abgebrochen wird.
func trigger_trap(owner: Player, trigger: String, source: Creature) -> bool:
for trap in owner.traps:
if trap.trigger != trigger:
continue
owner.traps.erase(trap)
say("Falle von %s: %s!" % [owner.name, trap.name])
emit({"type": "trap", "player": players.find(owner), "card": trap, "source": ref_of(source)})
var cancel := false
for fx in trap.effects:
if fx.kind == "cancel":
cancel = true
else:
resolve(owner, fx, null, source)
cleanup()
return cancel
return false
## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt. ## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt.
func _enter(p: Player, card: Cards.Card) -> Creature: func _enter(p: Player, card: Cards.Card) -> Creature:
var cr := Creature.new(card, p, next_uid) var cr := Creature.new(card, p, next_uid)
@@ -454,6 +424,9 @@ func _enter(p: Player, card: Cards.Card) -> Creature:
if "waechter_leben" in p.perks and cr.taunt(): if "waechter_leben" in p.perks and cr.taunt():
cr.health += 2 cr.health += 2
cr.max_health += 2 cr.max_health += 2
if "wesen_leben" in p.perks:
cr.health += 1
cr.max_health += 1
if "schild_wesen" in p.perks: if "schild_wesen" in p.perks:
cr.shield = true cr.shield = true
if "alle_flink" in p.perks: if "alle_flink" in p.perks:
@@ -464,14 +437,45 @@ func _enter(p: Player, card: Cards.Card) -> Creature:
return cr return cr
## Setzt ein Wesen aufs Feld (Fallen, Effekte). ## Prüft eine Verschmelzung: zwei eigene Wesen, genug Mana.
func summon(p: Player, card: Cards.Card) -> void: func can_fuse(p: Player, a: Creature, b: Creature) -> Array:
if p.board.size() >= MAX_BOARD: if winner != "" or p != current():
say("%s: kein Platz für %s." % [p.name, card.name]) return [false, "Nicht dein Zug"]
return if a == null or b == null or a == b:
_enter(p, card) return [false, "Zwei verschiedene Wesen wählen"]
say("%s beschwört %s." % [p.name, card.name]) if not (a in p.board) or not (b in p.board):
return [false, "Nur eigene Wesen"]
if p.energy < Cards.fusion_cost(a.card, b.card):
return [false, "Nicht genug Mana"]
return [true, ""]
## Verschmilzt zwei eigene Wesen zu einem. Es erbt Elemente und Schlüsselwörter beider
## Karten, seine Werte sind der abgerundete Durchschnitt plus 1. Kosten: Durchschnitt der
## Kartenkosten, aufgerundet. Eine Verschmelzung ist kein Ausspielen: sie löst keine
## Feldkombo aus und lädt das Feld nicht.
func fuse(p: Player, a: Creature, b: Creature) -> String:
var ok := can_fuse(p, a, b)
if not ok[0]:
return ok[1]
p.energy -= Cards.fusion_cost(a.card, b.card)
var card := Cards.fused(a.card, b.card, (a.attack + b.attack) / 2 + 1, (a.health + b.health) / 2 + 1)
var cr := Creature.new(card, p, next_uid)
next_uid += 1
cr.shield = cr.shield or a.shield or b.shield
cr.frozen = a.frozen or b.frozen
cr.ready = a.ready and b.ready
cr.attacks_left = 0 if not cr.ready else mini(a.attacks_left, b.attacks_left)
var at := p.board.find(a)
p.board.erase(a)
p.board.erase(b)
p.board.insert(mini(at, p.board.size()), cr)
say("%s und %s verschmelzen zu %s (%d/%d)." % [a.name, b.name, cr.name, cr.attack, cr.health])
emit({"type": "fuse", "player": players.find(p), "uid": cr.uid, "gone": [a.uid, b.uid]})
update_resonance() update_resonance()
cleanup()
check_end()
return ""
## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3. ## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3.
@@ -488,9 +492,10 @@ func update_resonance() -> void:
if o != c and o.card.elements.any(func(e): return e in c.card.elements): if o != c and o.card.elements.any(func(e): return e in c.card.elements):
bonus += 1 bonus += 1
c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1) c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1)
c.field_bonus = 1 if field_element != "" and field_element in c.card.elements else 0
func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus: int = 0) -> void: func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, elements: Array = []) -> void:
var enemies := enemies_of(p) var enemies := enemies_of(p)
var enemy_creatures := [] var enemy_creatures := []
for e in enemies: for e in enemies:
@@ -508,7 +513,7 @@ func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus
targets = p.board.duplicate() targets = p.board.duplicate()
elif fx.target == "self_hero": elif fx.target == "self_hero":
targets = [p] targets = [p]
elif fx.target == "self_creature" or fx.target == "trigger_source": elif fx.target == "self_creature":
targets = [source] if source != null else [] targets = [source] if source != null else []
elif fx.target == "strongest_enemy_creature": elif fx.target == "strongest_enemy_creature":
var best: Creature = null var best: Creature = null
@@ -527,13 +532,15 @@ func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus
for t in targets: for t in targets:
match fx.kind: match fx.kind:
"damage": "damage":
damage(t, fx.amount + bonus) damage(t, fx.amount + (Cards.matchup(elements, t.card.elements) if t is Creature else 0))
"heal": "heal":
t.life = mini(t.max_life, t.life + fx.amount) t.life = mini(t.max_life, t.life + fx.amount)
say("%s heilt sich um %d." % [t.name, fx.amount]) say("%s heilt sich um %d." % [t.name, fx.amount])
emit({"type": "heal", "target": ref_of(t), "amount": fx.amount}) emit({"type": "heal", "target": ref_of(t), "amount": fx.amount})
"draw": "mana":
draw(p, fx.amount) p.energy += fx.amount
say("%s erhält %d Mana." % [p.name, fx.amount])
emit({"type": "mana", "player": players.find(p), "amount": fx.amount})
"freeze": "freeze":
t.frozen = true t.frozen = true
t.attacks_left = 0 t.attacks_left = 0
@@ -558,8 +565,6 @@ func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, bonus
t.shield = true t.shield = true
say("%s erhält Schild." % t.name) say("%s erhält Schild." % t.name)
emit({"type": "buff", "target": ref_of(t), "text": "Schild"}) emit({"type": "buff", "target": ref_of(t), "text": "Schild"})
"summon":
summon(p, Cards.by_id(fx.card_id))
# --- Kampf ------------------------------------------------------------------- # --- Kampf -------------------------------------------------------------------
@@ -589,19 +594,21 @@ func attack(cr: Creature, target) -> String:
cr.attacks_left -= 1 cr.attacks_left -= 1
say("%s greift %s an." % [cr.name, target.name]) say("%s greift %s an." % [cr.name, target.name])
emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)}) emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)})
var tgt_owner: Player = target.owner() if target is Creature else target damage(target, cr.power() + matchup_bonus(cr, target))
var cancelled := trigger_trap(tgt_owner, "attacked", cr) if target is Creature and target.power() > 0: # wer nichts austeilt, kontert auch nicht
if cancelled or not (cr in cr.owner().board) or (target is Creature and not (target in tgt_owner.board)): damage(cr, target.power() + matchup_bonus(target, cr))
check_end()
return ""
damage(target, cr.power())
if target is Creature:
damage(cr, target.power())
cleanup() cleanup()
check_end() check_end()
return "" return ""
## Elementvorteil beim Zuschlagen: gegen Helden gibt es keinen.
static func matchup_bonus(source: Creature, target) -> int:
if not (target is Creature):
return 0
return Cards.matchup(source.card.elements, target.card.elements)
func damage(target, amount: int) -> void: func damage(target, amount: int) -> void:
if amount <= 0: if amount <= 0:
return return
@@ -622,9 +629,13 @@ func cleanup() -> void:
var died := false var died := false
for p in players: for p in players:
for c in p.board.duplicate(): for c in p.board.duplicate():
if c.health <= 0: # Wer besiegt ist, nimmt seine Wesen mit vom Tisch.
if c.health <= 0 or not p.alive():
p.board.erase(c) p.board.erase(c)
say("%s stirbt." % c.name) if c.health > 0:
say("%s verschwindet mit %s." % [c.name, p.name])
else:
say("%s stirbt." % c.name)
emit({"type": "death", "target": ref_of(c)}) emit({"type": "death", "target": ref_of(c)})
died = true died = true
if died: if died:
@@ -657,7 +668,7 @@ static func from_snapshot(d: Dictionary) -> Game:
return NetState.from_snapshot(d) return NetState.from_snapshot(d)
# --- Deckregeln und Gegner --------------------------------------------------- # --- Gegner und Kampfstart ---------------------------------------------------
static func shuffle(arr: Array, r: RandomNumberGenerator) -> void: static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
for i in range(arr.size() - 1, 0, -1): for i in range(arr.size() - 1, 0, -1):
@@ -667,81 +678,27 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
arr[j] = tmp arr[j] = tmp
static func deck_elements(cards: Array) -> Array: ## rules: {"human_perks": [...], "opponent_life": int, "looks": {...}}
var out := [] static func new_battle(n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
for c in cards:
for e in c.elements:
if not (e in out):
out.append(e)
return out
## Prüft ein Deck. owned: Dictionary id -> Anzahl (null = keine Besitzprüfung).
static func validate_deck(cards: Array, owned = null) -> Array:
if cards.size() != DECK_SIZE:
return [false, "Deck braucht genau %d Karten (%d)" % [DECK_SIZE, cards.size()]]
var counts := {}
for c in cards:
counts[c.id] = counts.get(c.id, 0) + 1
for cid in counts:
if counts[cid] > MAX_COPIES:
return [false, "Höchstens %dx dieselbe Karte" % MAX_COPIES]
if owned != null and owned.get(cid, 0) < counts[cid]:
return [false, "Karte nicht oft genug in der Sammlung"]
if deck_elements(cards).size() > MAX_DECK_ELEMENTS:
return [false, "Höchstens %d Elemente im Deck" % MAX_DECK_ELEMENTS]
return [true, ""]
## rules: {"human_perks": [...], "everyone_perk": String, "start_life": int, "target_rating": float, "tier": String}
# --- Deckstärke und Gegner (siehe DeckGen) --------------------------------------
static func deck_rating(cards: Array, stats: Dictionary = {}) -> float:
return DeckGen.deck_rating(cards, stats)
static func build_opponent_deck(r: RandomNumberGenerator, elements: Array, bias: float = 1.0) -> Array:
return DeckGen.build_opponent_deck(r, elements, bias)
static func build_opponent_deck_rated(r: RandomNumberGenerator, elements: Array, target: float, tolerance: float = 0.12) -> Array:
return DeckGen.build_opponent_deck_rated(r, elements, target, tolerance)
static func random_opponent(r: RandomNumberGenerator, index: int, life: int = START_LIFE, target: float = 0.0, tier: String = "") -> Player:
return DeckGen.random_opponent(r, index, life, target, tier)
static func archetypes(tier: String = "") -> Array:
return DeckGen.archetypes(tier)
static func new_battle(deck: Array, n_opp: int, r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner") assert(n_opp >= 1 and n_opp <= MAX_OPPONENTS, "1 bis 5 Gegner")
if r == null: if r == null:
r = make_rng() r = make_rng()
var target: float = float(rules.get("target_rating", 0.0)) var life: int = int(rules.get("opponent_life", START_LIFE))
var tier: String = str(rules.get("tier", ""))
var opps := [] var opps := []
for i in n_opp: for i in n_opp:
opps.append(random_opponent(r, i + 1, START_LIFE, target, tier)) opps.append(DeckGen.random_opponent(i + 1, life))
return new_battle_with(deck, opps, r, false, rules) return new_battle_with(opps, r, false, rules)
static func new_battle_with(deck: Array, opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game: static func new_battle_with(opps: Array, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game:
if r == null: if r == null:
r = make_rng() r = make_rng()
var human := Player.new("Du", deck, true, START_LIFE) var human := Player.new("Spieler", true, START_LIFE)
human.perks = Array(rules.get("human_perks", [])) human.perks = Array(rules.get("human_perks", []))
human.looks = rules.get("looks", {})
var g := Game.new(human, opps, r) var g := Game.new(human, opps, r)
var everyone: String = rules.get("everyone_perk", "")
for p in g.players: for p in g.players:
if everyone != "" and not (everyone in p.perks): if "leben_35" in p.perks and p.life == START_LIFE:
p.perks.append(everyone)
if rules.has("start_life"):
p.life = int(rules.start_life)
p.max_life = p.life
elif "leben_35" in p.perks and p.life == START_LIFE:
p.life = 35 p.life = 35
p.max_life = 35 p.max_life = 35
g.start(fixed_order) g.start(fixed_order)

View File

@@ -17,9 +17,9 @@ var peer: ENetMultiplayerPeer
var is_host := false var is_host := false
var connected := false var connected := false
var my_name := "Spieler" var my_name := "Spieler"
var my_deck: Array = [] # Karten-IDs var my_pick := {"looks": {}} # eigene Kartenoptik (Seltenheit), die der Gegner sieht
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie) var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
var decks := {} # peer_id -> Karten-IDs (Host) var picks := {} # peer_id -> {"looks": {...}} (Host)
var seats := {} # peer_id -> Spielerindex (Host) var seats := {} # peer_id -> Spielerindex (Host)
var my_seat := -1 var my_seat := -1
var game: Game # Host var game: Game # Host
@@ -56,7 +56,7 @@ func host(port: int = PORT) -> Error:
is_host = true is_host = true
connected = true connected = true
names = {1: my_name} names = {1: my_name}
decks = {1: my_deck} picks = {1: my_pick}
spectators = {} spectators = {}
status = "Warte auf Mitspieler …" status = "Warte auf Mitspieler …"
lobby_changed.emit() lobby_changed.emit()
@@ -83,7 +83,7 @@ func leave() -> void:
is_host = false is_host = false
connected = false connected = false
names = {} names = {}
decks = {} picks = {}
seats = {} seats = {}
waiting = {} waiting = {}
my_seat = -1 my_seat = -1
@@ -111,7 +111,7 @@ func _fail(reason: String) -> void:
func _on_connected() -> void: func _on_connected() -> void:
connected = true connected = true
status = "Verbunden" status = "Verbunden"
register.rpc_id(1, my_name, my_deck, spectator) register.rpc_id(1, my_name, my_pick, spectator)
# --- Host-Wechsel --------------------------------------------------------------- # --- Host-Wechsel ---------------------------------------------------------------
@@ -230,7 +230,7 @@ func _on_peer_disconnected(id: int) -> void:
return return
var pname: String = names.get(id, "") var pname: String = names.get(id, "")
names.erase(id) names.erase(id)
decks.erase(id) picks.erase(id)
spectators.erase(id) spectators.erase(id)
if game != null and seats.has(id): if game != null and seats.has(id):
var p: Game.Player = game.players[seats[id]] var p: Game.Player = game.players[seats[id]]
@@ -256,17 +256,19 @@ func tick() -> void:
waiting.erase(pname) waiting.erase(pname)
game.say("%s kehrt nicht zurück und scheidet aus." % p.name) game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
game.damage(p, 999) game.damage(p, 999)
game.cleanup()
game.check_end() game.check_end()
if game.winner == "" and game.current() == p:
game.end_turn() # sonst bliebe das Spiel beim Ausgeschiedenen hängen
action_applied.emit() action_applied.emit()
elif game.current() == p: elif game.current() == p:
p.mulligan_done = true
game.say("%s ist nicht da, der Zug wird übersprungen." % p.name) game.say("%s ist nicht da, der Zug wird übersprungen." % p.name)
game.end_turn() game.end_turn()
action_applied.emit() action_applied.emit()
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void: func register(name: String, pick: Dictionary, as_spectator: bool = false) -> void:
if not is_host: if not is_host:
return return
var id := multiplayer.get_remote_sender_id() var id := multiplayer.get_remote_sender_id()
@@ -293,7 +295,7 @@ func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void
status = "%s wollte beitreten, das Spiel läuft schon" % name status = "%s wollte beitreten, das Spiel läuft schon" % name
return return
names[id] = name names[id] = name
decks[id] = deck_ids picks[id] = pick
lobby_update.rpc(names, spectators) lobby_update.rpc(names, spectators)
lobby_changed.emit() lobby_changed.emit()
@@ -313,18 +315,14 @@ func start_battle(n_ai: int) -> void:
var players := [] var players := []
seats = {} seats = {}
for id in ids: for id in ids:
var deck_ids: Array = decks.get(id, []) var pick: Dictionary = picks.get(id, {})
var deck := [] var p := Game.Player.new(names[id], true, Game.START_LIFE)
for cid in deck_ids: for cid in pick.get("looks", {}):
if Cards.has_id(cid): if Cards.has_id(cid):
deck.append(Cards.by_id(cid)) p.looks[cid] = int(pick.looks[cid])
if Game.validate_deck(deck)[0] == false: players.append(p)
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: for i in n_ai:
players.append(Game.random_opponent(r, i + 1)) players.append(DeckGen.random_opponent(i + 1))
game = Game.new(players[0], players.slice(1), r) game = Game.new(players[0], players.slice(1), r)
game.start() game.start()
for id in ids: for id in ids:
@@ -398,12 +396,15 @@ func _apply(id: int, action: Dictionary) -> void:
var t = resolve_ref(action.get("target")) var t = resolve_ref(action.get("target"))
if cr != null and t != null: if cr != null and t != null:
game.attack(cr, t) game.attack(cr, t)
"mulligan": "buy":
var cards := [] game.buy(p, int(action.get("slot", -1)))
for i in action.get("hand", []): "reroll":
if int(i) >= 0 and int(i) < p.hand.size(): game.reroll(p)
cards.append(p.hand[int(i)]) "fuse":
game.mulligan(p, cards) var a := game.find_creature(int(action.get("uid", -1)))
var b := game.find_creature(int(action.get("other", -1)))
if a != null and b != null:
game.fuse(p, a, b)
"end": "end":
game.end_turn() game.end_turn()
_: _:

View File

@@ -2,8 +2,17 @@ class_name NetState
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln). ## 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. ## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände nur als Anzahl, fremde Läden gar nicht.
## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer). ## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer).
## Karten reisen als ID; verschmolzene stehen nicht im Pool und gehen als Ganzes mit.
static func card_ref(c: Cards.Card):
return c.id if Cards.has_id(c.id) else c.to_dict()
static func card_of(ref) -> Cards.Card:
return Cards.Card.from_dict(ref) if ref is Dictionary else Cards.by_id(ref)
static func snapshot(g: Game, viewer: int) -> Dictionary: static func snapshot(g: Game, viewer: int) -> Dictionary:
var ps := [] var ps := []
for i in g.players.size(): for i in g.players.size():
@@ -11,25 +20,26 @@ static func snapshot(g: Game, viewer: int) -> Dictionary:
var mine := i == viewer or viewer == -1 var mine := i == viewer or viewer == -1
var board := [] var board := []
for c in p.board: for c in p.board:
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance, board.append({"uid": c.uid, "card": card_ref(c.card),
"attack": c.attack, "resonance": c.resonance, "field_bonus": c.field_bonus,
"health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen, "health": c.health, "max_health": c.max_health, "shield": c.shield, "frozen": c.frozen,
"ready": c.ready, "attacks_left": c.attacks_left}) "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, 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, "energy": p.energy, "max_energy": p.max_energy, "start_bonus": p.start_bonus,
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done, "order": p.order, "perks": p.perks, "elements": p.elements, "looks": p.looks, "turns": p.turns,
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(), "hand": p.hand.map(card_ref) if mine else p.hand.size(),
"deck": p.deck.map(func(c): return c.id) if viewer == -1 else p.deck.size(), "shop": p.shop.map(func(c): return c.id if c != null else "") if mine else [],
"traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
"board": board}) "board": board})
var evs := [] var evs := []
for ev in g.events: for ev in g.events:
var e: Dictionary = ev.duplicate() var e: Dictionary = ev.duplicate()
if e.has("card"): if e.has("card"):
e["card"] = e.card.id e["card"] = card_ref(e.card)
evs.append(e) evs.append(e)
return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx, 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, "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} "next_uid": g.next_uid, "field_element": g.field_element, "field_charge": g.field_charge,
"log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs}
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt. ## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
@@ -37,38 +47,30 @@ static func from_snapshot(d: Dictionary) -> Game:
var placeholder: Cards.Card = Cards.all()[0] var placeholder: Cards.Card = Cards.all()[0]
var list := [] var list := []
for pd in d.players: for pd in d.players:
var p := Game.Player.new(pd.name, [], pd.is_human, int(pd.life), pd.title) var p := Game.Player.new(pd.name, pd.is_human, int(pd.life), pd.title, Array(pd.get("elements", [])))
p.max_life = int(pd.max_life) p.max_life = int(pd.max_life)
p.energy = int(pd.energy) p.energy = int(pd.energy)
p.max_energy = int(pd.max_energy) p.max_energy = int(pd.max_energy)
p.fatigue = int(pd.fatigue)
p.start_bonus = int(pd.start_bonus) p.start_bonus = int(pd.start_bonus)
p.order = int(pd.order) p.order = int(pd.order)
p.perks = Array(pd.perks) p.perks = Array(pd.perks)
for cid in pd.get("looks", {}):
p.looks[cid] = int(pd.looks[cid])
p.turns = int(pd.turns) p.turns = int(pd.turns)
p.mulligan_done = bool(pd.mulligan_done)
if pd.hand is Array: if pd.hand is Array:
for cid in pd.hand: for ref in pd.hand:
p.hand.append(Cards.by_id(cid)) p.hand.append(card_of(ref))
else: else:
for i in int(pd.hand): for i in int(pd.hand):
p.hand.append(placeholder) p.hand.append(placeholder)
if pd.deck is Array: for cid in pd.get("shop", []):
for cid in pd.deck: p.shop.append(Cards.by_id(cid) if str(cid) != "" else null)
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: for cd in pd.board:
var c := Game.Creature.new(Cards.by_id(cd.card), p, int(cd.uid)) var card := card_of(cd.card)
var c := Game.Creature.new(card, p, int(cd.uid))
c.attack = int(cd.attack) c.attack = int(cd.attack)
c.resonance = int(cd.resonance) c.resonance = int(cd.resonance)
c.field_bonus = int(cd.get("field_bonus", 0))
c.health = int(cd.health) c.health = int(cd.health)
c.max_health = int(cd.max_health) c.max_health = int(cd.max_health)
c.shield = bool(cd.shield) c.shield = bool(cd.shield)
@@ -85,16 +87,19 @@ static func from_snapshot(d: Dictionary) -> Game:
g.winner = d.winner g.winner = d.winner
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
g.next_uid = int(d.next_uid) g.next_uid = int(d.next_uid)
g.field_element = str(d.get("field_element", ""))
for e in Cards.ELEMENTS:
g.field_charge[e] = int(d.get("field_charge", {}).get(e, 0))
for line in d.log: for line in d.log:
g.log.append(str(line)) g.log.append(str(line))
for ev in d.events: for ev in d.events:
var e: Dictionary = ev.duplicate() var e: Dictionary = ev.duplicate()
if e.has("card"): if e.has("card"):
e["card"] = Cards.by_id(e.card) e["card"] = card_of(e.card)
for key in ["player", "uid", "count", "amount"]: for key in ["player", "uid", "amount"]:
if e.has(key): if e.has(key):
e[key] = int(e[key]) e[key] = int(e[key])
for key in ["target", "attacker", "source"]: for key in ["target", "attacker"]:
if e.has(key): if e.has(key):
var r: Dictionary = e[key] var r: Dictionary = e[key]
if r.has("uid"): if r.has("uid"):

View File

@@ -1,90 +1,39 @@
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, Kampagne, Einstellungen.
## Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die
## ist reine Optik: sie ändert weder Werte noch Text einer Karte.
const SAVE_VERSION := 5 const SAVE_VERSION := 6
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
const SAVE_PATH := "user://spielstand.json" const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100 const BOOSTER_COST := 100
const ELEMENT_BOOSTER_COST := 120 # nur Karten eines Elements (auch seine Kombos)
const CODE_PREFIX := "DINGER1:" # Deck-Code zum Teilen
const BOOSTER_SIZE := 5 const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5} const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
const START_COINS := 150 const START_COINS := 150
const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300} const REWARD_WIN := {1: 100, 2: 150, 3: 200, 4: 250, 5: 300}
const REWARD_LOSS := 50 const REWARD_LOSS := 50
const DECK_SLOTS := 5 # Schwierigkeit: Leben der Gegner, KI und Münzen.
# Schwierigkeit: Faktor auf die Zielstärke der Gegnerdecks, Belohnung und KI-Tiefe.
const DIFFICULTIES := { const DIFFICULTIES := {
"leicht": {"name": "Leicht", "factor": 0.9, "reward": 0.75, "smart": false, "tier": "leicht"}, "leicht": {"name": "Leicht", "life": 25, "reward": 0.75, "smart": false},
"normal": {"name": "Normal", "factor": 1.0, "reward": 1.0, "smart": true, "tier": ""}, "normal": {"name": "Normal", "life": 30, "reward": 1.0, "smart": true},
"schwer": {"name": "Schwer", "factor": 1.2, "reward": 1.5, "smart": true, "tier": "schwer"}, "schwer": {"name": "Schwer", "life": 35, "reward": 1.5, "smart": true},
} }
# 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 := {"gewöhnlich": 5, "selten": 20, "episch": 100, "legendär": 400, "mythisch": 1600}
const DUST_CRAFT := {"gewöhnlich": 40, "selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200} const DUST_UPGRADE := {"selten": 100, "episch": 400, "legendär": 1600, "mythisch": 3200}
const DAILY_TASKS := [
{"id": "sieg", "kind": "win", "text": "Gewinne einen Kampf", "goal": 1, "reward": 80},
{"id": "kaempfe", "kind": "game", "text": "Bestreite zwei Kämpfe", "goal": 2, "reward": 60},
{"id": "booster", "kind": "booster", "text": "Öffne einen Booster", "goal": 1, "reward": 40},
{"id": "wesen", "kind": "creature_played", "text": "Spiele fünf Wesen", "goal": 5, "reward": 50},
{"id": "falle", "kind": "trap_set", "text": "Lege eine Falle", "goal": 1, "reward": 50},
{"id": "kombo", "kind": "combo", "text": "Löse zwei Feldkombos aus", "goal": 2, "reward": 60},
{"id": "herstellen", "kind": "craft", "text": "Stelle eine Karte her", "goal": 1, "reward": 50},
{"id": "tisch", "kind": "win_vs3", "text": "Gewinne gegen 3 oder mehr Gegner", "goal": 1, "reward": 120},
{"id": "feuer", "kind": "win_element:Feuer", "text": "Gewinne mit einem Feuer-Deck", "goal": 1, "reward": 90},
{"id": "wasser", "kind": "win_element:Wasser", "text": "Gewinne mit einem Wasser-Deck", "goal": 1, "reward": 90},
{"id": "erde", "kind": "win_element:Erde", "text": "Gewinne mit einem Erde-Deck", "goal": 1, "reward": 90},
{"id": "luft", "kind": "win_element:Luft", "text": "Gewinne mit einem Luft-Deck", "goal": 1, "reward": 90},
]
static var today_override := "" # für Tests
var coins := 0 var coins := 0
var dust := 0 var dust := 0
var collection := {} # id -> Anzahl var collection := {} # id -> Seltenheitsstufe (Index in Cards.RARITIES), fehlend = gewöhnlich
var decks: Array = [] # DECK_SLOTS Einträge {"name": String, "cards": Array von ids} var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
var active_deck := 0
var stats := {"games": 0, "wins": 0, "boosters": 0, "wins_by_opp": {}}
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
var daily := {"date": "", "task": "", "progress": 0, "done": false}
var settings := {"volume": 0.8, "music": 0.5, "ai_speed": 1.0, "animations": true, "language": "de", "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 (Tagesaufgabe, Fortschritt) var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
var card_stats := {} # id -> {"played", "games", "wins"} var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
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": []})
static func starter() -> Profile: static func starter() -> Profile:
var p := Profile.new() var p := Profile.new()
p.coins = START_COINS p.coins = START_COINS
for c in Cards.of_rarity("gewöhnlich"):
if c.id.begins_with("f_") or c.id.begins_with("w_") or c.id.begins_with("e_") or c.id.begins_with("l_"):
p.collection[c.id] = 2
# Zwei seltene Karten zum Start, damit es etwas zum Aufbauen gibt
p.collection["f_drache"] = 1
p.collection["e_baum"] = 1
var starter_ids := ["f_funke", "f_kobold", "f_wolf", "f_feuerball", "f_asche",
"e_wall", "e_wurzel", "e_haerten", "e_golem", "e_troll"]
for cid in starter_ids:
p.deck.append(cid)
p.deck.append(cid)
p.deck[p.deck.find("f_asche")] = "f_drache"
p.deck[p.deck.find("e_troll")] = "e_baum"
return p return p
@@ -102,71 +51,34 @@ static func load_or_new(path: String = SAVE_PATH) -> Profile:
return p return p
## Liest jede Spielstand-Version; fehlende Felder bekommen Standardwerte (Migration). ## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und
## Einstellungen, die alte Sammlung (Anzahl je Karte) hat keine Entsprechung mehr.
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))
var coll = d.get("collection", {}) p.campaign_stage = int(d.get("campaign_stage", 0))
for cid in coll: if int(d.get("version", 1)) >= SAVE_VERSION:
if Cards.has_id(cid): var coll = d.get("collection", {})
p.collection[cid] = int(coll[cid]) for cid in coll:
if version >= 3:
var list = d.get("decks", [])
for i in mini(list.size(), DECK_SLOTS):
p.decks[i].name = str(list[i].get("name", "Deck %d" % (i + 1)))
p.decks[i].cards = []
for cid in list[i].get("cards", []):
if Cards.has_id(cid):
p.decks[i].cards.append(cid)
p.active_deck = clampi(int(d.get("active_deck", 0)), 0, DECK_SLOTS - 1)
else:
for cid in d.get("deck", []):
if Cards.has_id(cid): if Cards.has_id(cid):
p.decks[0].cards.append(cid) p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
if version >= 2: var se = d.get("settings", {})
var st = d.get("stats", {}) p.settings = {"volume": float(se.get("volume", 0.8)), "music": float(se.get("music", 0.5)),
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))}
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 return p
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection,
"decks": decks, "active_deck": active_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.
@@ -185,363 +97,114 @@ func take_notices() -> Array[String]:
return out return out
# --- Decks und Sammlung ----------------------------------------------------------- # --- Sammlung: Seltenheit als Aussehen --------------------------------------------
func deck_cards(slot: int = -1) -> Array: func level(cid: String) -> int:
var ids: Array = decks[slot if slot >= 0 else active_deck].cards return int(collection.get(cid, 0))
var out := []
for cid in ids:
out.append(Cards.by_id(cid)) func rarity(cid: String) -> String:
return Cards.RARITIES[level(cid)]
## Karten-ID -> Stufe für den Kampf (nur aufgewertete Karten, siehe Game.Player.looks).
func looks() -> Dictionary:
var out := {}
for cid in collection:
if int(collection[cid]) > 0:
out[cid] = int(collection[cid])
return out return out
func deck_valid(slot: int = -1) -> Array: ## Anzahl Karten über der gewöhnlichen Stufe.
return Game.validate_deck(deck_cards(slot), collection) func upgraded_count() -> int:
func deck_name(slot: int = -1) -> String:
return decks[slot if slot >= 0 else active_deck].name
func rename_deck(slot: int, name: String) -> String:
var n := name.strip_edges()
if n.is_empty() or n.length() > 20:
return "Name muss 1 bis 20 Zeichen haben"
decks[slot].name = n
return ""
## Füllt einen Slot mit eigenen Karten auf 20 auf, entlang einer Energiekurve.
## Ohne Karten im Slot werden die beiden Elemente mit den meisten eigenen Karten gewählt.
func autofill_deck(slot: int) -> String:
var cards: Array = decks[slot].cards
var elems := Game.deck_elements(deck_cards(slot))
if elems.is_empty():
var count := {}
for c in owned_cards():
for e in c.elements:
count[e] = count.get(e, 0) + 1
var ranked: Array = count.keys()
ranked.sort_custom(func(a, b): return count[a] > count[b])
elems = ranked.slice(0, Game.MAX_DECK_ELEMENTS)
var pool := owned_cards().filter(func(c): return c.elements.all(func(e): return e in elems))
pool.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
var counts := {}
for cid in cards:
counts[cid] = counts.get(cid, 0) + 1
var progressed := true
while cards.size() < Game.DECK_SIZE and progressed:
progressed = false
for cost in range(1, 11):
if cards.size() >= Game.DECK_SIZE:
break
for c in pool:
if c.cost != cost:
continue
var have: int = counts.get(c.id, 0)
if have < mini(collection.get(c.id, 0), Game.MAX_COPIES):
cards.append(c.id)
counts[c.id] = have + 1
progressed = true
break
var ok := Game.validate_deck(deck_cards(slot), collection)
return "" if ok[0] else ok[1]
func owned_cards() -> Array:
return Cards.all().filter(func(c): return collection.get(c.id, 0) > 0)
func distinct_owned() -> int:
var n := 0 var n := 0
for cid in collection: for cid in collection:
if collection[cid] > 0: if int(collection[cid]) > 0:
n += 1 n += 1
return n return n
# --- Zerlegen und Herstellen ------------------------------------------------------ ## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
func upgrade(cid: String) -> String:
## Zerlegt n Kopien in Staub. Gibt "" zurück oder eine Fehlermeldung. var lvl := level(cid)
func dismantle(cid: String, n: int = 1) -> String: if lvl >= Cards.RARITIES.size() - 1:
var owned: int = collection.get(cid, 0) return "Schon auf der höchsten Stufe"
if owned < n: var cost: int = DUST_UPGRADE[Cards.RARITIES[lvl + 1]]
return "Nicht genug Kopien"
var needed := 0
for d in decks:
needed = maxi(needed, d.cards.count(cid))
if owned - n < needed:
return "Wird in einem Deck gebraucht"
collection[cid] = owned - n
dust += DUST_DISMANTLE[Cards.by_id(cid).rarity] * n
return ""
## Stellt eine Karte aus Staub her. Gibt "" zurück oder eine Fehlermeldung.
func craft(cid: String) -> String:
var cost: int = DUST_CRAFT[Cards.by_id(cid).rarity]
if dust < cost: if dust < cost:
return "Nicht genug Staub (%d nötig)" % cost return "Nicht genug Staub (%d nötig)" % cost
dust -= cost dust -= cost
collection[cid] = collection.get(cid, 0) + 1 collection[cid] = lvl + 1
daily_event("craft")
return "" return ""
## Zerlegt alle Kopien über dem Deck-Limit. Gibt den gewonnenen Staub zurück. ## Gibt eine Stufe ab und bekommt Staub dafür. Gibt "" zurück oder eine Fehlermeldung.
func dismantle_extras() -> int: func dismantle(cid: String) -> String:
var before := dust var lvl := level(cid)
for cid in collection.keys(): if lvl == 0:
var extra: int = collection[cid] - Game.MAX_COPIES return "Gewöhnliche Karten lassen sich nicht zerlegen"
if extra > 0: dust += DUST_DISMANTLE[Cards.RARITIES[lvl]]
dismantle(cid, extra) collection[cid] = lvl - 1
return dust - before return ""
# --- Booster ---------------------------------------------------------------------- # --- Booster ----------------------------------------------------------------------
## Kartenpool einer Seltenheit, bei Elementboostern nur Karten mit diesem Element. ## Fünf Karten mit gewürfelter Seltenheit.
static func booster_pool(rarity: String, element: String) -> Array: static func roll_booster(r: RandomNumberGenerator) -> Array:
var pool := Cards.of_rarity(rarity)
if element != "":
pool = pool.filter(func(c): return element in c.elements)
return pool
static func roll_booster(r: RandomNumberGenerator, element: String = "") -> Array:
var rarities := RARITY_WEIGHTS.keys() var rarities := RARITY_WEIGHTS.keys()
var weights := PackedFloat32Array(RARITY_WEIGHTS.values()) var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
var cards := [] var pool := Cards.all()
for i in BOOSTER_SIZE:
var rarity: String = rarities[r.rand_weighted(weights)]
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
and cards.all(func(c): return c.rarity == "gewöhnlich"):
rarity = "selten" # mindestens eine seltene Karte pro Booster
var pool := booster_pool(rarity, element)
cards.append(pool[r.randi_range(0, pool.size() - 1)])
return cards
## Deck-Code zum Teilen: "DINGER1:id*anzahl,…" (sortiert, daher vergleichbar).
static func deck_code(card_ids: Array) -> String:
var counts := {}
for cid in card_ids:
counts[cid] = counts.get(cid, 0) + 1
var keys := counts.keys()
keys.sort()
return CODE_PREFIX + ",".join(keys.map(func(k): return "%s*%d" % [k, counts[k]]))
## Karten-IDs aus einem Deck-Code; leer bei ungültigem Code.
static func parse_deck_code(code: String) -> Array:
var s := code.strip_edges()
if not s.begins_with(CODE_PREFIX):
return []
var out := [] var out := []
for part in s.substr(CODE_PREFIX.length()).split(",", false): for i in BOOSTER_SIZE:
var kv: PackedStringArray = part.strip_edges().split("*") var rar: String = rarities[r.rand_weighted(weights)]
if kv.size() != 2 or not Cards.has_id(kv[0]) or not kv[1].is_valid_int(): if i == BOOSTER_SIZE - 1 and out.all(func(x): return x.rarity == "gewöhnlich"):
return [] rar = "selten" # mindestens eine seltene Karte pro Booster
for i in clampi(int(kv[1]), 0, Game.MAX_COPIES): out.append({"card": pool[r.randi_range(0, pool.size() - 1)], "rarity": rar})
out.append(kv[0])
return out return out
## Öffnet einen Booster (element: Elementbooster). Gibt die Karten zurück, leer wenn die Münzen nicht reichen. ## Öffnet einen Booster. Höhere Stufen werten die Karte auf, alles andere wird zu Staub.
func open_booster(r: RandomNumberGenerator = null, element: String = "") -> Array: ## Gibt die Ergebnisse zurück, leer wenn die Münzen nicht reichen.
var cost := ELEMENT_BOOSTER_COST if element != "" else BOOSTER_COST func open_booster(r: RandomNumberGenerator = null) -> Array:
if coins < cost: if coins < BOOSTER_COST:
return [] return []
if r == null: if r == null:
r = Game.make_rng() r = Game.make_rng()
coins -= cost coins -= BOOSTER_COST
var cards := roll_booster(r, element) var out := roll_booster(r)
booster_since_mythic += 1 for res in out:
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch") var lvl: int = Cards.RARITIES.find(res.rarity)
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic: if lvl > level(res.card.id):
var pool := booster_pool("mythisch", element) collection[res.card.id] = lvl
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)] res["upgraded"] = true
has_mythic = true res["dust"] = 0
if has_mythic: else:
booster_since_mythic = 0 res["upgraded"] = false
for c in cards: res["dust"] = DUST_DISMANTLE[res.rarity]
collection[c.id] = collection.get(c.id, 0) + 1 dust += res.dust
stats.boosters += 1 return out
daily_event("booster")
return cards
# --- Kampfergebnis, Tagesaufgabe ------------------------------------------- # --- Kampf und Kampagne -----------------------------------------------------------
## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück. ## Verbucht ein Kampfergebnis und gibt die Münzbelohnung zurück.
## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei. ## stage >= 0: Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei.
## turns: Dauer der Partie für die Bestenliste. func record_battle(won: bool, n_opp: int, stage: int = -1, reward_factor: float = 1.0) -> int:
func record_battle(won: bool, n_opp: int, deck_used: Array, stage: int = -1, played_ids: Array = [],
turns: int = 0, reward_factor: float = 1.0, remaining_life: int = -1) -> int:
stats.games += 1
records.margins.append(maxi(remaining_life, 0) if won else 0)
while records.margins.size() > 5:
records.margins.pop_front()
if won:
records.streak += 1
records.loss_streak = 0
records.best_streak = maxi(records.best_streak, records.streak)
if turns > 0 and (records.fastest_win == 0 or turns < records.fastest_win):
records.fastest_win = turns
else:
records.streak = 0
records.loss_streak += 1
var tip := suggest_difficulty()
if tip != "" and stage < 0:
notices.append("Tipp: %s. Schwierigkeit %s?" % [
"%d Siege in Folge" % records.streak if won else "%d Niederlagen in Folge" % records.loss_streak, DIFFICULTIES[tip].name])
for cid in played_ids:
_stat(cid).played += 1
var seen := {}
for c in deck_used:
if not seen.has(c.id):
seen[c.id] = true
_stat(c.id).games += 1
if won:
_stat(c.id).wins += 1
var reward := REWARD_LOSS var reward := REWARD_LOSS
if won: if won:
stats.wins += 1
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)) reward = int(round(REWARD_WIN[n_opp] * reward_factor))
if stage >= 0 and stage == campaign_stage: if stage >= 0 and stage == campaign_stage:
campaign_stage += 1 campaign_stage += 1
reward = Campaign.stage(stage).reward reward = Campaign.stage(stage).reward
notices.append("Kampagne: %s besiegt!" % Campaign.stage(stage).name) notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name)
coins += reward coins += reward
daily_event("game")
if won:
daily_event("win")
if n_opp >= 3:
daily_event("win_vs3")
for e in Game.deck_elements(deck_used):
daily_event("win_element:" + e)
return reward return reward
func _stat(cid: String) -> Dictionary:
if not card_stats.has(cid):
card_stats[cid] = {"played": 0, "games": 0, "wins": 0}
return card_stats[cid]
func difficulty() -> Dictionary: func difficulty() -> Dictionary:
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal) return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
## Vorschlag: rauf nach drei deutlichen Siegen in Folge (im Schnitt mindestens 12 Leben übrig) oder fünf
## Siegen egal wie knapp; runter nach drei Niederlagen in Folge. "" = kein Vorschlag.
func suggest_difficulty() -> String:
var order := ["leicht", "normal", "schwer"]
var i := order.find(str(settings.get("difficulty", "normal")))
if i < order.size() - 1 and records.streak >= 3:
var last: Array = records.margins.slice(maxi(0, records.margins.size() - 3))
var avg := 0.0
for m in last:
avg += float(m) / last.size()
if records.streak >= 5 or (records.margins.size() >= 3 and avg >= 12.0):
return order[i + 1]
if records.loss_streak >= 3 and i > 0:
return order[i - 1]
return ""
## Eigene Deckstärke, mit Kartenstatistik gewichtet.
func deck_rating(cards: Array = []) -> float:
var deck_used: Array = cards if not cards.is_empty() else deck_cards()
return Game.deck_rating(deck_used, card_stats)
## Zielstärke der Gegnerdecks: eigene Deckstärke mal Schwierigkeitsfaktor.
func target_rating(cards: Array = []) -> float:
return deck_rating(cards) * float(difficulty().factor)
## Tauscht die schwächste Karte des aktiven Decks gegen cid, wenn das Deck dadurch stärker und gültig bleibt.
## dry_run: nur prüfen. Gibt "" zurück oder den Grund, warum nicht.
func improve_deck_with(cid: String, dry_run: bool = false) -> String:
if collection.get(cid, 0) <= 0:
return "Karte nicht in der Sammlung"
var card := Cards.by_id(cid)
if deck.count(cid) >= mini(collection[cid], Game.MAX_COPIES):
return "Schon so oft im Deck wie möglich"
var elems := Game.deck_elements(deck_cards())
for e in card.elements:
if not (e in elems):
elems.append(e)
if elems.size() > Game.MAX_DECK_ELEMENTS:
return "Passt nicht zu den Elementen des Decks"
var weakest := ""
var weakest_value := Cards.card_value(card)
for other in deck:
if other == cid:
continue
var v := Cards.card_value(Cards.by_id(other))
if v < weakest_value:
weakest_value = v
weakest = other
if weakest == "":
return "Keine schwächere Karte im Deck"
if dry_run:
return ""
var trial: Array = deck.duplicate()
trial[trial.find(weakest)] = cid
var ok := Game.validate_deck(trial.map(func(id): return Cards.by_id(id)), collection)
if not ok[0]:
return ok[1]
deck = trial
return ""
## Karten aus new_ids, die das aktive Deck verbessern würden.
func deck_suggestions(new_ids: Array) -> Array:
var out := []
for cid in new_ids:
if not (cid in out) and improve_deck_with(cid, true) == "":
out.append(cid)
return out
func perk_unlocked() -> bool: func perk_unlocked() -> bool:
return campaign_stage >= Campaign.STAGES.size() return campaign_stage >= Campaign.STAGES.size()
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

View File

@@ -46,24 +46,16 @@ func play_events(evs: Array, centers: Dictionary) -> void:
popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1) popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1)
"play": "play":
sfx("play") sfx("play")
"spell": "fuse":
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("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
screen.show_preview(ev.card, true) "field":
screen.preview_timer = 1.8 sfx("combo")
"mulligan": popup("Feld: %s" % Cards.FIELD_NAMES[ev.element], Cards.ELEMENT_COLORS[ev.element], Vector2(640, 196), 0.0)
if ev.count > 0: "buy":
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") # die Karte landet sichtbar auf der Hand, das reicht
"reroll":
sfx("click")
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2: func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
@@ -73,9 +65,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 +91,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

View File

@@ -1,11 +1,12 @@
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 Laden, 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 überspringt Gegnerzüge,
## X macht rückgängig.
var screen # BattleScreen var screen # BattleScreen
var active := false var active := false
var zone := "hand" var zone := "shop"
var index := 0 var index := 0
var targets: Array = [] var targets: Array = []
@@ -26,7 +27,7 @@ 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 = "shop"
JOY_BUTTON_Y: JOY_BUTTON_Y:
if screen.b_skip.visible: if screen.b_skip.visible:
screen.skip_ai = true screen.skip_ai = true
@@ -41,16 +42,35 @@ 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 := ["shop", "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:
"shop":
return screen.shop_views
"hand": "hand":
return screen.hand_views return screen.hand_views
"own": "own":
if screen.fusing: # zum Verschmelzen zählt jedes eigene Wesen, nicht nur angriffsbereite
return screen.own_views
return screen.own_views.filter(func(v): return v.creature.can_attack()) return screen.own_views.filter(func(v): return v.creature.can_attack())
"target": "target":
return targets return targets
@@ -64,9 +84,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,6 +95,8 @@ 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:
"shop":
screen.on_click("shop", int(item.get_meta("slot", 0)))
"hand": "hand":
screen.on_click("hand", item.card) screen.on_click("hand", item.card)
if screen.pending != null: if screen.pending != null:
@@ -113,8 +132,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.

View File

@@ -30,11 +30,13 @@ static func other_slot(n_others: int, i: int, j: int) -> Rect2:
return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1]) return Rect2(bx + j * (sz[0] + sz[2]), 84, sz[0], sz[1])
## Laden: fünf Angebote über der Hand.
static func shop_rect(i: int) -> Rect2:
return Rect2(340 + i * 121, 487, 115, 112)
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 total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
var x := (1280 - total) / 2 var x := (1280 - total) / 2
return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H) return Rect2(x + i * (HAND_W + HAND_GAP), 800 - HAND_H - 8, HAND_W, HAND_H)
static func trap_rect(i: int) -> Rect2:
return Rect2(400 + i * 78, 490, 70, 98)

View File

@@ -1,63 +0,0 @@
class_name BattleMulligan
extends Control
## Mulligan-Maske: Karten anklicken, behalten oder tauschen.
signal decided(indexes: Array)
var layer: Control
var b_keep: Button
var b_swap: Button
var selected: Array = []
func _init() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
visible = false
var dark := ColorRect.new()
dark.color = Color(0, 0, 0, 0.67)
dark.set_anchors_preset(Control.PRESET_FULL_RECT)
dark.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(dark)
Style.panel(self, Rect2(140, 250, 1000, 320), Style.PANEL, 10, Style.GOLD, 2)
Style.label(self, "Hand tauschen?", 26, Style.TEXT, Rect2(140, 262, 1000, 40), "center", true)
Style.label(self, "Klicke Karten an die du zurücklegen willst. Einmal pro Partie.", 15, Style.MUTED, Rect2(140, 300, 1000, 24), "center")
layer = Control.new()
layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(layer)
b_keep = Style.button(self, "Behalten", Rect2(430, 515, 200, 44), func(): finish(false), Color("285a3c"))
b_swap = Style.button(self, "Tauschen", Rect2(650, 515, 200, 44), func(): finish(true))
func open(hand: Array) -> void:
selected = []
visible = true
Style.clear(layer)
for i in hand.size():
var r := BattleLayout.hand_rect(hand.size(), i)
var v := CardView.new(hand[i], r.size)
v.position = Vector2(r.position.x, 322)
var idx := i
v.clicked.connect(func(_view): toggle(idx))
layer.add_child(v)
_borders()
func toggle(idx: int) -> void:
if idx in selected:
selected.erase(idx)
else:
selected.append(idx)
_borders()
func _borders() -> void:
var views := layer.get_children()
for i in views.size():
views[i].border = Style.YELLOW if i in selected else null
views[i].queue_redraw()
b_swap.text = ("Tauschen (%d)" % selected.size()) if not selected.is_empty() else "Tauschen"
func finish(swap: bool) -> void:
visible = false
decided.emit(selected.duplicate() if swap else [])

View File

@@ -1 +0,0 @@
uid://gwdm38e83cbj

View File

@@ -14,6 +14,8 @@ var perspective: Game.Player # der Mensch, dessen Hand unten liegt
var fx: BattleFx var fx: BattleFx
var selected = null # Game.Creature var selected = null # Game.Creature
var pending = null # Cards.Card, wartet auf Ziel var pending = null # Cards.Card, wartet auf Ziel
var fusing := false # Verschmelzen gewählt: zwei eigene Wesen anklicken
var fuse_first = null # Game.Creature, erstes Wesen der Verschmelzung
var msg_t := 0.0 var msg_t := 0.0
var ai_delay := 0.75 var ai_delay := 0.75
var ai_timer := 0.0 var ai_timer := 0.0
@@ -24,7 +26,6 @@ var reward := -1
var ended := false var ended := false
var zoom_player = null var zoom_player = null
var preview_timer := 0.0 var preview_timer := 0.0
var played_ids: Array = [] # vom Menschen gespielte Karten (Statistik)
var pad: BattleGamepad var pad: BattleGamepad
var gp_zone: String: var gp_zone: String:
get: get:
@@ -42,9 +43,9 @@ var undo_t := 0.0
# Ansichten (auch für Tests) # Ansichten (auch für Tests)
var tiles := {} var tiles := {}
var hand_views: Array = [] var hand_views: Array = []
var shop_views: Array = []
var own_views: Array = [] var own_views: Array = []
var enemy_views: Array = [] var enemy_views: Array = []
var trap_views: Array = []
var hero_panels := {} # Player -> Panel var hero_panels := {} # Player -> Panel
var hero_labels := {} var hero_labels := {}
var flash_until := {} # Spielerindex -> Zeitpunkt (ms) var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
@@ -52,43 +53,29 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
var hero_layer: Control var hero_layer: Control
var tile_layer: Control var tile_layer: Control
var hand_layer: Control var hand_layer: Control
var trap_layer: Control var shop_layer: Control
var zoom_layer: Control var zoom_layer: Control
var fx_layer: Control var fx_layer: Control
var log_box: RichTextLabel var log_box: RichTextLabel
var preview_holder: Control var preview_holder: Control
var preview_hint: Label
var bar_panel: Panel var bar_panel: Panel
var name_label: Label var name_label: Label
var life_label: Label var life_label: Label
var energy_label: Label var energy_label: Label
var deck_label: Label var income_label: Label
var status_label: Label var status_label: Label
var msg_label: Label var msg_label: Label
var b_end: Button var b_end: Button
var b_fuse: Button
var b_reroll: Button
var b_giveup: Button var b_giveup: Button
var field_label: Label
var overlay: Control var overlay: Control
var result_label: Label var result_label: Label
var reward_label: Label var reward_label: Label
var notice_label: Label var notice_label: Label
var confirm: Control var confirm: Control
var b_save_leave: Button var b_save_leave: Button
var mulligan: BattleMulligan
var mulligan_box: Control:
get:
return mulligan
var mulligan_layer: Control:
get:
return mulligan.layer
var b_keep: Button:
get:
return mulligan.b_keep
var b_swap: Button:
get:
return mulligan.b_swap
var mulligan_selected: Array:
get:
return mulligan.selected
var cursor_layer: Control var cursor_layer: Control
var wait_label: Label var wait_label: Label
var b_skip: Button var b_skip: Button
@@ -110,7 +97,6 @@ static func online(seat: int) -> BattleScreen:
## Unterbrochenen Kampf aus der Datei fortsetzen (siehe save_battle). ## Unterbrochenen Kampf aus der Datei fortsetzen (siehe save_battle).
static func resume(data: Dictionary) -> BattleScreen: static func resume(data: Dictionary) -> BattleScreen:
var s := BattleScreen.new(int(data.get("n_opp", 1)), int(data.get("stage", -1))) var s := BattleScreen.new(int(data.get("n_opp", 1)), int(data.get("stage", -1)))
s.played_ids = Array(data.get("played_ids", []))
s.saved_game = Game.from_snapshot(data.game) s.saved_game = Game.from_snapshot(data.game)
return s return s
@@ -155,17 +141,19 @@ func _ready() -> void:
game = saved_game game = saved_game
elif stage >= 0: elif stage >= 0:
var r := Game.make_rng() var r := Game.make_rng()
game = Game.new_battle_with(app.profile.deck_cards(), Campaign.opponents(stage, r, app.profile.target_rating()), r, false, own_rules()) game = Game.new_battle_with(Campaign.opponents(stage), r, false, own_rules())
else: else:
game = Game.new_battle(app.profile.deck_cards(), n_opp, null, own_rules()) game = Game.new_battle(n_opp, Game.make_rng(), own_rules())
smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile() smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile()
_begin() _begin()
## Eigenes Merkmal des Spielers (nach Kapitel 1 wählbar) und Zielstärke der Gegner. ## Kartenoptik, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1).
func own_rules() -> Dictionary: func own_rules() -> Dictionary:
var p: Profile = app.profile var p: Profile = app.profile
var rules := {"target_rating": p.target_rating(), "tier": str(p.difficulty().tier)} var rules := {"looks": p.looks()}
if stage < 0:
rules["opponent_life"] = int(p.difficulty().life)
if p.perk != "" and p.perk_unlocked(): if p.perk != "" and p.perk_unlocked():
rules["human_perks"] = [p.perk] rules["human_perks"] = [p.perk]
return rules return rules
@@ -188,11 +176,11 @@ func _begin() -> void:
func _build_static() -> void: func _build_static() -> void:
Style.panel(self, Rect2(10, 205, 320, 340)) Style.panel(self, Rect2(10, 205, 320, 290))
Style.label(self, "Verlauf", 14, Style.MUTED, Rect2(20, 209, 200, 20)) Style.label(self, "Verlauf", 14, Style.MUTED, Rect2(20, 209, 200, 20))
log_box = RichTextLabel.new() log_box = RichTextLabel.new()
log_box.position = Vector2(18, 230) log_box.position = Vector2(18, 230)
log_box.size = Vector2(304, 308) log_box.size = Vector2(304, 258)
log_box.bbcode_enabled = true log_box.bbcode_enabled = true
log_box.scroll_following = true log_box.scroll_following = true
log_box.add_theme_font_size_override("normal_font_size", 13) log_box.add_theme_font_size_override("normal_font_size", 13)
@@ -200,13 +188,12 @@ func _build_static() -> void:
add_child(log_box) add_child(log_box)
b_giveup = Style.button(self, "Aufgeben", Rect2(10, 553, 150, 40), func(): confirm.visible = true) b_giveup = Style.button(self, "Aufgeben", Rect2(10, 553, 150, 40), func(): confirm.visible = true)
Style.panel(self, Rect2(950, 205, 320, 380)) Style.panel(self, Rect2(950, 205, 320, 380))
preview_hint = Style.label(self, "Karte überfahren für Details", 14, Style.MUTED, Rect2(950, 345, 320, 24), "center")
Style.panel(self, Rect2(BattleLayout.OWN_X - 10, 240, 5 * 110 + 4 * 10 + 20, 160), Style.darker(Style.PANEL, 0.8), 10) Style.panel(self, Rect2(BattleLayout.OWN_X - 10, 240, 5 * 110 + 4 * 10 + 20, 160), Style.darker(Style.PANEL, 0.8), 10)
bar_panel = Style.panel(self, BattleLayout.BAR) bar_panel = Style.panel(self, BattleLayout.BAR)
name_label = Style.label(self, "Du", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true) name_label = Style.label(self, "", 20, Style.TEXT, Rect2(349, 421, 120, 30), "left", true)
life_label = Style.label(self, "", 24, Style.RED, Rect2(470, 418, 100, 34), "left", true) life_label = Style.label(self, "", 24, Style.RED, Rect2(470, 418, 100, 34), "left", true)
energy_label = Style.label(self, "", 24, Style.BLUE, Rect2(570, 418, 150, 34), "left", true) energy_label = Style.label(self, "", 24, Style.BLUE, Rect2(570, 418, 150, 34), "left", true)
deck_label = Style.label(self, "", 16, Style.MUTED, Rect2(780, 424, 150, 24), "right") income_label = Style.label(self, "", 16, Style.MUTED, Rect2(760, 424, 170, 24), "right")
status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24)) status_label = Style.label(self, "", 15, Style.MUTED, Rect2(349, 455, 400, 24))
msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true) msg_label = Style.label(self, "", 16, Style.ORANGE, Rect2(600, 455, 331, 24), "right", true)
b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c")) b_end = Style.button(self, "Zug beenden", Rect2(1005, 527, 210, 48), end_turn, Color("285a3c"))
@@ -215,20 +202,21 @@ func _build_static() -> void:
b_skip.visible = false b_skip.visible = false
b_undo = Style.button(self, "Rückgängig", Rect2(170, 553, 150, 40), undo) b_undo = Style.button(self, "Rückgängig", Rect2(170, 553, 150, 40), undo)
b_undo.visible = false b_undo.visible = false
field_label = Style.label(self, "", 15, Style.MUTED, Rect2(340, 211, 600, 24), "center")
b_fuse = Style.button(self, "Verschmelzen", Rect2(10, 503, 150, 40), toggle_fuse, Color("3c2858"))
b_fuse.visible = false
b_reroll = Style.button(self, "", Rect2(170, 503, 150, 40), do_reroll, Color("285a5a"))
b_reroll.add_theme_font_size_override("font_size", 14)
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center") wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false wait_label.visible = false
hero_layer = _layer() hero_layer = _layer()
tile_layer = _layer() tile_layer = _layer()
trap_layer = _layer()
hand_layer = _layer() hand_layer = _layer()
shop_layer = _layer()
preview_holder = _layer() preview_holder = _layer()
zoom_layer = _layer() zoom_layer = _layer()
fx_layer = _layer() fx_layer = _layer()
cursor_layer = _layer() cursor_layer = _layer()
# Mulligan
mulligan = BattleMulligan.new()
mulligan.decided.connect(do_mulligan)
add_child(mulligan)
# Spielende # Spielende
overlay = _overlay() overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true) result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
@@ -239,7 +227,6 @@ func _build_static() -> void:
confirm = Style.overlay(self) confirm = Style.overlay(self)
Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.MUTED, 2) Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.MUTED, 2)
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true) Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
Style.label(confirm, tr("Aufgeben beendet den Kampf ohne Münzen.") + "\n" + tr("Speichern hebt ihn für später auf."), 15, Style.MUTED, Rect2(340, 356, 600, 48), "center")
Style.button(confirm, "Ja, aufgeben", Rect2(355, 420, 180, 46), leave, Color("5a2828")) Style.button(confirm, "Ja, aufgeben", Rect2(355, 420, 180, 46), leave, Color("5a2828"))
b_save_leave = Style.button(confirm, "Speichern und zurück", Rect2(550, 420, 180, 46), save_and_leave, Color("285a3c")) b_save_leave = Style.button(confirm, "Speichern und zurück", Rect2(550, 420, 180, 46), save_and_leave, Color("285a3c"))
b_save_leave.add_theme_font_size_override("font_size", 14) b_save_leave.add_theme_font_size_override("font_size", 14)
@@ -265,7 +252,7 @@ func resumable() -> bool:
func save_battle() -> void: func save_battle() -> void:
if not resumable() or game == null or game.winner != "": if not resumable() or game == null or game.winner != "":
return return
var d := {"version": 1, "n_opp": n_opp, "stage": stage, "played_ids": played_ids, "game": game.snapshot(-1)} var d := {"version": 1, "n_opp": n_opp, "stage": stage, "game": game.snapshot(-1)}
var f := FileAccess.open(app.battle_save_path(), FileAccess.WRITE) var f := FileAccess.open(app.battle_save_path(), FileAccess.WRITE)
if f != null: if f != null:
f.store_string(JSON.stringify(d)) f.store_string(JSON.stringify(d))
@@ -371,7 +358,7 @@ func my_turn() -> bool:
func any_overlay() -> bool: func any_overlay() -> bool:
return confirm.visible or mulligan.visible or overlay.visible return confirm.visible or overlay.visible
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
@@ -389,7 +376,7 @@ func _input(event: InputEvent) -> void:
KEY_ESCAPE: KEY_ESCAPE:
if confirm.visible: if confirm.visible:
confirm.visible = false confirm.visible = false
elif selected != null or pending != null or zoom_player != null: elif selected != null or pending != null or fusing or zoom_player != null:
cancel_all() cancel_all()
elif not any_overlay(): elif not any_overlay():
confirm.visible = true confirm.visible = true
@@ -402,9 +389,7 @@ func _input(event: InputEvent) -> void:
if b_undo.visible: if b_undo.visible:
undo() undo()
KEY_ENTER, KEY_KP_ENTER: KEY_ENTER, KEY_KP_ENTER:
if mulligan.visible: if overlay.visible:
do_mulligan([])
elif overlay.visible:
leave() leave()
_: _:
if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay(): if event.keycode >= KEY_1 and event.keycode <= KEY_9 and my_turn() and not any_overlay():
@@ -444,8 +429,6 @@ func _process(delta: float) -> void:
return return
var cur := game.current() var cur := game.current()
if cur.is_human: if cur.is_human:
if cur == perspective and not spectating() and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible:
show_mulligan()
return return
if is_client(): if is_client():
return return
@@ -477,7 +460,7 @@ func _process(delta: float) -> void:
func _start_ai(cur: Game.Player) -> void: func _start_ai(cur: Game.Player) -> void:
ai_copy = game.clone_for(cur) ai_copy = game.clone()
var copy := ai_copy var copy := ai_copy
var idx := game.current_idx var idx := game.current_idx
var box := ai_box var box := ai_box
@@ -494,7 +477,7 @@ func _finish() -> void:
var won := game.winner == "human" var won := game.winner == "human"
if counts_for_profile(): if counts_for_profile():
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0 var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
reward = app.profile.record_battle(won, n_opp, app.profile.deck_cards(), stage, played_ids, game.turn, factor, game.human.life) reward = app.profile.record_battle(won, n_opp, stage, factor)
app.save() app.save()
app.sfx.play("win" if won else "lose") app.sfx.play("win" if won else "lose")
app.clear_battle_save() app.clear_battle_save()
@@ -513,19 +496,21 @@ func say(m: String) -> void:
func cancel() -> void: func cancel() -> void:
selected = null selected = null
pending = null pending = null
fusing = false
fuse_first = null
func play(card: Cards.Card, target) -> void: func play(card: Cards.Card, target) -> void:
if is_client(): if is_client():
net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null}) net.send_action({"type": "play", "hand": perspective.hand.find(card), "target": Net.ref_dict(target) if target != null else null})
return return
var before: Game = game.clone() if card.is_creature() and not is_online() else null var before: Game = game.clone() if not is_online() else null
var log_len := game.log.size() var log_len := game.log.size()
var err := game.play_card(perspective, card, target) var err := game.play_card(perspective, card, target)
if err != "": if err != "":
say(tr(err)) say(tr(err))
return return
# Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte, Kombos, Fallen) # Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte und keine Kombos)
if before != null and game.events.size() == 1 and game.events[0].type == "play": if before != null and game.events.size() == 1 and game.events[0].type == "play":
undo_state = before undo_state = before
undo_log_len = log_len undo_log_len = log_len
@@ -534,6 +519,52 @@ func play(card: Cards.Card, target) -> void:
clear_undo() clear_undo()
## Verschmelzen an- und abwählen (zwei eigene Wesen, siehe Game.fuse).
func toggle_fuse() -> void:
var on := not fusing
cancel()
fusing = on
say(tr("Zwei eigene Wesen wählen") if on else tr("Abgebrochen"))
after_action()
## Kauft ein Angebot aus dem Laden auf die Hand.
func do_buy(slot: int) -> void:
clear_undo()
if is_client():
net.send_action({"type": "buy", "slot": slot})
return
var err := game.buy(perspective, slot)
if err != "":
say(tr(err))
## Würfelt den Laden neu (kostet Mana).
func do_reroll() -> void:
if not my_turn() or any_overlay():
return
clear_undo()
if is_client():
net.send_action({"type": "reroll"})
after_action()
return
var err := game.reroll(perspective)
if err != "":
say(tr(err))
after_action()
func do_fuse(a, b) -> void:
cancel()
clear_undo()
if is_client():
net.send_action({"type": "fuse", "uid": a.uid, "other": b.uid})
return
var err := game.fuse(perspective, a, b)
if err != "":
say(tr(err))
func clear_undo() -> void: func clear_undo() -> void:
undo_state = null undo_state = null
undo_t = 0.0 undo_t = 0.0
@@ -574,26 +605,23 @@ func end_turn() -> void:
after_action() after_action()
func show_mulligan() -> void:
mulligan.open(perspective.hand)
func do_mulligan(idxs: Array) -> void:
mulligan.visible = false
if is_client():
net.send_action({"type": "mulligan", "hand": idxs})
return
var cards := []
for i in idxs:
cards.append(perspective.hand[i])
game.mulligan(perspective, cards)
after_action()
func on_click(kind: String, obj) -> void: func on_click(kind: String, obj) -> void:
var g := game var g := game
if not my_turn() or any_overlay(): if not my_turn() or any_overlay():
return return
if fusing:
if kind != "own":
cancel()
say(tr("Abgebrochen"))
elif fuse_first == null:
fuse_first = obj
say(tr("Zweites Wesen wählen"))
elif obj == fuse_first:
fuse_first = null
else:
do_fuse(fuse_first, obj)
after_action()
return
if pending != null: if pending != null:
var card = pending var card = pending
pending = null pending = null
@@ -619,6 +647,8 @@ func on_click(kind: String, obj) -> void:
after_action() after_action()
return return
match kind: match kind:
"shop":
do_buy(int(obj))
"hand": "hand":
var ok := g.can_play(perspective, obj) var ok := g.can_play(perspective, obj)
if not ok[0]: if not ok[0]:
@@ -647,61 +677,17 @@ func on_tile_click(cr: Game.Creature) -> void:
on_click("enemy", cr) on_click("enemy", cr)
func show_preview(card, entered: bool) -> void: func show_preview(card, entered: bool, owner_looks: Dictionary = {}) -> void:
Style.clear(preview_holder) Style.clear(preview_holder)
if entered and card != null: if entered and card != null:
var v := CardView.new(card, Vector2(210, 294)) var v := CardView.new(card, Vector2(210, 294), false, Cards.RARITIES[int(owner_looks.get(card.id, 0))])
v.position = Vector2(1005, 215) v.position = Vector2(1005, 215)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v) preview_holder.add_child(v)
preview_hint.visible = not (entered and card != null)
## Vorschau einer Handkarte mit Hinweisen auf Resonanz und Feldkombos mit dem eigenen Feld.
func show_hand_preview(card: Cards.Card, entered: bool) -> void: func show_hand_preview(card: Cards.Card, entered: bool) -> void:
Style.clear(preview_holder) show_preview(card, entered, perspective.looks)
preview_hint.visible = not entered
if not entered:
return
var hints: Array[String] = combo_hints(card)
var v := CardView.new(card, Vector2(190, 266) if not hints.is_empty() else Vector2(210, 294))
v.position = Vector2(1015, 212) if not hints.is_empty() else Vector2(1005, 215)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
if not hints.is_empty():
var box := RichTextLabel.new()
box.position = Vector2(958, 480)
box.size = Vector2(304, 46)
box.bbcode_enabled = true
box.scroll_active = false
box.fit_content = false
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_theme_font_size_override("normal_font_size", 11)
box.add_theme_color_override("default_color", Style.GOLD)
box.text = "[center]" + "\n".join(hints.slice(0, 2)) + "[/center]"
preview_holder.add_child(box)
func combo_hints(card: Cards.Card) -> Array[String]:
var out: Array[String] = []
if not card.is_creature():
return out
var partners: Array[String] = []
var combos := {}
for other in perspective.board:
if other.card.elements.any(func(e): return e in card.elements):
partners.append(other.card.title())
for e1 in card.elements:
for e2 in other.card.elements:
if e1 != e2:
var name := Cards.combo_name([e1, e2])
if not combos.has(name):
combos[name] = other.card.title()
if not partners.is_empty():
out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)])
for name in combos:
out.append("Feldkombo %s mit %s: %s" % [name, combos[name], Cards.FIELD_COMBO_TEXT[name]])
return out
# --- Netzwerk ------------------------------------------------------------------------------- # --- Netzwerk -------------------------------------------------------------------------------
@@ -751,8 +737,6 @@ func after_action() -> void:
net.broadcast() net.broadcast()
var evs := game.events.duplicate() var evs := game.events.duplicate()
game.events.clear() game.events.clear()
if counts_for_profile():
_note_events(evs)
var centers := {} var centers := {}
for ev in evs: for ev in evs:
for key in ["target", "attacker", "source"]: for key in ["target", "attacker", "source"]:
@@ -763,22 +747,6 @@ func after_action() -> void:
save_battle() save_battle()
func _note_events(evs: Array) -> void:
var hi := game.players.find(game.human)
for ev in evs:
if ev.get("player", -1) != hi:
continue
match ev.type:
"play", "spell":
played_ids.append(ev.card.id)
if ev.type == "play":
app.profile.daily_event("creature_played")
"trap_set":
app.profile.daily_event("trap_set")
"combo":
app.profile.daily_event("combo")
# --- Ansicht angleichen -------------------------------------------------------------------- # --- Ansicht angleichen --------------------------------------------------------------------
func refresh() -> void: func refresh() -> void:
@@ -790,9 +758,9 @@ func refresh() -> void:
var mine := my_turn() var mine := my_turn()
var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else [] var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else []
hand_views = [] hand_views = []
shop_views = []
own_views = [] own_views = []
enemy_views = [] enemy_views = []
trap_views = []
for o in others(): for o in others():
var panel: Panel = hero_panels[o] var panel: Panel = hero_panels[o]
@@ -813,8 +781,8 @@ func refresh() -> void:
labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED) labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED)
if o.alive(): if o.alive():
labels.life.text = "♥ %d" % maxi(o.life, 0) labels.life.text = "♥ %d" % maxi(o.life, 0)
var traps_txt := (" F %d" if compact() else " Fallen %d") % o.traps.size() if not o.traps.is_empty() else "" labels.stats.text = ("H %d ◆ %d" % [o.hand.size(), o.energy]) if compact() \
labels.stats.text = ("H %d D %d%s ◆ %d" if compact() else "Hand %d Deck %d%s ◆ %d") % [o.hand.size(), o.deck.size(), traps_txt, o.max_energy] else ("Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy])
else: else:
labels.life.text = "besiegt" labels.life.text = "besiegt"
labels.life.add_theme_color_override("font_color", Style.MUTED) labels.life.add_theme_color_override("font_color", Style.MUTED)
@@ -840,7 +808,8 @@ func refresh() -> void:
tile.slot = rect.position tile.slot = rect.position
tiles[cr.uid] = tile tiles[cr.uid] = tile
tile_layer.add_child(tile) tile_layer.add_child(tile)
tile.hover.connect(show_preview) var looks: Dictionary = p.looks
tile.hover.connect(func(card, entered): show_preview(card, entered, looks))
var uid := cr.uid var uid := cr.uid
tile.clicked.connect(func(_v): tile.clicked.connect(func(_v):
var c := game.find_creature(uid) var c := game.find_creature(uid)
@@ -852,9 +821,12 @@ func refresh() -> void:
fx.move_to(tile, rect.position) fx.move_to(tile, rect.position)
var b = null var b = null
var w := 2 var w := 2
if cr == selected: if cr == selected or cr == fuse_first:
b = Style.YELLOW b = Style.YELLOW
w = 4 w = 4
elif fusing and p == h and mine:
b = Style.PURPLE
w = 3
elif cr in valid or (selected != null and g.can_attack(selected, cr)[0]): elif cr in valid or (selected != null and g.can_attack(selected, cr)[0]):
b = Style.ORANGE b = Style.ORANGE
w = 3 w = 3
@@ -874,15 +846,23 @@ func refresh() -> void:
tiles.erase(uid) tiles.erase(uid)
fx.fade_out(t) fx.fade_out(t)
Style.clear(trap_layer) Style.clear(shop_layer)
if not h.traps.is_empty(): if not spectating():
Style.label(trap_layer, "Fallen", 13, Style.MUTED, Rect2(335, 492, 60, 20)) for i in h.shop.size():
for i in h.traps.size(): var offer = h.shop[i]
var tv := CardView.new(h.traps[i], BattleLayout.trap_rect(i).size) var sr := BattleLayout.shop_rect(i)
tv.position = BattleLayout.trap_rect(i).position if offer == null:
tv.hover.connect(show_preview) Style.panel(shop_layer, sr, Style.darker(Style.PANEL, 0.8), 8, Style.PANEL_LIGHT, 2)
trap_layer.add_child(tv) continue
trap_views.append(tv) var sv := CardView.new(offer, sr.size, false, Cards.RARITIES[int(h.looks.get(offer.id, 0))])
sv.position = sr.position
sv.dim = not mine or h.energy < offer.cost or h.hand.size() >= Game.MAX_HAND
sv.hover.connect(show_hand_preview)
var slot := i
sv.set_meta("slot", slot) # der Platz im Laden, auch für das Gamepad
sv.clicked.connect(func(_view): on_click("shop", slot))
shop_layer.add_child(sv)
shop_views.append(sv)
Style.clear(hand_layer) Style.clear(hand_layer)
if spectating(): if spectating():
@@ -892,7 +872,7 @@ func refresh() -> void:
var card: Cards.Card = h.hand[i] var card: Cards.Card = h.hand[i]
var playable: bool = mine and g.can_play(h, card)[0] var playable: bool = mine and g.can_play(h, card)[0]
var r := BattleLayout.hand_rect(h.hand.size(), i) var r := BattleLayout.hand_rect(h.hand.size(), i)
var v := CardView.new(card, r.size) var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))])
v.position = r.position v.position = r.position
if card == pending: if card == pending:
v.border = Style.YELLOW v.border = Style.YELLOW
@@ -910,8 +890,8 @@ func refresh() -> void:
bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0)) bar_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
name_label.text = h.name name_label.text = h.name
life_label.text = "♥ %d" % maxi(h.life, 0) life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy] energy_label.text = "◆ %d" % h.energy
deck_label.text = "Deck %d" % h.deck.size() income_label.text = tr("+%d je Zug") % h.max_energy
status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name]) status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name])
if spectating(): if spectating():
status_label.text = tr("Zuschauer") + " · " + status_label.text status_label.text = tr("Zuschauer") + " · " + status_label.text
@@ -920,13 +900,29 @@ func refresh() -> void:
b_skip.visible = ai_running b_skip.visible = ai_running
b_end.visible = not ai_running and not spectating() b_end.visible = not ai_running and not spectating()
b_undo.visible = undo_state != null and mine b_undo.visible = undo_state != null and mine
b_fuse.visible = mine and not spectating() and h.board.size() >= 2
b_reroll.visible = mine and not spectating()
b_reroll.text = tr("Neu würfeln (%d)") % Game.REROLL_COST
b_reroll.disabled = h.energy < Game.REROLL_COST
b_fuse.text = tr("Abbrechen") if fusing else tr("Verschmelzen")
if g.field_element == "":
var parts: Array[String] = []
for e in Cards.ELEMENTS:
parts.append("%s %d" % [tr(e), int(g.field_charge.get(e, 0))])
field_label.text = tr("Feld: neutral") + " · " + " ".join(parts)
field_label.add_theme_color_override("font_color", Style.MUTED)
else:
field_label.text = "%s: %s" % [tr(Cards.FIELD_NAMES[g.field_element]), tr(Cards.FIELD_TEXT[g.field_element])]
field_label.add_theme_color_override("font_color", Cards.ELEMENT_COLORS[g.field_element])
var lines: Array[String] = [] var lines: Array[String] = []
var start := maxi(0, g.log.size() - 60) var start := maxi(0, g.log.size() - 60)
for i in range(start, g.log.size()): for i in range(start, g.log.size()):
var line := g.log[i] var line := g.log[i]
if line.begins_with("---"): if line.begins_with("---"):
lines.append("[color=#6eaaf0]%s[/color]" % line) lines.append("[color=#6eaaf0]%s[/color]" % line)
elif line.begins_with("Feldkombo") or line.begins_with("Falle von"): elif line.begins_with("Das Feld wird"):
lines.append("[color=#8c78dc]%s[/color]" % line)
elif line.begins_with("Feldkombo"):
lines.append("[color=#f5c850]%s[/color]" % line) lines.append("[color=#f5c850]%s[/color]" % line)
else: else:
lines.append(line) lines.append(line)
@@ -954,7 +950,6 @@ func refresh() -> void:
reward_label.text = "Gemeinsame Partie, keine Münzen" reward_label.text = "Gemeinsame Partie, keine Münzen"
overlay.visible = true overlay.visible = true
confirm.visible = false confirm.visible = false
mulligan.visible = false
func build_zoom(valid: Array) -> void: func build_zoom(valid: Array) -> void:
@@ -963,7 +958,7 @@ func build_zoom(valid: Array) -> void:
var box := Rect2(190, 190, 900, 300) var box := Rect2(190, 190, 900, 300)
var back := Style.panel(zoom_layer, box, Style.PANEL, 10, Style.GOLD, 2) var back := Style.panel(zoom_layer, box, Style.PANEL, 10, Style.GOLD, 2)
back.mouse_filter = Control.MOUSE_FILTER_STOP back.mouse_filter = Control.MOUSE_FILTER_STOP
Style.label(zoom_layer, "%s · %s ♥ %d Hand %d Deck %d Fallen %d ◆ %d" % [o.name, o.title, o.life, o.hand.size(), o.deck.size(), o.traps.size(), o.max_energy], Style.label(zoom_layer, "%s · %s ♥ %d Hand %d ◆ %d (+%d)" % [o.name, o.title, o.life, o.hand.size(), o.energy, o.max_energy],
18, Style.TEXT, Rect2(box.position.x + 20, box.position.y + 12, 800, 30), "left", true) 18, Style.TEXT, Rect2(box.position.x + 20, box.position.y + 12, 800, 30), "left", true)
for j in o.board.size(): for j in o.board.size():
var cr: Game.Creature = o.board[j] var cr: Game.Creature = o.board[j]

View File

@@ -5,17 +5,13 @@ const CARD_W := 200
const CARD_H := 280 const CARD_H := 280
var app var app
var cards: Array = [] var cards: Array = [] # {"card", "rarity", "upgraded", "dust"} je Karte
var revealed := {} var revealed := {}
var new_ids := {}
var coins_label: Label var coins_label: Label
var msg_label: Label var msg_label: Label
var b_buy: Button var b_buy: Button
var b_all: Button var b_all: Button
var b_improve: Button
var suggestion := ""
var slots: Control var slots: Control
var element_buttons: Array = []
class CardBack extends Control: class CardBack extends Control:
@@ -46,28 +42,19 @@ class CardBack extends Control:
func slot_rect(i: int) -> Rect2: func slot_rect(i: int) -> Rect2:
var gap := 20 var gap := 20
var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2 var x0 := (1280 - Profile.BOOSTER_SIZE * CARD_W - (Profile.BOOSTER_SIZE - 1) * gap) / 2
return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H) return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H)
func _ready() -> void: func _ready() -> void:
Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true) Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true)
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center") coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.",
16, Style.MUTED, Rect2(0, 115, 1280, 24), "center")
Style.label(self, "Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos" % Profile.ELEMENT_BOOSTER_COST,
14, Style.MUTED, Rect2(0, 586, 1280, 22), "center")
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
element_buttons.append(Style.button(self, e, Rect2(322 + i * 162, 612, 150, 38), buy.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)))
slots = Control.new() slots = Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(slots) add_child(slots)
msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 700, 1280, 22), "center") msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 700, 1280, 22), "center")
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(290, 720, 330, 48), buy, Color("5a4628")) b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(290, 720, 330, 48), buy, Color("5a4628"))
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all) b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all)
b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c"))
b_improve.visible = false
Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new())) Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new()))
refresh() refresh()
@@ -77,10 +64,9 @@ func _input(event: InputEvent) -> void:
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())
func buy(element: String = "") -> void: func buy() -> void:
var p: Profile = app.profile var p: Profile = app.profile
var before: Dictionary = p.collection.duplicate() var got: Array = p.open_booster()
var got: Array = p.open_booster(null, element)
if got.is_empty(): if got.is_empty():
msg_label.text = "Nicht genug Münzen" msg_label.text = "Nicht genug Münzen"
msg_label.add_theme_color_override("font_color", Style.RED) msg_label.add_theme_color_override("font_color", Style.RED)
@@ -89,13 +75,7 @@ func buy(element: String = "") -> void:
app.sfx.play("play") app.sfx.play("play")
cards = got cards = got
revealed = {} revealed = {}
new_ids = {} msg_label.text = ""
for c in cards:
if before.get(c.id, 0) == 0:
new_ids[c.id] = true
var notes: Array[String] = p.take_notices()
msg_label.text = " · ".join(notes)
msg_label.add_theme_color_override("font_color", Style.GREEN)
refresh() refresh()
@@ -106,59 +86,32 @@ func reveal(i: int) -> void:
func reveal_all() -> void: func reveal_all() -> void:
for i in 5: for i in Profile.BOOSTER_SIZE:
revealed[i] = true revealed[i] = true
refresh() refresh()
## Nimmt die vorgeschlagene Karte ins aktive Deck (ersetzt die schwächste).
func improve() -> void:
if suggestion == "":
return
var err: String = app.profile.improve_deck_with(suggestion)
if err == "":
app.save()
msg_label.text = tr("%s ist jetzt im Deck %s") % [Cards.by_id(suggestion).title(), app.profile.deck_name()]
msg_label.add_theme_color_override("font_color", Style.GREEN)
suggestion = ""
else:
msg_label.text = err
msg_label.add_theme_color_override("font_color", Style.RED)
refresh()
func refresh() -> void: func refresh() -> void:
var p: Profile = app.profile var p: Profile = app.profile
coins_label.text = "Münzen: %d" % p.coins coins_label.text = "Münzen: %d" % p.coins
b_buy.disabled = p.coins < Profile.BOOSTER_COST b_buy.disabled = p.coins < Profile.BOOSTER_COST
for b in element_buttons: b_all.disabled = cards.is_empty() or revealed.size() >= Profile.BOOSTER_SIZE
b.disabled = p.coins < Profile.ELEMENT_BOOSTER_COST
b_all.disabled = cards.is_empty() or revealed.size() >= 5
# Deck-Vorschlag, sobald alle Karten offen liegen
suggestion = ""
if not cards.is_empty() and revealed.size() >= 5:
var ids: Array = cards.map(func(c): return c.id)
var better: Array = p.deck_suggestions(ids)
if not better.is_empty():
suggestion = better[0]
b_improve.visible = suggestion != ""
if suggestion != "":
b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).title(), p.deck_name()]
Style.clear(slots) Style.clear(slots)
for i in 5: for i in Profile.BOOSTER_SIZE:
var r := slot_rect(i) var r := slot_rect(i)
if cards.is_empty(): if cards.is_empty():
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2) Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
elif revealed.has(i): elif revealed.has(i):
var c: Cards.Card = cards[i] var res: Dictionary = cards[i]
var v := CardView.new(c, r.size) var c: Cards.Card = res.card
var v := CardView.new(c, 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) Style.label(slots, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if new_ids.has(c.id): if res.upgraded:
Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true) Style.label(slots, "Aufgewertet", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
else: else:
Style.label(slots, "jetzt %dx" % p.collection[c.id], 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center") Style.label(slots, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
else: else:
var back := CardBack.new(r.size) var back := CardBack.new(r.size)
back.position = r.position back.position = r.position

View File

@@ -70,7 +70,7 @@ func build() -> void:
var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass) var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
b.disabled = true b.disabled = true
if p.campaign_stage >= Campaign.total(): if p.campaign_stage >= Campaign.total():
Style.clabel(col, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true) Style.clabel(col, "Alle Kapitel abgeschlossen", 16, Style.GREEN, "center", true)
var br := Style.row(col) var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new())) Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))

View File

@@ -3,7 +3,6 @@ extends ScrollContainer
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte. ## Scrollbares Kartenraster mit Beschriftung unter jeder Karte.
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 := 7
const CW := 100 const CW := 100
@@ -25,17 +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; label_fn(card) -> String; dim_fn(card) -> bool; rarity_fn(card) -> String (Optik)
func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void: func fill(cards: Array, label_fn: Callable, dim_fn: Callable, rarity_fn: Callable = 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 + 16)
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) if rarity_fn.is_valid() else "gewöhnlich")
v.dim = dim_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) 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") Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center")

View File

@@ -7,12 +7,14 @@ 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 Seltenheitsfarbe)
var rarity := "gewöhnlich" # nur Optik: Rahmenfarbe der eigenen Stufe
var dim := false var dim := false
## 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 = "gewöhnlich") -> void:
card = c card = c
rarity = rar
custom_minimum_size = sz custom_minimum_size = sz
size = sz size = sz
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
@@ -45,7 +47,7 @@ func _draw() -> void:
var f_text := 14 if big else (11 if mid else 10) var f_text := 14 if big else (11 if mid else 10)
var f_stat := 24 if big else (18 if mid else 15) var 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 bcol: Color = border if border != null else Cards.RARITY_COLORS[rarity]
Style.border(self, rect, bcol, 3 if big else 2, 8) Style.border(self, rect, bcol, 3 if big else 2, 8)
# Kosten # Kosten
var r := 16 if big else 12 var r := 16 if big else 12
@@ -57,17 +59,17 @@ func _draw() -> void:
var nm := Style.fit(card.title(), true, f_name, size.x - name_left - 6) var nm := Style.fit(card.title(), true, f_name, size.x - name_left - 6)
Style.text(self, nm, f_name, Style.TEXT, Vector2((name_left + size.x - 6) / 2.0, 4), "midtop", true) Style.text(self, nm, f_name, Style.TEXT, Vector2((name_left + size.x - 6) / 2.0, 4), "midtop", true)
if mid: if mid:
var sub: String = (tr(card.combo()) + " · " + tr(card.kind)) if card.is_combo() else tr(card.kind) var sub: String = tr(card.combo()) if card.is_combo() else ""
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop") Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop")
# Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest # Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest
var tsize := Style.fs(f_text) var tsize := Style.fs(f_text)
var text_w: float = size.x - 20 var text_w: float = size.x - 20
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 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
var box_bottom: float = size.y - (28 if big else 22) var box_bottom: float = size.y - (28 if big else 22)
var box_top: float = maxf(size.y * 0.30, box_bottom - maxf(needed, size.y * 0.42)) var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0) # unter Name und Kombo
var box_top: float = maxf(maxf(size.y * 0.30, band_top), box_bottom - maxf(needed, size.y * 0.42))
var box := Rect2(6, box_top, size.x - 12, box_bottom - box_top) var box := Rect2(6, box_top, size.x - 12, box_bottom - box_top)
# Bild # Bild
var band_top: float = 2 * r + 4 + (f_text + 6 if mid else 0)
var band_bottom: float = box_top - 4 var band_bottom: float = box_top - 4
if band_bottom - band_top > 14: if band_bottom - band_top > 14:
var gsize: float = minf(band_bottom - band_top, size.x * 0.42) var gsize: float = minf(band_bottom - band_top, size.x * 0.42)
@@ -77,9 +79,8 @@ func _draw() -> void:
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)), 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) card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
# Werte # 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.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)
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():

View File

@@ -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 gehören dir. Gesammelt wird die Seltenheitsstufe — reine Optik.
## zerlegen (Staub) oder herstellen. ## Aufwerten kostet Staub, eine Stufe abgeben bringt Staub.
var app var app
var filter := "" var filter := ""
@@ -14,14 +14,11 @@ 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_craft: Button var b_upgrade: 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.label(self, "Sammlung", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
Style.label(self, "Karte anklicken, dann zerlegen oder herstellen. Überschuss: alles über %d Kopien." % Game.MAX_COPIES,
14, Style.MUTED, Rect2(240, 18, 600, 24))
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size(): for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i] var e: String = Cards.ELEMENTS[i]
@@ -44,10 +41,9 @@ func _ready() -> void:
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, "", 14, Style.MUTED, Rect2(860, 396, 390, 66), "center")
b_dismantle = Style.button(self, "", Rect2(860, 470, 390, 44), dismantle, Color("5a3c28")) b_upgrade = Style.button(self, "", Rect2(860, 480, 390, 44), upgrade, Color("285a3c"))
b_craft = Style.button(self, "", Rect2(860, 524, 390, 44), craft, Color("285a3c")) b_dismantle = Style.button(self, "", Rect2(860, 534, 390, 44), dismantle, Color("5a3c28"))
b_extras = Style.button(self, "", Rect2(860, 600, 390, 44), dismantle_extras) msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 600, 390, 48), "center")
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(860, 660, 390, 48), "center")
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new())) Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), func(): app.show_screen(MenuScreen.new()))
refresh() refresh()
@@ -83,71 +79,57 @@ 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()
func craft() -> void:
if selected == null:
return
var err: String = app.profile.craft(selected.id)
if err == "":
app.sfx.play("play")
app.save()
var notes: Array[String] = app.profile.take_notices()
say(tr("%s hergestellt") % selected.title() + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
else:
say(err, false)
refresh()
func dismantle_extras() -> void:
var got: int = app.profile.dismantle_extras()
if got > 0:
app.save()
say("Überschuss zerlegt: +%d Staub" % got, true)
else:
say("Kein Überschuss vorhanden", false)
refresh()
func refresh() -> void: 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(), grid.fill(cards(), func(c): return tr(p.rarity(c.id)), func(_c): return false, func(c): return p.rarity(c.id))
func(c): return "%dx" % p.collection.get(c.id, 0) if p.collection.get(c.id, 0) > 0 else "fehlt",
func(c): return p.collection.get(c.id, 0) == 0)
Style.clear(preview_holder) Style.clear(preview_holder)
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, Vector2(200, 280), false, p.rarity(selected.id))
v.position = Vector2(955, 100)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
var lvl := p.level(selected.id)
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" if lvl == 0 else "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]
var owned: int = p.collection.get(selected.id, 0) b_dismantle.disabled = lvl == 0
var st: Dictionary = p.card_stats.get(selected.id, {"played": 0, "games": 0, "wins": 0})
info_label.text = "%s · %s · Wert %.1f\nIm Besitz: %dx · Gespielt %dx · %d Partien, %d Siege" % [
selected.combo(), selected.rarity, Cards.card_value(selected), owned, st.played, st.games, st.wins]
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[selected.rarity]
b_craft.text = "Herstellen (%d Staub)" % Profile.DUST_CRAFT[selected.rarity]
b_dismantle.disabled = owned == 0
b_craft.disabled = p.dust < Profile.DUST_CRAFT[selected.rarity]
var extras := 0
for cid in p.collection:
extras += maxi(0, p.collection[cid] - Game.MAX_COPIES)
b_extras.text = "Überschuss zerlegen (%d Karten)" % extras
b_extras.disabled = extras == 0

View File

@@ -36,7 +36,9 @@ func _draw() -> void:
var small := size.x < 100 var small := size.x < 100
var mini := size.x < 60 var mini := size.x < 60
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 owner = creature.owner()
var lvl: int = int(owner.looks.get(creature.card.id, 0)) if owner != null else 0
var bcol: Color = border if border != null else Cards.RARITY_COLORS[Cards.RARITIES[lvl]]
if flash: if flash:
bcol = Style.RED bcol = Style.RED
Style.border(self, rect, bcol, 4 if flash else width, 6) Style.border(self, rect, bcol, 4 if flash else width, 6)
@@ -57,12 +59,14 @@ func _draw() -> void:
badges.append("Eis") badges.append("Eis")
if creature.resonance > 0: if creature.resonance > 0:
badges.append("Resonanz") badges.append("Resonanz")
if creature.field_bonus > 0:
badges.append("Feld")
var y := 18.0 if small else 22.0 var y := 18.0 if small else 22.0
for b in badges: for b in badges:
Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop") Style.text(self, b, 10 if small else 12, Color("ebebf5"), Vector2(size.x / 2, y), "midtop")
y += 12 if small else 15 y += 12 if small else 15
var f := 16 if small else 20 var f := 16 if small else 20
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW var atk_col := Style.GREEN if creature.resonance + creature.field_bonus > 0 else Style.YELLOW
Style.text(self, str(creature.power()), f, atk_col, Vector2(6, size.y - 4), "bottomleft", true) 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") 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) Style.text(self, str(creature.health), f, hp_col, Vector2(size.x - 6, size.y - 4), "bottomright", true)
@@ -83,8 +87,10 @@ func _draw_mini() -> void:
letters.append("E") letters.append("E")
if creature.resonance > 0: if creature.resonance > 0:
letters.append("R") letters.append("R")
if creature.field_bonus > 0:
letters.append("F")
Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true) Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true)
var atk_col := Style.GREEN if creature.resonance > 0 else Style.YELLOW var atk_col := Style.GREEN if creature.resonance + creature.field_bonus > 0 else Style.YELLOW
Style.text(self, str(creature.power()), 13, atk_col, Vector2(4, size.y - 2), "bottomleft", true) 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") var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true) Style.text(self, str(creature.health), 13, hp_col, Vector2(size.x - 4, size.y - 2), "bottomright", true)

View File

@@ -1,422 +0,0 @@
class_name DeckScreen
extends Control
## Deckbau mit fünf Slots, Suche, Sortierung und fehlenden Karten.
const SORTS := ["Kosten", "Name", "Element", "Seltenheit"]
var app
var slot := 0
var deck: Array = []
var filter := ""
var search := ""
var sort_mode := 0
var show_missing := false
var msg_label: Label
var grid: CardGrid
var deck_list: Control
var deck_label: Label
var elems_label: Label
var valid_label: Label
var active_label: Label
var curve: Control
var preview: CardView
var search_box: LineEdit
var sort_box: OptionButton
var missing_box: CheckButton
var slot_buttons: Array = []
var b_activate: Button
var b_autofill: Button
var name_box: LineEdit
var confirm: Control
var code_dialog: Control
var code_box: LineEdit
class DeckRow extends Control:
signal clicked(card)
signal hover(card, entered: bool)
var card: Cards.Card
var count: int
var lit := false
func _init(c: Cards.Card, n: int, sz: Vector2) -> void:
card = c
count = n
size = sz
custom_minimum_size = sz
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
var light := func(on: bool):
lit = on
queue_redraw()
hover.emit(card, on)
mouse_entered.connect(light.bind(true))
mouse_exited.connect(light.bind(false))
focus_entered.connect(light.bind(true))
focus_exited.connect(light.bind(false))
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit(card)
elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event()
clicked.emit(card)
func _draw() -> void:
if lit:
draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size))
for k in card.elements.size():
draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]])
Style.text(self, "%dx %s" % [count, card.title()], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true)
class EnergyCurve extends Control:
var cards: Array = []
func _draw() -> void:
Style.text(self, "Energiekurve", 12, Style.MUTED, Vector2(0, 0))
var counts := {}
for c in cards:
var k: int = mini(c.cost, 8)
counts[k] = counts.get(k, 0) + 1
for i in range(1, 9):
var n: int = counts.get(i, 0)
var x := (i - 1) * 48
draw_rect(Rect2(x, 104 - n * 5, 36, n * 5), Style.BLUE)
if n > 0:
Style.text(self, str(n), 11, Style.TEXT, Vector2(x + 18, 102 - n * 5), "midbottom")
Style.text(self, str(i) + ("+" if i == 8 else ""), 12, Style.MUTED, Vector2(x + 18, 108), "midtop")
func _ready() -> void:
slot = app.profile.active_deck
deck = app.profile.decks[slot].cards.duplicate()
Style.label(self, "Deck bauen", 30, Style.GOLD, Rect2(20, 8, 400, 40), "left", true)
Style.label(self, "Klick: Karte ins Deck. Klick in der Liste: Karte entfernen. Mausrad: scrollen.",
14, Style.MUTED, Rect2(300, 18, 700, 24))
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14)
search_box = LineEdit.new()
search_box.position = Vector2(424, 55)
search_box.size = Vector2(136, 34)
search_box.placeholder_text = "Suche"
search_box.text_changed.connect(func(t: String):
search = t
refresh())
add_child(search_box)
sort_box = OptionButton.new()
sort_box.position = Vector2(570, 55)
sort_box.size = Vector2(130, 34)
for s in SORTS:
sort_box.add_item(s)
sort_box.item_selected.connect(func(i: int):
sort_mode = i
refresh())
add_child(sort_box)
missing_box = CheckButton.new()
missing_box.position = Vector2(710, 58)
missing_box.text = "Fehlende"
missing_box.toggled.connect(func(on: bool):
show_missing = on
refresh())
add_child(missing_box)
grid = CardGrid.new(Rect2(20, 100, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 690))
grid.card_clicked.connect(add)
grid.card_hover.connect(show_preview)
add_child(grid)
# Rechte Seite: Slots, Deckliste, Kurve
Style.panel(self, Rect2(845, 50, 420, 680))
for i in Profile.DECK_SLOTS:
slot_buttons.append(Style.button(self, str(i + 1), Rect2(860 + i * 46, 58, 40, 32), select_slot.bind(i)))
b_activate = Style.button(self, "Aktiv setzen", Rect2(1100, 58, 150, 32), activate)
name_box = LineEdit.new()
name_box.position = Vector2(860, 98)
name_box.size = Vector2(200, 30)
name_box.max_length = 20
name_box.text_submitted.connect(func(t: String): rename(t))
name_box.focus_exited.connect(func(): rename(name_box.text))
add_child(name_box)
deck_label = Style.label(self, "", 22, Style.TEXT, Rect2(1070, 98, 120, 30), "left", true)
active_label = Style.label(self, "", 14, Style.GREEN, Rect2(1180, 104, 75, 24), "right")
b_autofill = Style.button(self, "Auto-Ausfüllen", Rect2(1100, 154, 150, 26), autofill)
b_autofill.add_theme_font_size_override("font_size", 13)
elems_label = Style.label(self, "", 16, Style.MUTED, Rect2(860, 130, 390, 24))
valid_label = Style.label(self, "", 14, Style.GREEN, Rect2(860, 156, 235, 22))
deck_list = Control.new()
deck_list.position = Vector2(860, 182)
deck_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(deck_list)
curve = EnergyCurve.new()
curve.position = Vector2(860, 604)
curve.size = Vector2(400, 125)
curve.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(curve)
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
Style.button(self, "Speichern", Rect2(860, 740, 140, 44), save_deck, Color("285a3c"))
Style.button(self, "Leeren", Rect2(1008, 740, 70, 44), clear_deck)
Style.button(self, "Code", Rect2(1086, 740, 84, 44), open_code)
Style.button(self, "Zurück", Rect2(1178, 740, 82, 44), leave)
# Deck-Code zum Teilen: kopieren, einfügen, übernehmen
code_dialog = Style.overlay(self)
Style.panel(code_dialog, Rect2(240, 270, 800, 240), Style.PANEL, 10, Style.GOLD, 2)
Style.label(code_dialog, "Deck-Code", 26, Style.TEXT, Rect2(240, 290, 800, 40), "center", true)
Style.label(code_dialog, "Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.", 15, Style.MUTED, Rect2(240, 334, 800, 24), "center")
code_box = LineEdit.new()
code_box.position = Vector2(270, 368)
code_box.size = Vector2(740, 36)
code_dialog.add_child(code_box)
Style.button(code_dialog, "In Zwischenablage", Rect2(270, 430, 180, 44), func(): DisplayServer.clipboard_set(code_box.text))
Style.button(code_dialog, "Aus Zwischenablage", Rect2(460, 430, 180, 44), func(): code_box.text = DisplayServer.clipboard_get())
Style.button(code_dialog, "Übernehmen", Rect2(650, 430, 170, 44), func(): apply_code(code_box.text), Color("285a3c"))
Style.button(code_dialog, "Schließen", Rect2(830, 430, 180, 44), func(): code_dialog.visible = false)
confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.",
"Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD)
preview = CardView.new(Cards.all()[0], Vector2(200, 280))
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview.visible = false
add_child(preview)
refresh()
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:
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 confirm.visible:
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:
filter = e
refresh()
func select_slot(i: int) -> void:
slot = i
deck = app.profile.decks[slot].cards.duplicate()
say("", true)
refresh()
func rename(t: String) -> void:
if t.strip_edges() == app.profile.deck_name(slot):
return
var err: String = app.profile.rename_deck(slot, t)
if err == "":
app.save()
say("Umbenannt in %s" % app.profile.deck_name(slot), true)
else:
say(err, false)
refresh()
func autofill() -> void:
app.profile.decks[slot].cards = deck.duplicate()
var err: String = app.profile.autofill_deck(slot)
deck = app.profile.decks[slot].cards.duplicate()
if err == "":
app.save()
say("Aufgefüllt und gespeichert", true)
else:
say(err, false)
refresh()
func activate() -> void:
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
if not ok[0]:
say("Erst ein spielbares Deck speichern", false)
return
app.profile.decks[slot].cards = deck.duplicate()
app.profile.active_deck = slot
app.save()
say("%s ist jetzt das aktive Deck" % app.profile.deck_name(slot), true)
refresh()
## Karten der Sammlung (oder alle, wenn Fehlende gezeigt werden), gefiltert und sortiert.
func cards() -> Array:
var out: Array = Cards.all().duplicate() if show_missing else app.profile.owned_cards()
if filter != "":
out = out.filter(func(c): return filter in c.elements)
if search != "":
var q := search.to_lower()
out = out.filter(func(c): return q in c.name.to_lower() or q in c.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()
func clear_deck() -> void:
deck = []
refresh()
func save_deck() -> void:
var ok: Array = Game.validate_deck(deck_cards(), app.profile.collection)
if ok[0]:
app.profile.decks[slot].cards = deck.duplicate()
app.save()
say("%s gespeichert" % app.profile.deck_name(slot), true)
else:
say(ok[1], false)
refresh()
func 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 missing := {}
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:
var out := []
for cid in deck:
out.append(Cards.by_id(cid))
return out
func show_preview(card: Cards.Card, entered: bool) -> void:
if entered:
preview.card = card
preview.visible = true
preview.queue_redraw()
elif preview.card == card:
preview.visible = false
func refresh() -> void:
var p: Profile = app.profile
grid.fill(cards(),
func(c):
var owned: int = p.collection.get(c.id, 0)
return "fehlt" if owned == 0 else "%d/%d im Deck" % [deck.count(c.id), owned],
func(c):
var owned: int = p.collection.get(c.id, 0)
return owned == 0 or deck.count(c.id) >= mini(owned, Game.MAX_COPIES))
for i in slot_buttons.size():
var b: Button = slot_buttons[i]
var col: Color = Style.GOLD if i == slot else (Style.GREEN if p.deck_valid(i)[0] else Style.MUTED)
b.add_theme_stylebox_override("normal", Style.box(Style.PANEL_LIGHT if i != slot else Style.darker(Style.GOLD, 0.45), 6, col, 2))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(Style.PANEL_LIGHT), 6, col, 2))
active_label.text = "aktiv" if slot == p.active_deck else ""
# Deckliste
Style.clear(deck_list)
var counts := {}
for cid in deck:
counts[cid] = counts.get(cid, 0) + 1
var listed: Array = counts.keys().map(func(cid): return Cards.by_id(cid))
listed.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
for i in listed.size():
var c: Cards.Card = listed[i]
var row := DeckRow.new(c, counts[c.id], Vector2(400, 20))
row.position = Vector2(0, i * 21)
row.clicked.connect(remove)
row.hover.connect(show_preview)
deck_list.add_child(row)
var dc := deck_cards()
if not name_box.has_focus():
name_box.text = p.deck_name(slot)
deck_label.text = "%d/%d" % [deck.size(), Game.DECK_SIZE]
deck_label.tooltip_text = tr("Deckstärke: Durchschnitt aus Seltenheit, Schlüsselwörtern und Effekten")
deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT)
var elems := Game.deck_elements(dc)
elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b))
elems_label.text = tr("Elemente: ") + (", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else "–")
if not dc.is_empty():
elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc)
var ok: Array = Game.validate_deck(dc, p.collection)
valid_label.text = "spielbar" if ok[0] else ok[1]
valid_label.add_theme_color_override("font_color", Style.GREEN if ok[0] else Style.ORANGE)
curve.cards = dc
curve.queue_redraw()

View File

@@ -1 +0,0 @@
uid://yg8pbj56mib5

View File

@@ -19,7 +19,7 @@ var addr_label: Label
func _ready() -> void: func _ready() -> void:
net = app.net net = app.net
net.my_name = "Spieler" net.my_name = "Spieler"
net.my_deck = app.profile.deck.duplicate() net.my_pick = {"looks": app.profile.looks()}
var col := Style.column(self, 760, 40, 10) var col := Style.column(self, 760, 40, 10)
Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true) Style.clabel(col, "Netzwerkspiel", 36, Style.GOLD, "center", true)
Style.spacer(col, 10) Style.spacer(col, 10)

View File

@@ -23,7 +23,6 @@ func _ready() -> void:
net.name = "Net" net.name = "Net"
get_tree().root.add_child.call_deferred(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())
@@ -36,10 +35,9 @@ func apply_settings() -> void:
theme = Style.make_theme() theme = Style.make_theme()
## Neues Profil: Sammlung, Decks und Fortschritt werden verworfen. ## Neues Profil: Sammlung und Fortschritt werden verworfen.
func reset_profile() -> void: func reset_profile() -> void:
profile = Profile.starter() profile = Profile.starter()
profile.refresh_daily()
clear_battle_save() clear_battle_save()
save() save()
apply_settings() apply_settings()

View File

@@ -2,27 +2,19 @@ class_name MenuScreen
extends Control extends Control
var app var app
var b_single: Button
var b_campaign: Button
var b_online: Button
var b_resume: 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 col := Style.column(self, 640, 40, 5)
Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true) Style.clabel(col, "Dinger", 64, Style.GOLD, "center", true)
Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED) Style.clabel(col, "Ein Kartenspiel: jeder gegen jeden", 22, Style.MUTED)
Style.spacer(col, 8) Style.spacer(col, 8)
coins_label = Style.clabel(col, "", 20, Style.GOLD) coins_label = Style.clabel(col, "", 20, Style.GOLD)
coll_label = Style.clabel(col, "", 16, Style.MUTED) coll_label = Style.clabel(col, "", 16, Style.MUTED)
deck_label = Style.clabel(col, "", 16, Style.MUTED)
daily_label = Style.clabel(col, "", 16, Style.BLUE)
Style.spacer(col, 8) Style.spacer(col, 8)
var grid := GridContainer.new() var grid := GridContainer.new()
grid.columns = 2 grid.columns = 2
@@ -34,16 +26,14 @@ func _ready() -> void:
var sz := Vector2(270, 44) var sz := Vector2(270, 44)
if FileAccess.file_exists(app.battle_save_path()): if FileAccess.file_exists(app.battle_save_path()):
b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628")) b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628"))
b_single = Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c")) Style.cbutton(grid, "Einzelspieler", sz, func(): app.show_screen(OpponentSelectScreen.new()), Color("285a3c"))
b_campaign = Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c")) Style.cbutton(grid, "Kampagne", sz, func(): app.show_screen(CampaignScreen.new()), Color("285a3c"))
b_online = Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c")) Style.cbutton(grid, "Netzwerkspiel", sz, func(): app.show_screen(LobbyScreen.new()), Color("285a3c"))
Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new())) Style.cbutton(grid, "Booster öffnen", sz, func(): app.show_screen(BoosterScreen.new()))
Style.cbutton(grid, "Deck bauen", sz, func(): app.show_screen(DeckScreen.new()))
Style.cbutton(grid, "Sammlung", sz, func(): app.show_screen(CollectionScreen.new())) Style.cbutton(grid, "Sammlung", sz, func(): app.show_screen(CollectionScreen.new()))
Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new())) Style.cbutton(grid, "Einstellungen", sz, func(): app.show_screen(SettingsScreen.new()))
Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit()) Style.cbutton(grid, "Beenden", sz, func(): get_tree().quit())
Style.spacer(col, 10) Style.spacer(col, 10)
warn_label = Style.clabel(col, "", 16, Style.RED)
notice_label = Style.clabel(col, "", 16, Style.GREEN) notice_label = Style.clabel(col, "", 16, Style.GREEN)
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
refresh() refresh()
@@ -62,24 +52,9 @@ func _resume() -> void:
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("Aufgewertet: %d von %d Karten · Kampagne: %d/%d") % [
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()] p.upgraded_count(), Cards.all().size(), p.campaign_stage, Campaign.total()]
var elems := Game.deck_elements(p.deck_cards())
deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else tr("leer")]
var task := p.daily_task()
if p.daily.done:
daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text
else:
daily_label.text = tr("Tagesaufgabe: %s (%d/%d) · +%d Münzen") % [task.text, p.daily.progress, task.goal, task.reward]
warn_label.text = "" if ok[0] else tr("Aktives Deck nicht spielbar: ") + tr(ok[1])
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():

View File

@@ -1,10 +1,9 @@
class_name OpponentSelectScreen class_name OpponentSelectScreen
extends Control extends Control
## Einzelspieler: Schwierigkeit wählen und die Gegnerzahl schieben. Die Gegnerdecks ## Einzelspieler: Schwierigkeit und Gegnerzahl wählen. Die Karten kommen im Kampf
## werden erst beim Start zufällig gezogen (siehe BattleScreen und DeckGen). ## aus dem Laden (siehe DeckGen.shop_cards).
var app var app
var rating_label: Label
var diff_buttons := {} var diff_buttons := {}
var count_slider: HSlider var count_slider: HSlider
var count_label: Label var count_label: Label
@@ -12,17 +11,16 @@ var info_label: Label
func _ready() -> void: func _ready() -> void:
var col := Style.column(self, 1000, 60, 14) var col := Style.column(self, 1000, 50, 12)
Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true) Style.clabel(col, "Einzelspieler", 36, Style.GOLD, "center", true)
Style.spacer(col, 10) Style.spacer(col, 8)
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, 20)
Style.spacer(col, 24)
count_label = Style.clabel(col, "", 20, Style.TEXT, "center", true) count_label = Style.clabel(col, "", 20, Style.TEXT, "center", true)
var sr := Style.row(col, 12) var sr := Style.row(col, 12)
sr.alignment = BoxContainer.ALIGNMENT_CENTER sr.alignment = BoxContainer.ALIGNMENT_CENTER
@@ -39,7 +37,7 @@ func _ready() -> void:
sr.add_child(count_slider) sr.add_child(count_slider)
Style.clabel(sr, str(Game.MAX_OPPONENTS), 15, Style.MUTED, "left", false, 20) 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")) Style.cbutton(br, "Starten", Vector2(190, 46), start, Color("285a3c"))
@@ -64,16 +62,18 @@ func start() -> void:
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
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))
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))
rating_label.text = tr("Deine Deckstärke %.1f · Gegnerdecks %.1f") % [p.deck_rating(), p.target_rating()]
var n := opponents() var n := opponents()
count_label.text = tr("1 Gegner") if n == 1 else tr("%d Gegner") % n count_label.text = tr("1 Gegner") if n == 1 else tr("%d Gegner") % n
info_label.text = (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n] info_label.text = (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n]
func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void:
var base: Color = col if active else Style.PANEL_LIGHT
b.add_theme_stylebox_override("normal", Style.box(base, 8, Style.GOLD if active else Style.MUTED, 2))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 8, Style.GOLD if active else Style.MUTED, 2))
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())

View File

@@ -76,13 +76,12 @@ 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())) Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Decks, Münzen und Fortschritt gehen verloren.", confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.",
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED) "Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
_labels() _labels()

View File

@@ -2,8 +2,6 @@ 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.8
@@ -14,7 +12,6 @@ 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)

View File

@@ -2,12 +2,8 @@ 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“
const BG := Color("1a1c24") const BG := Color("1a1c24")
const PANEL := Color("282b36") const PANEL := Color("282b36")
@@ -20,6 +16,12 @@ 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")
## Schriftgröße mit der Einstellung „Große Schrift“.
static func fs(size: int) -> int:
return int(round(size * font_scale))
static func darker(c: Color, f: float = 0.65) -> Color: static func darker(c: Color, f: float = 0.65) -> Color:
@@ -244,65 +246,42 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius
ci.draw_style_box(sb, rect) ci.draw_style_box(sb, rect)
## Hintergrund in Elementfarbe; ein verschmolzenes Wesen zeigt seine Elemente als Streifen.
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void: static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
var a: Color = Cards.ELEMENT_COLORS[elements[0]]
if elements.size() == 1: if elements.size() == 1:
ci.draw_style_box(box(a, radius), rect) ci.draw_style_box(box(Cards.ELEMENT_COLORS[elements[0]], radius), rect)
elif elements[0] == elements[1]: return
# Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen var n := elements.size()
ci.draw_style_box(box(a, radius), rect) var w := rect.size.x / n
border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3)) for i in n:
else: var sb := StyleBoxFlat.new()
var b: Color = Cards.ELEMENT_COLORS[elements[1]] sb.bg_color = Cards.ELEMENT_COLORS[elements[i]]
var half := floorf(rect.size.x / 2) if i == 0:
var left := StyleBoxFlat.new() sb.corner_radius_top_left = radius
left.bg_color = a sb.corner_radius_bottom_left = radius
left.corner_radius_top_left = radius if i == n - 1:
left.corner_radius_bottom_left = radius sb.corner_radius_top_right = radius
var right := StyleBoxFlat.new() sb.corner_radius_bottom_right = radius
right.bg_color = b ci.draw_style_box(sb, Rect2(rect.position + Vector2(floorf(i * w), 0), Vector2(ceilf(w), rect.size.y)))
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) -------------------------- # --- Kartensymbole (prozedural, je Element) --------------------------
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void: static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> 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 = 0.95
if card.is_trap():
# Falle: Kreuz im Kreis
ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08))
var d := size * 0.28
ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1))
ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1))
return
if card.kind == "Zauber":
_star(ci, center, size * 0.55, Color(col, 0.35))
var elems: Array = card.elements 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 = 0.95
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void: if elems.size() >= 3:
var pts := PackedVector2Array() ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
for i in 8:
var a := TAU * i / 8 - PI / 2
var rr := r if i % 2 == 0 else r * 0.45
pts.append(c + Vector2(cos(a), sin(a)) * rr)
ci.draw_colored_polygon(pts, col)
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void: static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:

View File

@@ -19,14 +19,11 @@ func _ready() -> void:
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(): scr.game.buy(h, 0)
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 Gegnern und 8 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()

View File

@@ -1,35 +0,0 @@
extends SceneTree
## Misst Deckarchetypen im Duell (beide gierig): wer beginnt, Mono gegen Paar.
func deck(r: RandomNumberGenerator, mono: bool) -> Array:
var pool: Array = Cards.ELEMENTS.duplicate()
Game.shuffle(pool, r)
return Game.build_opponent_deck(r, [pool[0]] if mono else [pool[0], pool[1]])
func duel(r: RandomNumberGenerator, first_mono: bool, second_mono: bool) -> bool:
var second := Game.Player.new("Gegner 1", deck(r, second_mono), false, Game.START_LIFE, "x")
var g := Game.new_battle_with(deck(r, first_mono), [second], r)
var guard := 0
while g.winner == "" and guard < 300:
AI.take_turn(g, g.current(), true)
guard += 1
return g.winner == "human"
func run(label: String, a_mono: bool, b_mono: bool, games: int, r: RandomNumberGenerator) -> void:
var wins := 0
for i in games:
if duel(r, a_mono, b_mono):
wins += 1
print("%-28s Startspieler gewinnt %3d%%" % [label, wins * 100 / games])
func _initialize() -> void:
var r := RandomNumberGenerator.new()
r.seed = 5
run("Paar gegen Paar", false, false, 200, r)
run("Mono gegen Mono", true, true, 200, r)
run("Paar (startet) gegen Mono", false, true, 200, r)
run("Mono (startet) gegen Paar", true, false, 200, r)
quit()

View File

@@ -1 +0,0 @@
uid://bu5hjcfuinhy8

View File

@@ -25,7 +25,7 @@ func _ready() -> void:
net.name = "Net" net.name = "Net"
get_tree().root.add_child.call_deferred(net) get_tree().root.add_child.call_deferred(net)
net.my_name = bot_name net.my_name = bot_name
net.my_deck = Profile.starter().deck.duplicate() net.my_pick = {"elements": ["Feuer", "Erde"], "looks": {}}
net.started.connect(func(s): net.started.connect(func(s):
seat = s seat = s
if hosting: if hosting:
@@ -73,16 +73,18 @@ func _act_host() -> void:
_decide(game.players[seat]) _decide(game.players[seat])
## Einfache Züge mit kurzer Bedenkzeit: Mulligan überspringen, erstes spielbares Wesen ohne Ziel, ## Einfache Züge mit kurzer Bedenkzeit: erst das billigste bezahlbare Angebot kaufen, dann das
## dann angreifen, sonst Zug beenden. ## erste spielbare Wesen ohne Ziel, dann angreifen, sonst Zug beenden.
func _decide(me: Game.Player) -> void: func _decide(me: Game.Player) -> void:
var g := game var g := game
await get_tree().create_timer(0.25).timeout await get_tree().create_timer(0.25).timeout
if game != g or (hosting and (net.game == null or net.game.current_idx != seat)): if game != g or (hosting and (net.game == null or net.game.current_idx != seat)):
return # inzwischen ein neuer Zustand return # inzwischen ein neuer Zustand
if not me.mulligan_done and me.turns == 1: for i in me.shop.size():
net.send_action({"type": "mulligan", "hand": []}) var offer = me.shop[i]
return if offer != null and me.energy >= offer.cost and me.hand.size() < Game.MAX_HAND:
net.send_action({"type": "buy", "slot": i})
return
for i in me.hand.size(): for i in me.hand.size():
var card: Cards.Card = me.hand[i] var card: Cards.Card = me.hand[i]
if game.can_play(me, card)[0] and card.target_type() == "": if game.can_play(me, card)[0] and card.target_type() == "":

View File

@@ -14,18 +14,16 @@ 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("Ladenangebot: %.2f ms" % ms(func(): DeckGen.shop_cards(r, 5), 50))
print("Gegnerdeck (Zielstärke): %.1f ms" % ms(func(): DeckGen.build_opponent_deck_rated(r, ["Feuer", "Erde"], 1.5), 10))
print("Vorschau 1..5 Gegner: %.1f ms" % ms(func(): print("Vorschau 1..5 Gegner: %.1f ms" % ms(func():
for n in range(1, 6): for n in range(1, 6):
for i in n: for i in n:
Game.random_opponent(r, i + 1, Game.START_LIFE, 1.3, ""))) DeckGen.random_opponent(i + 1)))
for n in [1, 3, 5]: for n in [1, 3, 5]:
var g := Game.new_battle(starter, n, r, {"target_rating": 1.5}) var g := Game.new_battle(n, r)
# 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_wolf"), p, uid)
uid += 1 uid += 1
@@ -36,15 +34,14 @@ func _initialize() -> void:
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_ritter"))
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(5, 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(5, r).snapshot(0), 10))
print("Snapshot 6 Spieler: %.2f ms" % ms(func(): Game.new_battle(starter, 5, r).snapshot(0), 10))
quit() quit()

View File

@@ -8,31 +8,26 @@ 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("Alle gierig (Erwartung etwa 1/(Gegner+1)):")
for n in range(1, Game.MAX_OPPONENTS + 1): for n in range(1, Game.MAX_OPPONENTS + 1):
_run("Zufallsdeck", random_deck, n, games, r, true, true) _run(n, games, r, true, true)
print("Du mit Vorausschau, Gegner gierig:") print("Du mit Vorausschau, Gegner gierig:")
for n in [1, 2, 3]: for n in [1, 2, 3]:
_run("Zufallsdeck", random_deck, n, games / 2, r, false, true) _run(n, games / 2, r, false, true)
print("Alle mit Vorausschau:") print("Alle mit Vorausschau:")
_run("Starterdeck", func(_rr): return starter, 1, games / 2, r, false, false) _run(1, games / 2, r, false, false)
_run("Zufallsdeck", random_deck, 2, games / 4, r, false, false) _run(2, games / 4, r, false, false)
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 _run(n_opp: int, games: int, r: RandomNumberGenerator,
human_greedy: bool, opp_greedy: bool) -> void: 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(n_opp, 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 +35,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("gegen %d: Siege %3d%% Ø Züge %.1f" % [n_opp, wins * 100 / games, float(turns) / games])

View File

@@ -1,41 +0,0 @@
extends SceneTree
## Archetyp-Stärke: jede Elementkombination gegen zufällige andere, gleiche Zielstärke, beide KI.
func archetypes() -> Array:
var out := []
for e in Cards.ELEMENTS:
out.append([e])
for i in Cards.ELEMENTS.size():
for j in range(i + 1, Cards.ELEMENTS.size()):
out.append([Cards.ELEMENTS[i], Cards.ELEMENTS[j]])
return out
func _initialize() -> void:
var r := RandomNumberGenerator.new()
r.seed = 21
var arts := archetypes()
var n := int(OS.get_environment("N")) if OS.get_environment("N") != "" else 60
var results := {}
for a in arts:
var wins := 0
for i in n:
var b: Array = arts[r.randi_range(0, arts.size() - 1)]
var deck_a := Game.build_opponent_deck_rated(r, a, 1.6)
var deck_b := Game.build_opponent_deck_rated(r, b, 1.6)
var opp := Game.Player.new("Gegner 1", deck_b, false, Game.START_LIFE, "x")
var g := Game.new_battle_with(deck_a, [opp], r)
var guard := 0
while g.winner == "" and guard < 300:
AI.take_turn(g, g.current())
guard += 1
if g.winner == "human":
wins += 1
results["+".join(a)] = wins * 100 / n
print("%-14s %3d%%" % ["+".join(a), wins * 100 / n])
var f := FileAccess.open(DeckGen.ARCHETYPE_PATH, FileAccess.WRITE)
f.store_string(JSON.stringify(results, "\t"))
f.close()
print("nach %s geschrieben" % DeckGen.ARCHETYPE_PATH)
quit()

View File

@@ -1 +0,0 @@
uid://d4m21t7od3kdm

File diff suppressed because it is too large Load Diff

View File

@@ -83,11 +83,6 @@ func pad(button: int) -> void:
## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist. ## Mulligan-Maske wegklicken (Hand behalten), falls sie offen ist.
func clear_mulligan(scr) -> void:
if scr.mulligan_box.visible:
await click(scr.b_keep)
func hover(node: Control) -> void: func hover(node: Control) -> void:
var ev := InputEventMouseMotion.new() var ev := InputEventMouseMotion.new()
ev.position = node.get_global_rect().get_center() ev.position = node.get_global_rect().get_center()
@@ -123,15 +118,6 @@ func find_button(text: String, root: Node = null) -> Button:
return null return null
func card_views(root: Node) -> Array:
var out := []
for c in root.get_children():
if c is CardView:
out.append(c)
out.append_array(card_views(c))
return out
## Wartet, bis keine Kachel mehr animiert wird (Angriffe verschieben Kacheln kurz). ## Wartet, bis keine Kachel mehr animiert wird (Angriffe verschieben Kacheln kurz).
func settle(scr) -> void: func settle(scr) -> void:
for i in 40: for i in 40:
@@ -147,7 +133,6 @@ func wait_for_human(scr) -> void:
await frames(1) await frames(1)
wait += 1 wait += 1
await frames(1) await frames(1)
await clear_mulligan(scr)
func _ready() -> void: func _ready() -> void:
@@ -164,9 +149,9 @@ func _ready() -> void:
add_child(main) add_child(main)
await frames(3) await frames(3)
var fast := OS.get_environment("DINGER_FAST") != "" var fast := OS.get_environment("DINGER_FAST") != ""
var suite := [test_menu, test_gamepad_menue, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen, var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen,
test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_feld_und_verschmelzen, test_kampf_grenzen,
test_kampf_fortsetzen, test_gamepad_deckbau, test_deckcode, test_elementbooster] test_kampf_fortsetzen]
if not fast: if not fast:
suite += [test_kampf.bind(2, 3), test_kampf_abbrechen] suite += [test_kampf.bind(2, 3), test_kampf_abbrechen]
# Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten. # Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten.
@@ -209,108 +194,16 @@ func test_booster() -> void:
check(scr.revealed.size() == 5, "alle aufgedeckt") check(scr.revealed.size() == 5, "alle aufgedeckt")
await shot("04_booster_offen") await shot("04_booster_offen")
check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert") check(Profile.load_or_new(SAVE).coins == main.profile.coins, "Kauf gespeichert")
var ids: Array = scr.cards.map(func(c): return c.id) for res in scr.cards:
var better: Array = main.profile.deck_suggestions(ids) if res.upgraded:
check(scr.b_improve.visible == (not better.is_empty()), "Deck-Vorschlag nur bei passender Karte") check(main.profile.level(res.card.id) == Cards.RARITIES.find(res.rarity), "aufgewertete Karte trägt ihre Stufe")
if scr.b_improve.visible: else:
var deck_before: Array = main.profile.deck.duplicate() check(res.dust > 0, "sonst gibt es Staub")
await click(scr.b_improve)
check(main.profile.deck != deck_before and main.profile.deck_valid()[0] and "im Deck" in scr.msg_label.text, "Karte ins Deck übernommen")
check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand") check(scr.b_buy.disabled == (main.profile.coins < Profile.BOOSTER_COST), "Kaufknopf folgt dem Münzstand")
await click(find_button("Zurück")) await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
func test_deckbau() -> void:
await click(find_button("Deck bauen"))
var scr = screen()
check(scr is DeckScreen, "Deckbildschirm")
check(scr.deck.size() == 20, "Deck geladen")
check("Stärke" in scr.elems_label.text, "Deckstärke im Deckbau")
await shot("05_deck")
await click(find_button("Leeren"))
check(scr.deck.is_empty(), "geleert")
var first_id: String = card_views(scr.grid)[0].card.id
var allowed: int = mini(main.profile.collection[first_id], Game.MAX_COPIES)
await click(card_views(scr.grid)[0])
check(scr.deck == [first_id], "Karte hinzugefügt")
for i in allowed:
await click(card_views(scr.grid)[0])
check(scr.deck.count(first_id) == allowed and "Kopien" in scr.msg_label.text, "nur besessene Kopien (%d)" % allowed)
await click(find_button("Speichern"))
check(main.profile.deck.size() == 20 and scr.msg_label.text != "Deck gespeichert", "zu kleines Deck nicht gespeichert")
await click(find_button("Wasser"))
await click(card_views(scr.grid)[0])
await click(find_button("Erde"))
await click(card_views(scr.grid)[0])
check("Elemente" in scr.msg_label.text, "drittes Element abgelehnt")
await click(find_button("Alle"))
# Suche, Sortierung, fehlende Karten
var all_count := card_views(scr.grid).size()
scr.search_box.text = "Wächter"
scr.search_box.text_changed.emit("Wächter")
await frames(1)
var found := card_views(scr.grid)
check(found.size() > 0 and found.size() < all_count, "Suche filtert")
check(found.all(func(v): return "Wächter" in v.card.text() or "Wächter" in v.card.name), "Suche trifft Kartentext")
scr.search_box.text = ""
scr.search_box.text_changed.emit("")
scr.sort_box.select(1)
scr.sort_box.item_selected.emit(1)
await frames(1)
var names: Array = card_views(scr.grid).map(func(v): return v.card.name)
var sorted_names := names.duplicate()
sorted_names.sort()
check(names == sorted_names, "Sortierung nach Name")
scr.missing_box.button_pressed = true
await frames(1)
check(card_views(scr.grid).size() == Cards.all().size(), "Fehlende Karten eingeblendet")
var missing = card_views(scr.grid).filter(func(v): return main.profile.collection.get(v.card.id, 0) == 0)
check(not missing.is_empty(), "es gibt fehlende Karten")
await click(missing[0])
check("fehlt" in scr.msg_label.text, "fehlende Karte nicht ins Deck")
scr.missing_box.button_pressed = false
scr.sort_box.select(0)
scr.sort_box.item_selected.emit(0)
await frames(1)
var n: int = scr.deck.size()
await click(scr.deck_list.get_children()[0])
check(scr.deck.size() == n - 1, "Zeile entfernt Karte")
await hover(card_views(scr.grid)[0])
check(scr.preview.visible, "Vorschau bei Hover")
await shot("06_deck_bearbeitet")
# Slots: Deck 2 aus Deck 1 kopieren, speichern, aktiv setzen
await click(scr.slot_buttons[1])
if scr.slot != 1 or not scr.deck.is_empty():
print("DEBUG Slot: slot=", scr.slot, " deck=", scr.deck.size(), " rect=", scr.slot_buttons[1].get_global_rect(),
" hovered=", get_viewport().gui_get_hovered_control(), " decks1=", main.profile.decks[1].cards.size())
check(scr.slot == 1 and scr.deck.is_empty(), "Slot 2 leer")
scr.deck = main.profile.decks[0].cards.duplicate()
scr.refresh()
await click(find_button("Speichern"))
check(main.profile.decks[1].cards.size() == 20 and "gespeichert" in scr.msg_label.text, "Slot 2 gespeichert")
await click(find_button("Aktiv setzen"))
check(main.profile.active_deck == 1 and main.profile.deck_name() == "Deck 2", "Slot 2 aktiv")
check(Profile.load_or_new(SAVE).active_deck == 1, "aktiver Slot gespeichert")
scr.name_box.text = "Testdeck"
scr.name_box.text_submitted.emit("Testdeck")
check(main.profile.deck_name(1) == "Testdeck" and Profile.load_or_new(SAVE).deck_name(1) == "Testdeck", "Deck umbenannt")
await click(scr.slot_buttons[2])
check(scr.slot == 2 and scr.deck.is_empty(), "Slot 3 leer")
await click(find_button("Auto-Ausfüllen"))
check(scr.deck.size() == 20 and main.profile.deck_valid(2)[0], "Slot 3 automatisch gefüllt und spielbar")
await shot("25_deck_slots")
await click(scr.deck_list.get_children()[0]) # ungespeicherte Änderung: eine Karte entfernt
check(scr.dirty(), "Deck geändert")
await click(find_button("Zurück"))
check(scr.confirm.visible and screen() == scr, "Warnung bei ungespeicherten Änderungen")
await click(find_button("Weiter bearbeiten"))
check(not scr.confirm.visible, "weiter bearbeiten")
await click(find_button("Zurück"))
await click(find_button("Verwerfen"))
check(screen() is MenuScreen and main.profile.deck.size() == 20, "Änderungen verworfen")
func target_view(scr, valid: Array) -> Control: func target_view(scr, valid: Array) -> Control:
for v in scr.enemy_views: for v in scr.enemy_views:
if v.creature in valid: if v.creature in valid:
@@ -326,17 +219,26 @@ func target_view(scr, valid: Array) -> Control:
func human_turn(scr) -> void: func human_turn(scr) -> void:
var g: Game = scr.game var g: Game = scr.game
await clear_mulligan(scr) # erst kaufen, solange Mana und Handplatz reichen
for i in 12:
var offers: Array = scr.shop_views.filter(func(v): return g.human.energy >= v.card.cost and g.human.hand.size() < Game.MAX_HAND)
if offers.is_empty():
break
var offer: Cards.Card = offers[0].card
var mana: int = g.human.energy
await click(offers[0])
check(g.human.energy == mana - offer.cost, "gekauft: " + offer.name)
# dann ausspielen, das kostet nichts
for i in 12: for i in 12:
var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0]) var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0])
if playable.is_empty(): if playable.is_empty():
break break
var card: Cards.Card = playable[0].card var card: Cards.Card = playable[0].card
var energy: int = g.human.energy var board_before: int = g.human.board.size()
await click(playable[0]) await click(playable[0])
if scr.pending != null: if scr.pending != null:
await click(target_view(scr, g.valid_targets(g.human, card.target_type()))) await click(target_view(scr, g.valid_targets(g.human, card.target_type())))
check(g.human.energy == energy - card.cost, "Karte gespielt: " + card.name) check(g.human.board.size() > board_before, "Karte gespielt: " + card.name)
for i in 12: for i in 12:
var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack()) var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack())
if ready.is_empty() or g.winner != "": if ready.is_empty() or g.winner != "":
@@ -377,9 +279,8 @@ func test_gegnerauswahl() -> void:
await click(find_button("Einzelspieler")) await click(find_button("Einzelspieler"))
var sel = screen() var sel = screen()
check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet") check(sel is OpponentSelectScreen, "Gegnerauswahl geöffnet")
check("Deckstärke" in sel.rating_label.text and "Gegnerdecks" in sel.rating_label.text
and not ("Archetypen" in sel.rating_label.text or "KI" in sel.rating_label.text), "unter der Schwierigkeit nur die zwei Stärken")
check(find_button("Neu würfeln") == null and find_button("1 Gegner") == null and find_button("Starten") != null, "kein Würfeln und keine Gegnerknöpfe, nur Starten") check(find_button("Neu würfeln") == null and find_button("1 Gegner") == null and find_button("Starten") != null, "kein Würfeln und keine Gegnerknöpfe, nur Starten")
check(find_button("Feuer") == null and find_button("Luft") == null, "keine Elementwahl mehr")
check(sel.count_slider.min_value == 1 and sel.count_slider.max_value == Game.MAX_OPPONENTS and sel.count_slider.step == 1, "Gegnerzahl als Schieberegler von 1 bis %d" % Game.MAX_OPPONENTS) check(sel.count_slider.min_value == 1 and sel.count_slider.max_value == Game.MAX_OPPONENTS and sel.count_slider.step == 1, "Gegnerzahl als Schieberegler von 1 bis %d" % Game.MAX_OPPONENTS)
check(sel.opponents() == 1 and sel.count_label.text == "1 Gegner" and "Duell" in sel.info_label.text, "Regler beginnt beim Duell") check(sel.opponents() == 1 and sel.count_label.text == "1 Gegner" and "Duell" in sel.info_label.text, "Regler beginnt beim Duell")
var rect: Rect2 = sel.count_slider.get_global_rect() var rect: Rect2 = sel.count_slider.get_global_rect()
@@ -400,17 +301,17 @@ func test_gegnerauswahl() -> void:
await click(find_button("Starten")) await click(find_button("Starten"))
var hard = screen() var hard = screen()
check(hard is BattleScreen and hard.game.opponents.size() == 1, "Kampf mit einem Gegner gestartet") check(hard is BattleScreen and hard.game.opponents.size() == 1, "Kampf mit einem Gegner gestartet")
var own := Game.deck_rating(main.profile.deck_cards())
var opp: Game.Player = hard.game.opponents[0] var opp: Game.Player = hard.game.opponents[0]
var opp_rating := Game.deck_rating(opp.deck + opp.hand) check(opp.max_life == Profile.DIFFICULTIES.schwer.life and hard.smart_ai, "Schwer: Gegner mit %d Leben, Vorausschau-KI" % Profile.DIFFICULTIES.schwer.life)
check(opp_rating >= own * 1.05 - 0.05 and hard.smart_ai, "Schwer: Gegnerdeck stärker (%.2f zu %.2f), Vorausschau-KI" % [opp_rating, own]) check(hard.game.human.shop.size() == Game.SHOP_SIZE and hard.game.human.hand.is_empty(),
"Start mit vollem Laden und leerer Hand")
await give_up() await give_up()
await click(find_button("Einzelspieler")) await click(find_button("Einzelspieler"))
await click(find_button("Leicht")) await click(find_button("Leicht"))
await click(find_button("Starten")) await click(find_button("Starten"))
var easy = screen() var easy = screen()
opp = easy.game.opponents[0] opp = easy.game.opponents[0]
check(Game.deck_rating(opp.deck + opp.hand) <= own * 1.0 + 0.15 and not easy.smart_ai, "Leicht: schwächeres Deck, einfache KI") check(opp.max_life == Profile.DIFFICULTIES.leicht.life and not easy.smart_ai, "Leicht: Gegner mit %d Leben, einfache KI" % Profile.DIFFICULTIES.leicht.life)
await give_up() await give_up()
await click(find_button("Einzelspieler")) await click(find_button("Einzelspieler"))
await click(find_button("Normal")) await click(find_button("Normal"))
@@ -438,7 +339,6 @@ func leave_battle() -> void:
func give_up() -> void: func give_up() -> void:
var scr = screen() var scr = screen()
await clear_mulligan(scr)
await click(find_button("Aufgeben")) await click(find_button("Aufgeben"))
check(scr.confirm.visible, "Aufgeben fragt nach") check(scr.confirm.visible, "Aufgeben fragt nach")
await click(find_button("Weiterspielen")) await click(find_button("Weiterspielen"))
@@ -448,71 +348,6 @@ func give_up() -> void:
check(screen() is MenuScreen, "Aufgeben führt ins Menü") check(screen() is MenuScreen, "Aufgeben führt ins Menü")
## Gamepad im Deckbau: Karten und Deckzeilen sind fokussierbar, A wirkt wie ein Klick, B geht zurück.
func test_gamepad_deckbau() -> void:
await click(find_button("Deck bauen"))
var scr = screen()
var before: int = scr.deck.size()
await pad(JOY_BUTTON_DPAD_DOWN)
check(get_viewport().gui_get_focus_owner() != null and main.pad_mode, "Gamepad setzt Fokus im Deckbau")
var row: Control = scr.deck_list.get_child(0)
row.grab_focus()
await frames(1)
check(row.has_focus() and scr.preview.visible, "Deckzeile fokussierbar, Vorschau folgt")
await pad(JOY_BUTTON_A)
check(scr.deck.size() == before - 1, "A entfernt die fokussierte Karte aus dem Deck")
var elems: Array = Game.deck_elements(scr.deck_cards())
var target: CardView = null
for v in scr.grid.views():
if not v.dim and v.card.elements.all(func(e): return e in elems):
target = v
break
target.grab_focus()
await frames(1)
check(target.has_focus() and scr.preview.visible and scr.preview.card == target.card, "Karte im Raster fokussierbar mit Vorschau")
var target_id: String = target.card.id
await pad(JOY_BUTTON_A) # das Raster wird danach neu aufgebaut, target ist dann freigegeben
check(scr.deck.size() == before and target_id in scr.deck, "A nimmt die fokussierte Karte ins Deck (%d Karten, Meldung '%s')" % [scr.deck.size(), scr.msg_label.text])
await shot("31_gamepad_deckbau")
await pad(JOY_BUTTON_B)
check(scr.confirm.visible, "B fragt bei ungespeicherten Änderungen nach")
await click(find_button("Verwerfen"))
check(screen() is MenuScreen, "Verwerfen führt ins Menü")
await hover(main)
## Deck-Code kopieren, einfügen und übernehmen.
func test_deckcode() -> void:
await click(find_button("Deck bauen"))
var scr = screen()
await click(find_button("Code"))
check(scr.code_dialog.visible and scr.code_box.text.begins_with(Profile.CODE_PREFIX), "Code-Dialog zeigt den Deck-Code")
await click(find_button("In Zwischenablage"))
check(DisplayServer.clipboard_get() == scr.code_box.text, "Code in der Zwischenablage")
await shot("32_deckcode")
scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,x_nix*1"
await click(find_button("Übernehmen"))
check(scr.deck.size() == Game.DECK_SIZE and "Ungültiger Code" in scr.msg_label.text, "ungültiger Code wird abgelehnt")
scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,fff_sonne*1"
await click(find_button("Übernehmen"))
check(scr.deck == ["f_funke", "f_funke", "e_wall"] and "Kern der Sonne" in scr.msg_label.text and not scr.code_dialog.visible, "Code übernommen, fehlende Karte gemeldet")
await click(find_button("Zurück"))
await click(find_button("Verwerfen"))
check(screen() is MenuScreen, "Deckbau verlassen")
## Elementbooster liefert nur Karten des Elements.
func test_elementbooster() -> void:
main.profile.coins = 300
await click(find_button("Booster öffnen"))
var scr = screen()
await click(find_button("Feuer"))
check(scr.cards.size() == 5 and scr.cards.all(func(c): return "Feuer" in c.elements) and main.profile.coins == 300 - Profile.ELEMENT_BOOSTER_COST, "Feuer-Booster: fünf Feuerkarten")
await click(find_button("Alle aufdecken"))
await shot("33_elementbooster")
await click(find_button("Zurück"))
## Kampf speichern, im Menü fortsetzen, Aufgeben löscht die Datei. ## Kampf speichern, im Menü fortsetzen, Aufgeben löscht die Datei.
func test_kampf_fortsetzen() -> void: func test_kampf_fortsetzen() -> void:
await start_battle(1) await start_battle(1)
@@ -569,28 +404,20 @@ func test_sammlung() -> void:
var scr = screen() var scr = screen()
check(scr is CollectionScreen, "Sammlung geöffnet") check(scr is CollectionScreen, "Sammlung geöffnet")
var p: Profile = main.profile var p: Profile = main.profile
p.dust = 0 check(scr.grid.views().size() == Cards.all().size(), "alle Karten gehören dir")
var in_decks := {}
for d in p.decks:
for cid in d.cards:
in_decks[cid] = true
var free = scr.grid.views().filter(func(v): return p.collection.get(v.card.id, 0) > 0 and not in_decks.has(v.card.id))
check(not free.is_empty(), "eine freie Karte zum Zerlegen")
var card: Cards.Card = free[0].card
await click(free[0])
check(scr.selected == card, "Karte gewählt")
var before: int = p.collection[card.id]
await click(scr.b_dismantle)
check(p.collection[card.id] == before - 1 and p.dust == Profile.DUST_DISMANTLE[card.rarity], "zerlegt, Staub gutgeschrieben")
p.dust = 5000 p.dust = 5000
scr.search_box.text = "Phönix" scr.search_box.text = "Phönix"
scr.search_box.text_changed.emit("Phönix") scr.search_box.text_changed.emit("Phönix")
await frames(1) await frames(1)
var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0] var phoenix = scr.grid.views().filter(func(v): return v.card.id == "ff_phoenix")[0]
await click(phoenix) await click(phoenix)
await click(scr.b_craft) check(scr.selected.id == "ff_phoenix" and p.rarity("ff_phoenix") == "gewöhnlich", "Karte gewählt, noch gewöhnlich")
check(p.collection.get("ff_phoenix", 0) == 1 and p.dust == 5000 - Profile.DUST_CRAFT["legendär"], "Phönix hergestellt") await click(scr.b_upgrade)
check(Profile.load_or_new(SAVE).collection.get("ff_phoenix", 0) == 1, "Sammlung gespeichert") check(p.level("ff_phoenix") == 1 and p.dust == 5000 - Profile.DUST_UPGRADE["selten"], "auf selten aufgewertet")
check(Profile.load_or_new(SAVE).level("ff_phoenix") == 1, "Sammlung gespeichert")
var dust: int = p.dust
await click(scr.b_dismantle)
check(p.level("ff_phoenix") == 0 and p.dust == dust + Profile.DUST_DISMANTLE["selten"], "wieder zerlegt")
await shot("20_sammlung") await shot("20_sammlung")
await click(find_button("Zurück")) await click(find_button("Zurück"))
check(screen() is MenuScreen, "zurück im Menü") check(screen() is MenuScreen, "zurück im Menü")
@@ -634,7 +461,7 @@ func test_profil_zuruecksetzen() -> void:
check(not scr.confirm.visible, "abgebrochen") check(not scr.confirm.visible, "abgebrochen")
await click(find_button("Profil zurücksetzen")) await click(find_button("Profil zurücksetzen"))
await click(find_button("Ja, zurücksetzen")) await click(find_button("Ja, zurücksetzen"))
check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and main.profile.collection.size() > 0, "Profil zurückgesetzt") check(screen() is MenuScreen and main.profile.coins == Profile.START_COINS and main.profile.collection.is_empty(), "Profil zurückgesetzt")
check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert") check(Profile.load_or_new(SAVE).coins == Profile.START_COINS, "zurückgesetzt gespeichert")
@@ -646,7 +473,7 @@ func test_sprache() -> void:
await frames(2) await frames(2)
check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch") check(TranslationServer.get_locale().begins_with("en") and main.profile.settings.language == "en", "Sprache auf Englisch")
check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen") check(TranslationServer.translate("Zurück") == "Back" and TranslationServer.translate("Einzelspieler") == "Single player", "Übersetzung geladen")
check(TranslationServer.translate("Zug %d · Du bist dran") == "Turn %d · Your turn" and TranslationServer.translate("Nicht genug Energie") == "Not enough energy", "dynamische Texte übersetzt") check(TranslationServer.translate("Zug %d · Du bist dran") == "Turn %d · Your turn" and TranslationServer.translate("Nicht genug Mana") == "Not enough mana", "dynamische Texte übersetzt")
check(find_button("Back") != null, "Knöpfe übersetzt") check(find_button("Back") != null, "Knöpfe übersetzt")
await shot("26_englisch") await shot("26_englisch")
await click(find_button("Back")) await click(find_button("Back"))
@@ -670,41 +497,39 @@ func test_kampf_steuerung() -> void:
scr.ai_delay = 0.0 scr.ai_delay = 0.0
await wait_for_human(scr) await wait_for_human(scr)
var g: Game = scr.game var g: Game = scr.game
var idx := -1 g.human.energy = 20 # das Angebot ist zufällig, hier soll nur die Steuerung geprüft werden
for i in g.human.hand.size(): scr.refresh()
if g.human.hand[i].is_creature() and g.can_play(g.human, g.human.hand[i])[0]: check(scr.shop_views.size() == Game.SHOP_SIZE and g.human.hand.is_empty(), "voller Laden, leere Hand")
idx = i # Kaufen: Mana weg, Karte auf der Hand
break var offer: Cards.Card = scr.shop_views[0].card
check(idx >= 0, "spielbares Wesen auf der Hand") var mana: int = g.human.energy
if idx >= 0: await click(scr.shop_views[0])
var played_id: String = g.human.hand[idx].id check(g.human.hand.size() == 1 and g.human.hand[0] == offer and g.human.energy == mana - offer.cost, "Angebot gekauft")
var board_before: int = g.human.board.size() # Ausspielen kostet nichts
await key(KEY_1 + idx) var board_before: int = g.human.board.size()
check(g.human.board.size() == board_before + 1, "Wesen per Taste gespielt") mana = g.human.energy
# Rückgängig: Wesen zurück auf die Hand await key(KEY_1)
check(scr.b_undo.visible, "Rückgängig angeboten") check(g.human.board.size() == board_before + 1 and g.human.energy == mana, "Wesen per Taste gespielt, ohne Mana")
var energy: int = g.human.energy # Rückgängig: Wesen zurück auf die Hand
await click(scr.b_undo) check(scr.b_undo.visible, "Rückgängig angeboten")
g = scr.game await click(scr.b_undo)
check(g.human.board.size() == board_before and g.human.hand.any(func(c): return c.id == played_id) and g.human.energy > energy, "Rückgängig stellt Hand und Energie her") g = scr.game
check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht") check(g.human.board.size() == board_before and g.human.hand.any(func(c): return c.id == offer.id), "Rückgängig stellt die Hand her")
await key(KEY_1 + idx) check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht")
check(g.human.board.size() == board_before + 1, "erneut gespielt") await key(KEY_1)
check(g.human.board.size() == board_before + 1, "erneut gespielt")
# Neu würfeln kostet Mana
var shop_before: Array = g.human.shop.duplicate()
mana = g.human.energy
await click(scr.b_reroll)
check(g.human.energy == mana - Game.REROLL_COST and g.human.shop != shop_before, "Laden neu gewürfelt")
await shot("22_kampf_steuerung") await shot("22_kampf_steuerung")
# Gamepad: Zeiger bewegen, Karte spielen # Gamepad: Zeiger im Laden bewegen, A kauft
await pad(JOY_BUTTON_DPAD_RIGHT) await pad(JOY_BUTTON_DPAD_RIGHT)
check(scr.gp_zone == "hand" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweiter Handkarte") check(scr.gp_zone == "shop" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweitem Angebot")
var pad_idx := -1 var hand_before: int = g.human.hand.size()
for i in g.human.hand.size(): await pad(JOY_BUTTON_A)
if g.human.hand[i].is_creature() and g.can_play(g.human, g.human.hand[i])[0]: check(g.human.hand.size() == hand_before + 1, "Gamepad A kauft das Angebot")
pad_idx = i
break
if pad_idx >= 0:
while scr.gp_index != pad_idx:
await pad(JOY_BUTTON_DPAD_RIGHT)
var board_before2: int = g.human.board.size()
await pad(JOY_BUTTON_A)
check(g.human.board.size() == board_before2 + 1, "Gamepad A spielt das Wesen")
scr.ai_delay = 0.75 scr.ai_delay = 0.75
await key(KEY_SPACE) await key(KEY_SPACE)
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet") check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
@@ -717,7 +542,6 @@ func test_kampf_steuerung() -> void:
w += 1 w += 1
check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w) check(g.current() == g.human and w < 60, "Gegnerzug übersprungen (%d Bilder)" % w)
scr.ai_delay = 0.0 scr.ai_delay = 0.0
await clear_mulligan(scr)
await key(KEY_ESCAPE) await key(KEY_ESCAPE)
check(scr.confirm.visible, "Esc öffnet Aufgeben") check(scr.confirm.visible, "Esc öffnet Aufgeben")
await key(KEY_ESCAPE) await key(KEY_ESCAPE)
@@ -725,6 +549,92 @@ func test_kampf_steuerung() -> void:
await give_up() await give_up()
## Grenzen im Kampf: volle Hand, volles Feld, kein Mana.
func test_kampf_grenzen() -> void:
await start_battle(1)
var scr = screen()
scr.ai_delay = 0.0
await wait_for_human(scr)
var g: Game = scr.game
g.human.energy = 100
scr.refresh()
await frames(1)
# Hand voll kaufen
var guard := 0
while g.human.hand.size() < Game.MAX_HAND and guard < 30:
guard += 1
if scr.shop_views.is_empty():
await click(scr.b_reroll)
continue
await click(scr.shop_views[0])
check(g.human.hand.size() == Game.MAX_HAND, "Hand auf %d gefüllt" % Game.MAX_HAND)
check(scr.shop_views.all(func(v): return v.dim), "bei voller Hand ist nichts mehr kaufbar")
if not scr.shop_views.is_empty():
await click(scr.shop_views[0])
check(scr.msg_label.text == tr("Hand voll"), "Meldung bei voller Hand: " + scr.msg_label.text)
# Feld voll spielen
guard = 0
while g.human.board.size() < Game.MAX_BOARD and guard < 30:
guard += 1
await key(KEY_1)
if scr.pending != null: # Karte will ein Ziel
var valid: Array = g.valid_targets(g.human, scr.pending.target_type())
if valid.is_empty():
await right_click()
else:
await click(target_view(scr, valid))
check(g.human.board.size() == Game.MAX_BOARD, "Feld mit %d Wesen voll" % Game.MAX_BOARD)
await key(KEY_1)
check(scr.msg_label.text == tr("Dein Feld ist voll"), "Meldung bei vollem Feld: " + scr.msg_label.text)
await shot("25_kampf_voll")
# Verschmelzen macht wieder Platz
var before: int = g.human.board.size()
await click(scr.b_fuse)
await click(scr.own_views[0])
await click(scr.own_views[1])
check(g.human.board.size() == before - 1, "Verschmelzen macht einen Platz frei")
# Ohne Mana kein Neuwürfeln
g.human.energy = 0
scr.refresh()
await frames(1)
check(scr.b_reroll.disabled, "ohne Mana kein Neuwürfeln")
await give_up()
## Feldanzeige und Verschmelzen im Kampf.
func test_kampf_feld_und_verschmelzen() -> void:
await start_battle(1)
var scr = screen()
check(scr is BattleScreen, "Kampf gestartet")
scr.ai_delay = 0.0
await wait_for_human(scr)
var g: Game = scr.game
g.human.board.clear()
for id in ["f_wolf", "w_reiter"]:
var cr := Game.Creature.new(Cards.by_id(id), g.human, g.next_uid)
g.next_uid += 1
g.human.board.append(cr)
g.human.energy = 10
for i in Cards.FIELD_CHARGE:
g.charge_field(Cards.by_id("f_kobold"))
scr.refresh()
await frames(2)
check(scr.field_label.text.begins_with("Vulkan"), "Feldanzeige nennt das Feld: " + scr.field_label.text)
check(g.human.board[0].field_bonus == 1 and g.human.board[0].power() == 4, "das Feuerwesen bekommt +1 Angriff")
check(scr.b_fuse.visible and scr.b_fuse.text == "Verschmelzen", "Verschmelzen wird angeboten")
await shot("23_feld")
await click(scr.b_fuse)
check(scr.fusing and scr.b_fuse.text == "Abbrechen", "Verschmelzen ausgewählt")
await click(scr.own_views[0])
check(scr.fuse_first != null, "erstes Wesen gewählt")
await shot("24_verschmelzen")
await click(scr.own_views[1])
check(g.human.board.size() == 1 and g.human.board[0].name == "Dampf", "die beiden Wesen sind zu Dampf verschmolzen")
check(not scr.fusing and g.human.energy == 8, "Modus beendet und Mana bezahlt")
check(scr.own_views.size() == 1, "nur noch eine eigene Kachel")
await give_up()
func test_netzwerk() -> void: func test_netzwerk() -> void:
await click(find_button("Netzwerkspiel")) await click(find_button("Netzwerkspiel"))
var scr = screen() var scr = screen()
@@ -756,7 +666,6 @@ func test_netzwerk() -> void:
var rounds := 0 var rounds := 0
while b.game != null and b.game.winner == "" and rounds < 2: while b.game != null and b.game.winner == "" and rounds < 2:
if b.game.current() == b.perspective: if b.game.current() == b.perspective:
await clear_mulligan(b)
if b.game.current() == b.perspective: if b.game.current() == b.perspective:
await click(b.b_end) await click(b.b_end)
var w2 := 0 var w2 := 0
@@ -767,7 +676,7 @@ func test_netzwerk() -> void:
if b.game.winner != "": if b.game.winner != "":
print("DEBUG Netz: Sieger=", b.game.winner, " Verlauf=", b.game.log.slice(maxi(0, b.game.log.size() - 12))) print("DEBUG Netz: Sieger=", b.game.winner, " Verlauf=", b.game.log.slice(maxi(0, b.game.log.size() - 12)))
check(b.game.turn >= 2 and b.game.winner == "", "Runden gespielt, Partie läuft (Zug %d)" % b.game.turn) check(b.game.turn >= 2 and b.game.winner == "", "Runden gespielt, Partie läuft (Zug %d)" % b.game.turn)
check(b.game.log.any(func(l): return l.begins_with("Bot spielt") or l.begins_with("Bot behält") or l.begins_with("Bot tauscht")), "Bot hat gehandelt") check(b.game.log.any(func(l): return l.begins_with("Bot kauft") or l.begins_with("Bot spielt")), "Bot hat gehandelt")
await shot("24_netzwerk_kampf") await shot("24_netzwerk_kampf")
# Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen # Bot verlässt geordnet: Platz bleibt frei, Züge werden übersprungen
var quit_file := "user://bot_quit" var quit_file := "user://bot_quit"
@@ -781,7 +690,6 @@ func test_netzwerk() -> void:
check(main.net.waiting.has("Bot") and b.game.log.any(func(l): return "Verbindung verloren" in l), "Platz wird freigehalten") check(main.net.waiting.has("Bot") and b.game.log.any(func(l): return "Verbindung verloren" in l), "Platz wird freigehalten")
var turn_before: int = b.game.turn var turn_before: int = b.game.turn
for i in 2: for i in 2:
await clear_mulligan(b)
if b.game.current() == b.perspective and b.game.winner == "": if b.game.current() == b.perspective and b.game.winner == "":
await click(b.b_end) await click(b.b_end)
var w4 := 0 var w4 := 0
@@ -799,7 +707,6 @@ func test_netzwerk() -> void:
check(main.net.waiting.is_empty() and main.net.seats.size() == 2 and b.game.log.any(func(l): return l == "Bot ist zurück."), "Bot wieder verbunden, Platz zurück") check(main.net.waiting.is_empty() and main.net.seats.size() == 2 and b.game.log.any(func(l): return l == "Bot ist zurück."), "Bot wieder verbunden, Platz zurück")
var turn2: int = b.game.turn var turn2: int = b.game.turn
for i in 2: for i in 2:
await clear_mulligan(b)
if b.game.current() == b.perspective and b.game.winner == "": if b.game.current() == b.perspective and b.game.winner == "":
await click(b.b_end) await click(b.b_end)
var w6 := 0 var w6 := 0
@@ -873,7 +780,6 @@ func test_host_wechsel() -> void:
await shot("34_host_wechsel") await shot("34_host_wechsel")
# Der alte Host kehrt nicht zurück: ein Zug wird übersprungen, nach der Frist scheidet er aus # Der alte Host kehrt nicht zurück: ein Zug wird übersprungen, nach der Frist scheidet er aus
b.ai_delay = 0.0 b.ai_delay = 0.0
await clear_mulligan(b)
if b.game.current() == b.perspective: if b.game.current() == b.perspective:
await click(b.b_end) await click(b.b_end)
w = 0 w = 0
@@ -906,7 +812,7 @@ func test_zuschauer() -> void:
while (b.game == null or b.game.turn < 2) and w < 900: while (b.game == null or b.game.turn < 2) and w < 900:
await frames(1) await frames(1)
w += 1 w += 1
check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.mulligan_box.visible and not b.b_end.visible, check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.b_end.visible,
"Zuschauer sieht die Partie ohne Hände und ohne Züge (Zug %d)" % (b.game.turn if b.game != null else 0)) "Zuschauer sieht die Partie ohne Hände und ohne Züge (Zug %d)" % (b.game.turn if b.game != null else 0))
await shot("35_zuschauer") await shot("35_zuschauer")
check(not b.b_end.visible and not b.b_undo.visible, "Zuschauer hat keine Zugknöpfe") check(not b.b_end.visible and not b.b_undo.visible, "Zuschauer hat keine Zugknöpfe")
@@ -964,7 +870,7 @@ func test_kampf(n_opp: int, shot_turn: int) -> void:
var g: Game = scr.game var g: Game = scr.game
check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost") check(g.opponents.size() == n_opp and g.log[0].begins_with("Zugreihenfolge"), "Spiel gestartet, Reihenfolge ausgelost")
await wait_for_human(scr) await wait_for_human(scr)
check(g.current() == g.human and g.human.mulligan_done, "Spieler ist dran, Mulligan erledigt") check(g.current() == g.human, "Spieler ist dran")
check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar") check(scr.hand_views.size() == g.human.hand.size(), "Handkarten sichtbar")
await shot("07_kampf_start") await shot("07_kampf_start")
var coins: int = main.profile.coins var coins: int = main.profile.coins
@@ -998,35 +904,30 @@ func test_kampf_abbrechen() -> void:
var g: Game = scr.game var g: Game = scr.game
await wait_for_human(scr) await wait_for_human(scr)
check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner") check(g.opponents.size() == 5 and g.human.max_energy == 1, "5 Gegner")
# Falle legen und Kombo-Hinweis prüfen # Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit
g.human.hand.append(Cards.by_id("f_falle"))
g.human.energy = 10 g.human.energy = 10
var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000) var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000)
g.human.board.append(wolf) g.human.board.append(wolf)
g.human.hand.append(Cards.by_id("w_reiter")) g.human.hand.append(Cards.by_id("w_reiter"))
g.human.looks["w_reiter"] = Cards.RARITIES.find("episch")
scr.refresh() scr.refresh()
var trap_view = scr.hand_views.filter(func(v): return v.card.id == "f_falle")[0]
await click(trap_view)
check(g.human.traps.size() == 1 and scr.trap_views.size() == 1, "Falle gelegt und sichtbar")
var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0] var reiter_view = scr.hand_views.filter(func(v): return v.card.id == "w_reiter")[0]
check(reiter_view.rarity == "episch", "Handkarte trägt die eigene Stufe")
await hover(scr.log_box) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird await hover(scr.log_box) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird
await hover(reiter_view) await hover(reiter_view)
var hint_boxes = scr.preview_holder.get_children().filter(func(c): return c is RichTextLabel) var previews = scr.preview_holder.get_children().filter(func(c): return c is CardView)
if hint_boxes.size() != 1: check(previews.size() == 1 and previews[0].rarity == "episch", "Vorschau ebenfalls")
print("DEBUG Hinweis: hints=", scr.combo_hints(Cards.by_id("w_reiter")), " kinder=", scr.preview_holder.get_children().map(func(c): return c.get_class()), await shot("21_kampf_vorschau")
" brett=", g.human.board.map(func(c): return c.name), " hovered=", get_viewport().gui_get_hovered_control(), " reiter_pos=", reiter_view.get_global_rect()) var targeted: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0])
check(hint_boxes.size() == 1 and "Feldkombo Dampf" in hint_boxes[0].text, "Kombo-Hinweis in der Vorschau") if not targeted.is_empty():
await shot("21_kampf_falle_hinweis") var card: Cards.Card = targeted[0].card
var spells: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0]) await click(targeted[0])
if not spells.is_empty():
var card: Cards.Card = spells[0].card
await click(spells[0])
check(scr.pending == card, "Ziel wird erwartet") check(scr.pending == card, "Ziel wird erwartet")
await right_click() await right_click()
check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab") check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab")
await hover(scr.hand_views[0]) await hover(scr.hand_views[0])
if scr.preview_holder.get_child_count() == 0: if scr.preview_holder.get_child_count() == 0:
print("DEBUG Hover: mulligan=", scr.mulligan_box.visible, " overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(), print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(),
" dran=", g.current().name, " Sieger=", g.winner, " hovered=", get_viewport().gui_get_hovered_control()) " dran=", g.current().name, " Sieger=", g.winner, " hovered=", get_viewport().gui_get_hovered_control())
check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover") check(scr.preview_holder.get_child_count() > 0, "Vorschau bei Hover")
await click(find_button("Lupe")) await click(find_button("Lupe"))

View File

@@ -5,31 +5,22 @@ Einzelspieler,Single player
Kampagne,Campaign Kampagne,Campaign
Netzwerkspiel,Network game Netzwerkspiel,Network game
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
@@ -39,30 +30,17 @@ Booster,Boosters
Booster kaufen (100 Münzen),Buy booster (100 coins) Booster kaufen (100 Münzen),Buy booster (100 coins)
Alle aufdecken,Reveal all Alle aufdecken,Reveal all
Zerlegen,Dismantle Zerlegen,Dismantle
Herstellen,Craft
Kämpfen,Fight Kämpfen,Fight
Wiederholen,Replay Wiederholen,Replay
Gesperrt,Locked Gesperrt,Locked
Spiel eröffnen,Host game Spiel eröffnen,Host game
Beitreten,Join Beitreten,Join
Behalten,Keep
Tauschen,Swap
Hand tauschen?,Swap cards?
Klicke Karten an die du zurücklegen willst. Einmal pro Partie.,Click the cards you want to put back. Once per game.
Fallen,Traps
Du bist dran,Your turn Du bist dran,Your turn
Antreten,Enter
Gegnerzüge überspringen,Skip AI turns Gegnerzüge überspringen,Skip AI turns
Rückgängig,Undo Rückgängig,Undo
Kapitel 1,Chapter 1 Kapitel 1,Chapter 1
Kapitel 2,Chapter 2 Kapitel 2,Chapter 2
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d" "Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
"Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d","Collection: %d of %d cards (%d total) · Campaign: %d/%d"
Aktives Deck: %s (%s),Active deck: %s (%s)
leer,empty
Tagesaufgabe erledigt: %s ✓,Daily task done: %s ✓
Tagesaufgabe: %s (%d/%d) · +%d Münzen,Daily task: %s (%d/%d) · +%d coins
"Aktives Deck nicht spielbar: ","Active deck not playable: "
Zug %d · Du bist dran,Turn %d · Your turn Zug %d · Du bist dran,Turn %d · Your turn
Zug %d · %s ist dran …,Turn %d · %s is playing … Zug %d · %s ist dran …,Turn %d · %s is playing …
Wähle ein Ziel,Choose a target Wähle ein Ziel,Choose a target
@@ -70,57 +48,35 @@ 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 Duell,Duel
%d am Tisch,%d at the table %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" "Speichern und zurück","Save and go back"
"Kampf fortsetzen","Resume battle" "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 gegnerisches Wesen,%s damage to an enemy creature
%s Schaden an ein Ziel,%s damage to a target %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 gegnerischen Wesen,%s damage to all enemy creatures
%s Schaden an alle Gegner,%s damage to all opponents %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 %s Schaden an alle Gegner und ihre Wesen,%s damage to all opponents and their creatures
Heile dich um %s,Heal yourself for %s Heile dich um %s,Heal yourself for %s
Ziehe 1 Karte,Draw 1 card Erhalte 1 Mana,Gain 1 mana
Ziehe %s Karten,Draw %s cards Erhalte %s Mana,Gain %s mana
Friere ein gegnerisches Wesen ein,Freeze an enemy creature Friere ein gegnerisches Wesen ein,Freeze an enemy creature
Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature
Friere alle gegnerischen Wesen ein,Freeze all enemy creatures Friere alle gegnerischen Wesen ein,Freeze all enemy creatures
@@ -131,17 +87,11 @@ Alle befreundeten Wesen erhalten %s,All friendly creatures get %s
Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield
Erhält Schild,Gets Shield Erhält Schild,Gets Shield
Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield
Falle. %s: %s,Trap. %s: %s
Beim Ausspielen: %s,On play: %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 Wächter,Guardian
Flink,Swift Flink,Swift
Schild,Shield Schild,Shield
Doppelschlag,Double Strike Doppelschlag,Double Strike
Wesen,Creature
Zauber,Spell
Falle,Trap
gewöhnlich,common gewöhnlich,common
selten,rare selten,rare
episch,epic episch,epic
@@ -161,33 +111,49 @@ Blitz,Lightning
Schlamm,Mud Schlamm,Mud
Sturm,Storm Sturm,Storm
Sand,Sand Sand,Sand
Sonne,Sun
Ozean,Ocean
Gebirge,Mountains
Himmel,Sky
"Code übernommen, es fehlen: ","Code applied, missing: "
Elemente: ,Elements:
%s zerlegt (+%d Staub),%s dismantled (+%d dust) %s zerlegt (+%d Staub),%s dismantled (+%d dust)
%s hergestellt,%s crafted
%s ist jetzt im Deck %s,%s is now in deck %s
Deck-Code,Deck code
In Zwischenablage,To clipboard
Aus Zwischenablage,From clipboard
Code,Code
Ungültiger Code,Invalid code
Code übernommen,Code applied
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.
Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos,Element booster (%d coins): only cards of that element and its combos
"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks.","Chapter 3: three perks, full tables and the strongest decks."
Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!,All three chapters completed. The Archmage is defeated for good!
Kapitel %d,Chapter %d Kapitel %d,Chapter %d
Zuschauen,Watch Zuschauen,Watch
Zuschauer,Spectator Zuschauer,Spectator
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden" "Zuschauer: Hände sind verdeckt","Spectator: hands are hidden"
Deine Deckstärke %.1f · Gegnerdecks %.1f,Your deck strength %.1f · Opponent decks %.1f
Starten,Start Starten,Start
1 Gegner,1 opponent 1 Gegner,1 opponent
%d Gegner,%d opponents %d Gegner,%d opponents
Übernehmen,Apply
KI-Gegner,AI opponents KI-Gegner,AI opponents
Sammlung,Collection Sammlung,Collection
Verschmelzen,Fuse
Zwei eigene Wesen wählen,Pick two of your creatures
Zweites Wesen wählen,Pick the second creature
Feld: neutral,Field: neutral
Zwei verschiedene Wesen wählen,Pick two different creatures
Nur eigene Wesen,Only your own creatures
Vulkan,Volcano
Flut,Flood
Fels,Rock
Wirbel,Vortex
Feuerwesen haben +1 Angriff,Fire creatures get +1 attack
Wasserwesen haben +1 Angriff,Water creatures get +1 attack
Erdwesen haben +1 Angriff,Earth creatures get +1 attack
Luftwesen haben +1 Angriff,Air creatures get +1 attack
Aufgewertet: %d von %d Karten · Kampagne: %d/%d,Upgraded: %d of %d cards · Campaign: %d/%d
%s ist jetzt %s,%s is now %s
Alle Kapitel abgeschlossen,All chapters completed
Elemente,Elements
Aufwerten,Upgrade
Aufwerten zu %s (%d Staub),Upgrade to %s (%d dust)
Höchste Stufe,Top tier
Keine Karte gewählt,No card selected
Zerlegen (+%d Staub),Dismantle (+%d dust)
Nicht genug Staub (%d nötig),Not enough dust (%d needed)
Schon auf der höchsten Stufe,Already at the top tier
Gewöhnliche Karten lassen sich nicht zerlegen,Common cards cannot be dismantled
Aufgewertet,Upgraded
+%d Staub,+%d dust
+%d je Zug,+%d per turn
Neu würfeln (%d),Reroll (%d)
Nicht genug Mana,Not enough mana
Kein Angebot,No offer
Hand voll,Hand full
Profil wirklich zurücksetzen?,Really reset your profile?
"Sammlung, Münzen und Fortschritt gehen verloren.","Collection, coins and progress are lost."
Chimäre,Chimera
1 keys en
5 Kampagne Campaign
6 Netzwerkspiel Network game
7 Booster öffnen Open boosters
Deck bauen Build deck
8 Einstellungen Settings
9 Beenden Quit
10 Zurück Back
11 Start Start
Speichern Save
Leeren Clear
12 Suche Search
Fehlende Missing
13 Alle All
Aktiv setzen Set active
Auto-Ausfüllen Auto-fill
Umbenennen Rename
14 Zug beenden End turn
15 Aufgeben Surrender
16 Weiterspielen Keep playing
17 Ja, aufgeben Yes, surrender
18 Wirklich aufgeben? Really surrender?
Der Kampf endet ohne Münzen. The battle ends without coins.
19 Zurück zum Menü Back to menu
20 Lupe Zoom
21 Schließen Close
22 Held als Ziel Target hero
23 Verlauf Log
Karte überfahren für Details Hover a card for details
24 Lautstärke Volume
25 Musik Music
26 KI-Tempo AI speed
30 Booster kaufen (100 Münzen) Buy booster (100 coins)
31 Alle aufdecken Reveal all
32 Zerlegen Dismantle
Herstellen Craft
33 Kämpfen Fight
34 Wiederholen Replay
35 Gesperrt Locked
36 Spiel eröffnen Host game
37 Beitreten Join
Behalten Keep
Tauschen Swap
Hand tauschen? Swap cards?
Klicke Karten an die du zurücklegen willst. Einmal pro Partie. Click the cards you want to put back. Once per game.
Fallen Traps
38 Du bist dran Your turn
Antreten Enter
39 Gegnerzüge überspringen Skip AI turns
40 Rückgängig Undo
41 Kapitel 1 Chapter 1
42 Kapitel 2 Chapter 2
43 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:
44 Zug %d · Du bist dran Turn %d · Your turn
45 Zug %d · %s ist dran … Turn %d · %s is playing …
46 Wähle ein Ziel Choose a target
48 Abgebrochen Cancelled
49 %s kann nicht angreifen %s cannot attack
50 Nicht dein Zug Not your turn
51 Nicht genug Energie Nicht auf der Hand Not enough energy Not in your hand
52 Dein Feld ist voll Your board is full
53 Kein gültiges Ziel No valid target
54 Ungültiges Ziel Invalid target
Höchstens 2 Fallen At most 2 traps
55 Ein Wächter muss zuerst angegriffen werden A guardian must be attacked first
56 Sieg! Victory!
57 Niederlage Defeat
58 +%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
59 Schwierigkeit Difficulty
60 Leicht Easy
61 Normal Normal
62 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)
63 Profil zurücksetzen Reset profile
64 Große Schrift Large text
Verwerfen Discard
Weiter bearbeiten Keep editing
Ungespeicherte Änderungen Unsaved changes
65 Duell Duel
66 %d am Tisch %d at the table
%d Spieler am Tisch %d players at the table
67 %d Münzen %d coins
68 Abbrechen Cancel
69 Ja, zurücksetzen Yes, reset
70 Speichern und zurück Save and go back
71 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
72 %s Schaden an ein gegnerisches Wesen %s damage to an enemy creature
73 %s Schaden an ein Ziel %s damage to a target
74 %s Schaden an alle gegnerischen Wesen %s damage to all enemy creatures
75 %s Schaden an alle Gegner %s damage to all opponents
76 %s Schaden an alle Gegner und ihre Wesen %s damage to all opponents and their creatures
77 Heile dich um %s Heal yourself for %s
78 Ziehe 1 Karte Erhalte 1 Mana Draw 1 card Gain 1 mana
79 Ziehe %s Karten Erhalte %s Mana Draw %s cards Gain %s mana
80 Friere ein gegnerisches Wesen ein Freeze an enemy creature
81 Friere das stärkste gegnerische Wesen ein Freeze the strongest enemy creature
82 Friere alle gegnerischen Wesen ein Freeze all enemy creatures
87 Ein befreundetes Wesen erhält Schild A friendly creature gets Shield
88 Erhält Schild Gets Shield
89 Alle befreundeten Wesen erhalten Schild All friendly creatures get Shield
Falle. %s: %s Trap. %s: %s
90 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
91 Wächter Guardian
92 Flink Swift
93 Schild Shield
94 Doppelschlag Double Strike
Wesen Creature
Zauber Spell
Falle Trap
95 gewöhnlich common
96 selten rare
97 episch epic
111 Schlamm Mud
112 Sturm Storm
113 Sand Sand
Sonne Sun
Ozean Ocean
Gebirge Mountains
Himmel Sky
Code übernommen, es fehlen: Code applied, missing:
Elemente: Elements:
114 %s zerlegt (+%d Staub) %s dismantled (+%d dust)
%s hergestellt %s crafted
%s ist jetzt im Deck %s %s is now in deck %s
Deck-Code Deck code
In Zwischenablage To clipboard
Aus Zwischenablage From clipboard
Code Code
Ungültiger Code Invalid code
Code übernommen Code applied
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.
Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos Element booster (%d coins): only cards of that element and its combos
Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks. Chapter 3: three perks, full tables and the strongest decks.
Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt! All three chapters completed. The Archmage is defeated for good!
115 Kapitel %d Chapter %d
116 Zuschauen Watch
117 Zuschauer Spectator
118 Zuschauer: Hände sind verdeckt Spectator: hands are hidden
Deine Deckstärke %.1f · Gegnerdecks %.1f Your deck strength %.1f · Opponent decks %.1f
119 Starten Start
120 1 Gegner 1 opponent
121 %d Gegner %d opponents
Übernehmen Apply
122 KI-Gegner AI opponents
123 Sammlung Collection
124 Verschmelzen Fuse
125 Zwei eigene Wesen wählen Pick two of your creatures
126 Zweites Wesen wählen Pick the second creature
127 Feld: neutral Field: neutral
128 Zwei verschiedene Wesen wählen Pick two different creatures
129 Nur eigene Wesen Only your own creatures
130 Vulkan Volcano
131 Flut Flood
132 Fels Rock
133 Wirbel Vortex
134 Feuerwesen haben +1 Angriff Fire creatures get +1 attack
135 Wasserwesen haben +1 Angriff Water creatures get +1 attack
136 Erdwesen haben +1 Angriff Earth creatures get +1 attack
137 Luftwesen haben +1 Angriff Air creatures get +1 attack
138 Aufgewertet: %d von %d Karten · Kampagne: %d/%d Upgraded: %d of %d cards · Campaign: %d/%d
139 %s ist jetzt %s %s is now %s
140 Alle Kapitel abgeschlossen All chapters completed
141 Elemente Elements
142 Aufwerten Upgrade
143 Aufwerten zu %s (%d Staub) Upgrade to %s (%d dust)
144 Höchste Stufe Top tier
145 Keine Karte gewählt No card selected
146 Zerlegen (+%d Staub) Dismantle (+%d dust)
147 Nicht genug Staub (%d nötig) Not enough dust (%d needed)
148 Schon auf der höchsten Stufe Already at the top tier
149 Gewöhnliche Karten lassen sich nicht zerlegen Common cards cannot be dismantled
150 Aufgewertet Upgraded
151 +%d Staub +%d dust
152 +%d je Zug +%d per turn
153 Neu würfeln (%d) Reroll (%d)
154 Nicht genug Mana Not enough mana
155 Kein Angebot No offer
156 Hand voll Hand full
157 Profil wirklich zurücksetzen? Really reset your profile?
158 Sammlung, Münzen und Fortschritt gehen verloren. Collection, coins and progress are lost.
159 Chimäre Chimera

Binary file not shown.