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-10-02 19:57:02 +02:00
parent f0a8f18de8
commit 24a03ed1ee
31 changed files with 1866 additions and 2620 deletions

View File

@@ -20,20 +20,19 @@ Der Spielstand liegt unter `user://spielstand.json`
## Spielsysteme
- 77 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos entstehen erst im Spiel (Verschmelzen, Feldkombos)
- 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
- Mana wächst um 1 je Zug (bis 10) und bleibt liegen, wenn du es nicht ausgibst — sparen ist eine echte Entscheidung
- Der Laden zeigt nur Karten, die höchstens dein Einkommen plus eins kosten: teure Wesen tauchen erst später auf
- 77 Karten in `data/karten.json`, alle Wesen, jede mit genau einem der vier Elemente; Kombos sind die Namen verschmolzener Wesen
- Karten haben nur Element, Angriff und Leben; die Kosten sind Angriff plus Leben, halbiert und aufgerundet
- Mana: 5 im ersten Zug, jede Runde eins mehr ohne Grenze; was übrig bleibt, verfällt. Mana geht in Ziehen, Legen, Verbessern und Verschmelzen
- Kein Deckbau: beide ziehen vom gemeinsamen Stapel (jede Karte dreimal). Zu Beginn fünf sehen, drei behalten; Ziehen kostet 1 Mana und zeigt drei, eine bleibt; angeboten wird nur, was das Mana dieses Zugs hergibt, der Rest wandert unter den Stapel
- Verbessern: Schild (3) und Doppelschlag (4) lassen sich für Mana auf jede Hand- oder Brettkarte kaufen (Rechtsklick oder Y). Frisch gelegte Wesen greifen sofort an, zu Zugbeginn heilen die eigenen Wesen ganz
- Verdeckt legen: gleich teuer, das Wesen greift nicht an und zählt nicht für die Elementregel; aufdecken ist kostenlos, ein Angriff deckt es auf
- Jede Karte gehört jedem von Anfang an. Gesammelt wird die Seltenheitsstufe, und die ist reine Optik wie bei Holo-Karten: selten farbiger Rahmen, Schein und Edelstein, episch ein wandernder Lichtstreifen, legendär Funkeln, mythisch schillernde Farben; auch auf dem Feld für alle sichtbar, Werte und Text bleiben gleich
- 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
- Resonanz: gleiche Elemente auf dem Feld verstärken sich (+1, +2, +3, +3); der Bonus steht als „+n“ am Angriff, verbundene Wesen sind unter der Reihe verlinkt
- Feldkombos: ein neues Wesen neben einem Wesen mit anderem Element löst einen Effekt aus
- Karteneffekte lösen beim Ausspielen aus (Schaden, Einfrieren, Heilen, Zurückwerfen, Verstärken, Schild, Mana); Kartenvorteil ist hier Manavorteil
- Elementvorteil im Kreis (Wasser > Feuer > Luft > Erde > Wasser): 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, auch für Karteneffekte gegen Wesen
- Gemeinsames Feld: vier gespielte Karten eines Elements kippen es (Vulkan, Flut, Fels, Wirbel), Wesen dieses Elements bekommen +1 Angriff; die Feldzeile zeigt je Element die Ladung als vier Punkte
- Vorschau: Hand- und Ladenkarten zeigen, welchen Bonus und welche Feldkombo das Ausspielen jetzt brächte; beim Zeigen ist der Kombo-Partner markiert, beim Angreifen steht der Elementvorteil (+1/−1) am Ziel
- 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
- Duell mit ausgeloster Zugreihenfolge, wer nicht beginnt, bekommt einmalig 1 Mana; die Hand fasst 8 Karten, das Feld 5 Wesen
- Vier offene Wesen eines Elements auf dem Tisch: dieses Element bekommt +1/+1, das Element, das es schlägt, −1/−1 (Wasser > Feuer > Luft > Erde > Wasser)
- Elementvorteil beim Kampf: der Angreifer macht 1 Schaden mehr im Vorteil und 1 weniger im Nachteil, der Konter bleibt gleich, gegen Helden nichts; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht, auch bei der Vier-Elemente-Regel zählt sein bester Wert
- Angriffe treffen erst Wesen; das Leben ist nur ohne Wesen angreifbar. Schaden über das Leben eines Wesens hinaus schlägt auf den Besitzer durch
- Verschmelzen: zwei eigene Wesen werden eins mit dem besseren Angriff und Leben plus 1, Elementen und Eigenschaften beider; kostet 1 Mana, verdoppelt je weiterer Verschmelzung (1, 2, 4, 8); drei oder mehr Elemente ergeben eine Chimäre
- Duell mit ausgeloster Zugreihenfolge, wer nicht beginnt, bekommt einmalig 1 Mana; die Hand fasst 5 Karten, das Feld 5 Wesen
- Schwierigkeit Leicht/Normal/Schwer: Gegner mit 25/30/35 Leben, einfache oder Vorausschau-KI, Münzen 0,75/1/1,5-fach
- KI mit Vorausschau in zwei Stufen und Zeitbudget (150 ms), sie rechnet im Hintergrund-Thread
- Kampagne: drei Kapitel mit je zehn Magiern, Bosse mit ein bis drei Merkmalen, eigenes Merkmal nach Kapitel 1
@@ -55,7 +54,7 @@ godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen K
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
DINGER_ONLY=test_sammlung godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
DINGER_NETZ=1 godot --path . tests/ui_test.tscn # zusätzlich die langsamen Netzwerktests (Bot-Prozesse)
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Ladenangebot, Musik
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Stapel, Musik
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf
```
@@ -71,14 +70,14 @@ godot --headless --path . --export-release Windows build/dinger-windows.exe
## Aufbau
- `scripts/cards.gd` – Elemente, Kombos, Kartenklassen; Pool aus `data/karten.json`
- `scripts/game.gd` – Spielregeln, Züge, Kampf, Feldkombos, Resonanz, Feld, Verschmelzen
- `scripts/deckgen.gd` – Ladenangebot und Gegner
- `scripts/game.gd` – Spielregeln: Züge, Mana, Ziehen, verdeckte Wesen, Elementregel, Kampf, Verbessern, Verschmelzen
- `scripts/deckgen.gd` – gemeinsamer Stapel und Gegner
- `scripts/netstate.gd` – Zustandsbilder fürs Netzwerk
- `scripts/ai.gd` – KI mit Vorausschau (plus gierige Vergleichs-KI)
- `scripts/profile.gd` – Spielstand (versioniert), Sammlung, Booster, Einstellungen
- `scripts/campaign.gd` – Kampagnenstufen
- `scenes/main.tscn` – Startszene
- `scripts/net.gd` – Netzwerkspiel (ENet) mit Wiederverbinden
- `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `field_view.gd` die Feldzeile, `battle_layout.gd` die Aufteilung des Kampfes
- `scripts/ui/` – Bildschirme; `card_view.gd` und `creature_view.gd` zeichnen Karten und Wesen, `element_view.gd` den Elementzähler, `battle_layout.gd` die Aufteilung des Kampfes
- `translations/ui.csv` – englische Oberflächentexte
- `design.md` – Gestaltung: Vorbilder, Karte, Kampfbildschirm, Bewegung, Menüs

View File

@@ -15,15 +15,17 @@
- Gesammelt wird die Seltenheitsstufe einer Karte, und die ist reine Optik wie bei Holo-Karten: je höher die Stufe, desto mehr Glanz (Rahmen, Schein, Lichtstreifen, Funkeln, Schillern), auch im Netzwerkspiel sichtbar
- Booster öffnen: höhere Stufen werten Karten auf, der Rest wird zu Staub
- 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)
- Kein Deckbau: alle ziehen vom gemeinsamen Stapel (jede Karte dreimal); zu Beginn fünf sehen, drei behalten; Ziehen kostet 1 Mana und zeigt drei, eine bleibt; angeboten wird nur, was man sich in diesem Zug leisten kann
- Mana für Ziehen, Legen (Kosten = Angriff + Leben, halbiert), Verbessern und Verschmelzen; 5 im ersten Zug, dann je Runde eins mehr; der Rest verfällt; höchstens 5 Handkarten
- Karten haben nur Angriff und Leben; Eigenschaften (Schild, Doppelschlag) kauft man per Rechtsklick für Mana dazu; gelegte Wesen greifen sofort an, eigene Wesen heilen zu Zugbeginn
- Wesen lassen sich verdeckt legen; aufdecken ist frei, ein Angriff deckt auf
- Leben erst ohne Wesen angreifbar; Überschuss schlägt durch
- Kartenvorteil ist Manavorteil: Effekte geben Mana statt Nachziehen
## Elemente
- Jede Karte gehört zu genau einem Element
- Kombos entstehen erst im Spiel: beim Verschmelzen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan) und als Feldkombo
- Elemente wirken auf dem Feld zusammen: gleiche verstärken sich (Resonanz), verschiedene lösen Feldkombos aus; beides ist vor dem Ausspielen auf der Karte zu sehen
- 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 beiden: 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
- Kombos sind die Namen verschmolzener Wesen (Feuer + Wasser wird Dampf, gleiche Elemente werden Inferno, Tiefsee, Berg, Orkan)
- Vier offene Wesen eines Elements: +1/+1 für das Element, −1/−1 für das, das es schlägt; die Karte zeigt vorher, was sie bekäme
- Elementvorteil im Kreis: Wasser schlägt Feuer, Feuer schlägt Luft, Luft schlägt Erde, Erde schlägt Wasser (der Angreifer macht 1 Schaden mehr im Vorteil, 1 weniger im Nachteil, der Konter bleibt gleich; ein Wesen mit mehreren Elementen kämpft mit dem, das gegen den Gegner am besten steht, und nimmt auch bei der Vier-Elemente-Regel seinen besten Wert)
- Zwei eigene Wesen lassen sich für Mana verschmelzen: das neue erbt Elemente und Eigenschaften beider, seine Werte sind jeweils der bessere Wert plus 1
- Alle Karten sind Wesen: keine Zauber, keine Fallen; Effekte lösen beim Ausspielen aus

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,10 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw
und Leben in Kreisen unten), der Name liegt auf einem Band über die volle Breite, Text bleibt
kurz. Auf dem Brett: eigene Seite unten, Gegner oben, Verlauf links, Zug beenden rechts, Wesen
als kompakte Kacheln statt voller Karten. Zugbeginn und Kartenzüge werden angesagt.
- **Teamfight Tactics** – der Laden ist eine Reihe von fünf großen Karten unten, Neuwürfeln steht
mit seinem Preis direkt daneben, die Bank (bei uns die Hand) liegt als schmale Reihe darüber.
Der Laden zeigt an, was zum eigenen Brett passt (bei uns: „+n“ und die Feldkombo).
- **Hearthstones Entdecken** – drei Karten sehen, eine nehmen: Zufall mit Entscheidung. Jede
Wahl zeigt an, was zum eigenen Brett passt (Elementbonus als „+1/+1“ oder „−1/−1“).
- **Yu-Gi-Oh** – verdeckt gelegte Monster, Angriffe erst auf Monster, Kampfschaden schlägt
durch, Fusion.
- **Pokémon TCG** – Seltenheit ist Optik: Folienglanz, Funkeln, Schillern. Werte und Symbole
statt Sätze.
- **Allgemeine Regeln für Kartenspiel-Oberflächen** (GDKeys, Medium): klar getrennte Zonen,
@@ -21,45 +22,46 @@ Stand: September 2026. Kein Enddesign, sondern die Richtung mit umgesetztem Entw
## Karte
- Kostenkristall oben links, Bild (Elementsymbol) oben, Namensband auf 36 % Höhe, darunter
Schlüsselwörter als Symbole (Mauer = Wächter, Schild, Doppelpfeil = Flink, gekreuzte Klingen =
Doppelschlag), dann der Effekttext ohne Einleitung, unten Angriff (gelb) und Leben (rot) als
Kreise, dazwischen der Edelstein der Stufe.
- Große Karten (Vorschau, Sammlung, Booster) schreiben das Wort neben das Symbol und die
Überschrift „Beim Ausspielen“ über den Effekt. Kleine Karten zeigen nur Symbole.
- Handkacheln (96×62) sind flach: Kosten, Name, Werte, Symbole oder das Kombo-Schild. Details
kommen aus der schwebenden Vorschau.
gekaufte Eigenschaften als Symbole (Schild, gekreuzte Klingen = Doppelschlag), unten Angriff (gelb) und Leben (rot) als Kreise, dazwischen der Edelstein der
Stufe. Karten haben keinen Text: nur Element, Angriff, Leben.
- Große Karten (Vorschau, Sammlung, Booster) schreiben das Wort neben das Symbol.
- Die Hand sind bis zu fünf große Karten; Details kommen aus der schwebenden Vorschau.
- Seltenheit: gewöhnlich schlicht; selten Rahmen, Schein, Edelstein, stehender Lichtstreifen;
episch wandernder Lichtstreifen; legendär Funkeln; mythisch schillernde Farben.
- Der Kartentext in Daten und Sammlungssuche bleibt vollständig (`Card.text()`), die Karte
zeichnet nur `effect_text()`.
- Verdeckte fremde Wesen sind ein Kartenrücken mit „?“; eigene verdeckte zeigen die Karte mit
dem Vermerk „verdeckt“.
## Kampfbildschirm (1280×800)
Nur, was gerade zählt, ist zu sehen. Von unten nach oben:
- **Laden** als fünf große Karten, links daneben „Neu würfeln“ mit Preis, rechts „Zug beenden“.
- **Hand** als flache Kacheln (die Bank), für andere verdeckt.
- **Hand** als bis zu fünf große Karten, links der Zieh-Stapel mit „◆ 1“ (Klick zieht), rechts
„Zug beenden“.
- **Wahl** als Überlagerung: zu Beginn „Wähle 3 von 5“, nach dem Ziehen „Wähle 1 von 3“; solange
sie offen ist, geht nichts anderes.
- **Eigenes Brett** mit fünf sichtbaren Plätzen, färbt sich mit dem aktiven Feld; links daneben
die eigene Leiste (Leben, Mana, Einkommen, Zug), rechts „Verschmelzen“ und „Rückgängig“, nur
wenn sie gerade möglich sind.
- **Feldband** mit vier Ladepunkten je Element.
- **Elementzähler** mit vier Punkten je Element: offene Wesen auf dem Tisch; voll heißt +1/+1,
das geschlagene Element bekommt −1/−1.
- **Kartenmenü** per Rechtsklick oder Y auf Hand- und Brettkarten: verdeckt legen, aufdecken,
Schild und Doppelschlag kaufen.
- **Gegner** oben in der Mitte mit seinem Brett in voller Größe. Das Spiel ist ein Duell wie
Hearthstone: mehr als zwei Bretter passen nicht lesbar auf einen Bildschirm, und ein Gegner
macht Ziele, Texte und Balance eindeutig.
- **Vorschau** schwebt neben der Karte, auf die man zeigt (über Ladenkarten), mit Resonanz, Feld
und Feldkombo darunter.
- **Verlauf** und **Aufgeben** als kleine Knöpfe oben links; der Verlauf öffnet als Überlagerung.
- Hand- und Ladenkarten tragen „+n“ (Bonus auf dem Brett) und das Kombo-Schild; beim Zeigen ist
der Partner auf dem Brett markiert; beim Angreifen steht der Elementkonter (+1/−1) am Ziel.
- Wesen mit gleichem Element sind unter der Reihe verbunden (Resonanz).
- Hand- und Wahlkarten tragen den Elementbonus, den sie offen gelegt bekämen; beim Angreifen
steht der Elementkonter (+1/−1) am Ziel, bei Verdeckten nicht.
## Bewegung und Ton
- Zugbeginn: Band „Dein Zug“ quer über die Mitte, Laden wird ausgeteilt, Ton.
- Kaufen: die Karte fliegt aus dem Laden auf die Hand, Münzton.
- Zugbeginn: Band „Dein Zug“ quer über die Mitte, Ton.
- Ziehen: die Wahl wird ausgeteilt; die genommene Karte fliegt auf die Hand.
- Ausspielen: die Kachel fliegt von der Hand auf ihren Platz und federt ein.
- Angriff: Ausfallschritt, roter Rahmen am Ziel, kurzes Zittern des Bretts, Schadenszahl.
- Feldwechsel: Band in Elementfarbe, das Brett färbt sich.
- Aufdecken: „Aufgedeckt!“ am Wesen; Verbessern: die Eigenschaft steigt grün auf.
- Karten heben sich beim Zeigen an, Knöpfe federn beim Drücken, Bildschirme blenden ein.
- Booster: Karten drehen sich auf, seltene Stufen blitzen in ihrer Farbe auf.
- Alles hängt an der Einstellung „Animationen“.

View File

@@ -1,25 +1,19 @@
class_name AI
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels
## ausgeführt und die Stellung danach bewertet; die besten Erstschritte werden um
## einen Folgeschritt vertieft. Die gierige Variante (choose_action_greedy) dient
## als Vergleich und für die Schwierigkeit Leicht. Alles hier ist zustandslos und
## darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels ausgeführt und die
## Stellung danach bewertet; die besten Erstschritte werden um einen Folgeschritt vertieft.
## Verdeckte gegnerische Wesen kennt die KI nicht (Game.masked_clone). Die gierige Variante
## (choose_action_greedy) dient als Vergleich und für die Schwierigkeit Leicht. Alles hier ist
## zustandslos und darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
# --- Bewertung -------------------------------------------------------------------
static func creature_value(c: Game.Creature) -> float:
var v := float(c.power() + c.health)
if c.taunt():
v += 2.0
if c.shield:
v += 1.5
v += 1.0
if c.attacks_per_turn() == 2:
v += c.power()
if c.frozen:
v -= c.power() # setzt den nächsten Angriff aus: der Angriffswert zählt nicht
if not c.ready:
v -= c.power() * 0.25
v += c.power() * 0.5
return v
@@ -38,16 +32,14 @@ static func hand_value(p: Game.Player) -> float:
return v
## Stellung aus Sicht von p: eigenes Leben, Feld, Hand und übriges Mana zählen positiv,
## die des Gegners negativ. Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung,
## tödliche besonders.
## Stellung aus Sicht von p: eigenes Leben, Brett und Hand zählen positiv, die des Gegners
## negativ. Ohne eigene Wesen zielt gegnerische Angriffskraft aufs Leben, tödliche besonders.
static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive():
return -10000.0
var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.02
var score := p.life * 1.0 + board_value(p) * 1.0 + hand_value(p) + p.energy * 0.01
var alive_enemies := 0
var threat := 0.0
var leader := 0.0
for e in g.players:
if e == p:
continue
@@ -55,59 +47,40 @@ static func evaluate(g: Game, p: Game.Player) -> float:
score += 40.0
continue
alive_enemies += 1
var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
score -= ev
leader = maxf(leader, ev)
score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
if e.life <= 6:
score += 3.0 # fast besiegt: dranbleiben
for c in e.board:
if not c.frozen:
if not c.hidden:
threat += c.power() * c.attacks_per_turn()
if alive_enemies == 0:
return 10000.0
score -= leader * 0.25
var guarded := false
for c in p.board:
if c.taunt():
guarded = true
break
if guarded:
score -= threat * 0.1
elif threat >= p.life:
if p.board.is_empty():
if threat >= p.life:
score -= 25.0
else:
score -= threat * 0.3
else:
score -= threat * 0.12 # Durchschlag: auch hinter Wesen tut Angriffskraft weh
return score
# --- Vorausschau ---------------------------------------------------------------
const MAX_TARGETS := 4 # je Karte bzw. Angreifer nur die aussichtsreichsten Ziele prüfen
const MAX_TARGETS := 4 # je Angreifer nur die aussichtsreichsten Ziele prüfen
static var allow_hidden := true # zum Messen: verdeckt legen erwägen
static var allow_upgrade := true # zum Messen: Verbessern erwägen
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
## Grobe Rangfolge eines Ziels für einen Karteneffekt (nur zum Vorsortieren).
static func _target_rank(kind: String, amount: int, t) -> float:
if t is Game.Player:
return 50.0 - t.life if kind == "damage" else 0.0
match kind:
"damage":
return 100.0 + _value(t) if not t.shield and t.health <= amount else float(_value(t))
"freeze", "bounce":
return t.card.cost * 10.0 + t.power()
"buff", "shield":
return (100.0 if t.can_attack() else 0.0) + t.power()
return float(_value(t))
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann Helden, dann der Rest.
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann der Rest; Durchschlag zählt mit.
static func _attack_rank(cr: Game.Creature, t) -> float:
if t is Game.Player:
return 50.0 - t.life
if _kills(cr, t):
return (100.0 if not _kills(t, cr) else 60.0) + _value(t)
return (100.0 if not _kills(t, cr) else 60.0) + _value(t) + _overflow(cr, t)
return -float(t.health)
@@ -121,65 +94,44 @@ static func _top(list: Array, rank: Callable, n: int) -> Array:
return ranked.slice(0, n).map(func(pair): return pair[1])
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Karte und Angreifer.
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Angreifer.
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
var out := []
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:
if not game.can_play(p, card)[0]:
continue
var tt: String = card.target_type()
if tt == "":
out.append({"type": "play", "card": card, "target": null})
continue
if not valid_cache.has(tt):
valid_cache[tt] = game.valid_targets(p, tt)
var valid: Array = valid_cache[tt]
if valid.is_empty():
out.append({"type": "play", "card": card, "target": null})
continue
if prune:
var fx: Cards.Effect = null
for f in card.effects:
if f.target == tt:
fx = f
break
var kind: String = fx.kind if fx != null else ""
var amount: int = fx.amount if fx != null else 0
var key := "%s/%s/%d" % [tt, kind, amount]
if not valid_cache.has(key):
valid_cache[key] = _top(valid, func(t): return _target_rank(kind, amount, t), MAX_TARGETS)
valid = valid_cache[key]
for t in valid:
out.append({"type": "play", "card": card, "target": t})
out.append({"type": "play", "card": card, "face_down": false})
if allow_hidden and game.forecast(p, card) < 0:
out.append({"type": "play", "card": card, "face_down": true})
for cr in p.board:
if cr.hidden and game.can_reveal(p, cr)[0]:
out.append({"type": "reveal", "creature": cr})
elif not cr.hidden and allow_upgrade:
# Nur Verbesserungen, die sich noch in diesem Zug auszahlen können: Doppelschlag für
# ein Wesen, das gleich angreift; Schild nur, wenn kein Wesen mehr spielbar ist.
var has_target := not game.enemies_of(p).is_empty()
if cr.attacks_left > 0 and has_target and game.can_upgrade(p, cr, "Doppelschlag")[0]:
out.append({"type": "upgrade", "creature": cr, "keyword": "Doppelschlag"})
if not p.hand.any(func(c): return game.can_play(p, c)[0]) and game.can_upgrade(p, cr, "Schild")[0]:
out.append({"type": "upgrade", "creature": cr, "keyword": "Schild"})
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)
pairs.append([p.board[i], p.board[j]])
if prune and pairs.size() > MAX_TARGETS:
pairs = _top(pairs, func(pr): return _value(pr[0]) + _value(pr[1]), MAX_TARGETS)
for pr in pairs:
out.append({"type": "fuse", "creature": pr[0], "other": pr[1]})
for cr in p.board:
if not cr.can_attack():
continue
var targets := []
for e in game.enemies_of(p):
for t in e.board + [e]:
if game.can_attack(cr, t)[0]:
targets.append(t)
targets.append_array(e.board)
if e.board.is_empty():
targets.append(e)
targets = targets.filter(func(t): return game.can_attack(cr, t)[0])
if prune:
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
for t in targets:
@@ -190,20 +142,17 @@ static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -
## Führt eine Aktion des Originals auf der Kopie aus.
static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
var p2: Game.Player = g2.players[game.current_idx]
var target = null
if action.get("target") != null:
target = map_target(g2, game, action.target)
match action.type:
"play":
g2.play_card(p2, action.card, target)
"buy":
g2.buy(p2, int(action.slot))
"reroll":
g2.reroll(p2)
g2.play_card(p2, action.card, bool(action.get("face_down", false)))
"reveal":
g2.reveal(p2, g2.find_creature(action.creature.uid))
"upgrade":
g2.upgrade(p2, g2.find_creature(action.creature.uid), action.keyword)
"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)
g2.attack(g2.find_creature(action.creature.uid), map_target(g2, game, action.target))
static func map_target(g2: Game, game: Game, t):
@@ -224,21 +173,24 @@ static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary
return out
## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer
## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug.
## Beste Aktion nach einer Vorausschau von zwei Schritten auf einer Kopie, die verdeckte
## gegnerische Wesen nicht kennt. Bei Zeitmangel gilt der beste bisherige Zug.
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"}
if not p.offer.is_empty():
return {"type": "pick", "index": pick_index(game, p)}
var view := game.masked_clone(p)
var me: Game.Player = view.players[game.players.find(p)]
var t0 := Time.get_ticks_msec()
var base := evaluate(game, p) + 0.01
var base := evaluate(view, me) + 0.01
var firsts := [] # {action, game, score}
for action in candidate_actions(game, p, true):
for action in candidate_actions(view, me, true):
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
break
var g2 := game.clone()
apply_on_clone(g2, game, action)
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])})
var g2 := view.clone()
apply_on_clone(g2, view, action)
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[view.current_idx])})
firsts.sort_custom(func(a, b): return a.score > b.score)
var best := {"type": "end"}
var best_score := base
@@ -247,7 +199,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
var g2: Game = f.game
var score: float = f.score
if g2.winner == "":
var p2: Game.Player = g2.players[game.current_idx]
var p2: Game.Player = g2.players[view.current_idx]
var count := 0
for nxt in candidate_actions(g2, p2, true):
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
@@ -255,7 +207,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
count += 1
var g3 := g2.clone()
apply_on_clone(g3, g2, nxt)
score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001)
score = maxf(score, evaluate(g3, g3.players[view.current_idx]) - 0.001)
if score > best_score:
best_score = score
best = f.action
@@ -264,57 +216,81 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if firsts[i].score > best_score:
best_score = firsts[i].score
best = firsts[i].action
if best.type == "end":
if game.can_draw(p)[0]:
return {"type": "draw"} # Mana übrig und Platz auf der Hand: lieber ziehen als passen
return best
return map_action(game, view, best)
# --- Gierige Vergleichs-KI -------------------------------------------------------
# --- Einfache Heuristiken ----------------------------------------------------------
static func _kills(attacker, defender) -> bool:
return not defender.shield and defender.health <= attacker.power() + Game.matchup_bonus(attacker, defender)
if defender is Game.Player:
return attacker.power() >= defender.life
if defender.shield:
return false
return attacker.power() + Cards.matchup(attacker.card.elements, defender.card.elements) >= defender.health
## Schaden, der beim Angriff auf das Wesen auf dessen Besitzer durchschlägt.
static func _overflow(attacker, defender) -> int:
if defender is Game.Player or defender.shield:
return 0
return maxi(0, attacker.power() + Cards.matchup(attacker.card.elements, defender.card.elements) - defender.health)
static func _value(c) -> int:
return c.power() + c.health + (2 if c.taunt() else 0)
if c is Game.Player:
return 1000
return c.power() + c.health
static func _best(arr: Array, key: Callable):
static func _best(list: Array, key: Callable):
var best = null
var best_v := 0
for x in arr:
var v: int = key.call(x)
if best == null or v > best_v:
best = x
var best_v := -INF
for t in list:
var v := float(key.call(t))
if v > best_v:
best_v = v
best = t
return best
static func _threat_score(h) -> int:
var board_attack := 0
for c in h.board:
board_attack += c.power()
return h.life - board_attack
## Wert einer angebotenen Karte für p: Werte je Mana, jetzt spielbar zählt extra, der
## Elementbonus und ein Vorteil gegen das gegnerische Brett auch; viel zu teuer zählt ab.
static func offer_score(game: Game, p: Game.Player, c: Cards.Card) -> float:
var score := float(c.attack + c.health) / maxf(1.0, c.cost) * 2.0 + c.attack + c.health
if c.cost <= p.energy:
score += 3.0
if c.cost > p.max_energy + 2:
score -= 2.0 * (c.cost - p.max_energy - 2)
score += 2.0 * game.forecast(p, c)
for e in game.enemies_of(p):
for o in e.board:
if not o.hidden:
score += 1.0 * Cards.matchup(c.elements, o.card.elements)
return score
static func _weakest(heroes: Array):
var best = null
for h in heroes:
if best == null or _threat_score(h) < _threat_score(best):
best = h
## Welche der angebotenen Karten p nimmt.
static func pick_index(game: Game, p: Game.Player) -> int:
var best := 0
var best_score := -INF
for i in p.offer.size():
var s := offer_score(game, p, p.offer[i])
if s > best_score:
best_score = s
best = i
return best
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"}
# Gierig: erst das teuerste Bezahlbare kaufen, dann ausspielen, dann angreifen.
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}
if not p.offer.is_empty():
return {"type": "pick", "index": pick_index(game, p)}
# Gierig: erst das teuerste Spielbare legen, dann aufdecken und angreifen, dann ziehen.
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
if not playable.is_empty():
Game.shuffle(playable, game.rng)
@@ -322,71 +298,35 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
for c in playable:
if c.cost > card.cost:
card = c
return {"type": "play", "card": card, "target": pick_target(game, p, card)}
return {"type": "play", "card": card, "face_down": false}
for cr in p.board:
if cr.hidden and game.can_reveal(p, cr)[0]:
return {"type": "reveal", "creature": cr}
for cr in p.board:
if cr.can_attack():
var t = pick_attack_target(game, p, cr)
if t != null:
return {"type": "attack", "creature": cr, "target": t}
if game.can_draw(p)[0]:
return {"type": "draw"}
return {"type": "end"}
static func pick_target(game: Game, p: Game.Player, card: Cards.Card):
var tt := card.target_type()
if tt == "":
return null
var valid := game.valid_targets(p, tt)
if valid.is_empty():
return null
var fx: Cards.Effect = null
for f in card.effects:
if f.target == tt:
fx = f
break
var creatures := valid.filter(func(t): return t is Game.Creature)
var heroes := valid.filter(func(t): return t is Game.Player)
match fx.kind:
"damage":
var killable := creatures.filter(func(c): return not c.shield and c.health <= fx.amount)
if not killable.is_empty():
return _best(killable, Callable(AI, "_value"))
if not heroes.is_empty():
return _weakest(heroes)
return _best(creatures, Callable(AI, "_value"))
"freeze", "bounce":
return _best(creatures, func(c): return c.card.cost * 100 + c.power())
"buff", "shield":
return _best(creatures, func(c): return (100 if c.can_attack() else 0) + c.power())
return valid[0]
## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
## der Held nur, wenn kein Wesen mehr steht.
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
var enemies := game.enemies_of(p)
var total := 0
for c in p.board:
if c.can_attack():
total += c.power() * c.attacks_left
var options := []
for e in enemies:
var taunts: Array = e.board.filter(func(c): return c.taunt())
if not taunts.is_empty():
options.append_array(taunts)
else:
if total >= e.life:
return e
options.append_array(e.board)
options.append(e)
var creatures := options.filter(func(t): return t is Game.Creature)
var heroes := options.filter(func(t): return t is Game.Player)
var good := creatures.filter(func(c): return _kills(cr, c) and not _kills(c, cr))
for e in game.enemies_of(p):
if e.board.is_empty():
return e if game.can_attack(cr, e)[0] else null
var creatures: Array = e.board.filter(func(c): return game.can_attack(cr, c)[0])
if creatures.is_empty():
continue
var good: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and not _kills(c, cr))
if not good.is_empty():
return _best(good, Callable(AI, "_value"))
var trade := creatures.filter(func(c): return _kills(cr, c) and _value(c) > _value(cr))
var trade: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and _value(c) > _value(cr))
if not trade.is_empty():
return _best(trade, Callable(AI, "_value"))
if not heroes.is_empty():
return _weakest(heroes)
if not creatures.is_empty():
return _best(creatures, func(c): return 1000 - c.health)
return null
@@ -396,11 +336,15 @@ static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
match action.type:
"play":
game.play_card(p, action.card, action.target)
"buy":
game.buy(p, int(action.slot))
"reroll":
game.reroll(p)
game.play_card(p, action.card, bool(action.get("face_down", false)))
"pick":
game.pick(p, int(action.index))
"draw":
game.draw(p)
"reveal":
game.reveal(p, action.creature)
"upgrade":
game.upgrade(p, action.creature, action.keyword)
"attack":
game.attack(action.creature, action.target)
"fuse":
@@ -416,3 +360,4 @@ static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: i
apply_action(game, p, action)
if action.type == "end" or game.winner != "":
return
game.end_turn()

View File

@@ -1,47 +1,48 @@
class_name Campaign
## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Leben und Merkmalen.
## Kampagne: zehn benannte Magier in drei Kapiteln. Die Bosse haben weniger Leben als du,
## dafür Merkmale; je Kapitel kommt eins dazu.
const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "life": 30, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "elements": ["Wasser"], "life": 30, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "elements": ["Erde"], "life": 31, "reward": 150, "perk": "waechter_leben"},
{"name": "Liska", "title": "Windläuferin", "elements": ["Luft"], "life": 31, "reward": 150, "perk": "flink_angriff"},
{"name": "Vulkar", "title": "Herr der Lava", "elements": ["Feuer", "Erde"], "life": 32, "reward": 200, "perk": "wesen_leben"},
{"name": "Nyx", "title": "Sturmruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 200, "perk": "extra_mana"},
{"name": "Ignis", "title": "Inferno-Priester", "elements": ["Feuer"], "life": 33, "reward": 250, "perk": "resonanz_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "elements": ["Wasser"], "life": 33, "reward": 250, "perk": "schild_wesen"},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 300, "perk": "alle_flink"},
{"name": "Archimagier", "title": "Meister der Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 500, "perk": "start_energie"},
{"name": "Fenn", "title": "Kobold-Zähmer", "life": 22, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "life": 22, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "life": 24, "reward": 150, "perk": "wesen_angriff"},
{"name": "Liska", "title": "Windläuferin", "life": 24, "reward": 150, "perk": "ziehen_gratis"},
{"name": "Vulkar", "title": "Herr der Lava", "life": 24, "reward": 200, "perk": "wesen_leben"},
{"name": "Nyx", "title": "Sturmruferin", "life": 26, "reward": 200, "perk": "extra_mana"},
{"name": "Ignis", "title": "Inferno-Priester", "life": 26, "reward": 250, "perk": "element_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "life": 26, "reward": 250, "perk": "schild_wesen"},
{"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 300, "perk": "verbessern_billig"},
{"name": "Archimagier", "title": "Meister der Elemente", "life": 28, "reward": 500, "perk": "start_energie"},
]
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Leben zurück.
const STAGES2 := [
{"name": "Fenn", "title": "Flammenzähmer", "elements": ["Feuer"], "life": 30, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
{"name": "Mara", "title": "Herrin der Gezeiten", "elements": ["Wasser"], "life": 30, "reward": 300, "perks": ["leben_35", "extra_mana"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "elements": ["Erde"], "life": 31, "reward": 350, "perks": ["waechter_leben", "schild_wesen"]},
{"name": "Liska", "title": "Sturmläuferin", "elements": ["Luft"], "life": 31, "reward": 350, "perks": ["flink_angriff", "alle_flink"]},
{"name": "Vulkar", "title": "Aschekönig", "elements": ["Feuer", "Erde"], "life": 32, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "elements": ["Wasser", "Luft"], "life": 32, "reward": 400, "perks": ["extra_mana", "flink_angriff"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "elements": ["Feuer"], "life": 33, "reward": 450, "perks": ["resonanz_doppelt", "wesen_leben"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "elements": ["Wasser"], "life": 33, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
{"name": "Zephyra", "title": "Blitzkaiserin", "elements": ["Feuer", "Luft"], "life": 34, "reward": 500, "perks": ["alle_flink", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "elements": ["Erde", "Luft"], "life": 34, "reward": 800, "perks": ["start_energie", "resonanz_doppelt", "extra_mana"]},
{"name": "Fenn", "title": "Flammenzähmer", "life": 22, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
{"name": "Mara", "title": "Herrin der Gezeiten", "life": 22, "reward": 300, "perks": ["leben_35", "extra_mana"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "life": 24, "reward": 350, "perks": ["wesen_angriff", "schild_wesen"]},
{"name": "Liska", "title": "Sturmläuferin", "life": 24, "reward": 350, "perks": ["ziehen_gratis", "verbessern_billig"]},
{"name": "Vulkar", "title": "Aschekönig", "life": 24, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "life": 26, "reward": 400, "perks": ["extra_mana", "ziehen_gratis"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "life": 26, "reward": 450, "perks": ["element_doppelt", "wesen_leben"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "life": 26, "reward": 450, "perks": ["schild_wesen", "leben_35"]},
{"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 500, "perks": ["verbessern_billig", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "life": 28, "reward": 800, "perks": ["start_energie", "element_doppelt"]},
]
## Kapitel 3: drei Merkmale und volle Tische.
const STAGES3 := [
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "life": 32, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "life": 32, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]},
{"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "life": 33, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]},
{"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "life": 33, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_mana"]},
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "life": 34, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "waechter_leben"]},
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "life": 34, "reward": 800, "perks": ["extra_mana", "flink_angriff", "resonanz_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "life": 35, "reward": 900, "perks": ["resonanz_doppelt", "wesen_leben", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "life": 35, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "life": 36, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]},
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "life": 36, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_mana", "leben_35"]},
{"name": "Fenn", "title": "Aschefürst", "life": 20, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
{"name": "Mara", "title": "Sturmflut", "life": 20, "reward": 600, "perks": ["leben_35", "extra_mana", "schild_wesen"]},
{"name": "Borin", "title": "Berg selbst", "life": 22, "reward": 700, "perks": ["wesen_angriff", "schild_wesen", "leben_35"]},
{"name": "Liska", "title": "Windbraut", "life": 22, "reward": 700, "perks": ["ziehen_gratis", "verbessern_billig", "extra_mana"]},
{"name": "Vulkar", "title": "Magmakaiser", "life": 22, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "wesen_angriff"]},
{"name": "Nyx", "title": "Orkanmutter", "life": 24, "reward": 800, "perks": ["extra_mana", "ziehen_gratis", "element_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "life": 24, "reward": 900, "perks": ["element_doppelt", "wesen_leben", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "life": 24, "reward": 900, "perks": ["schild_wesen", "leben_35", "element_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "life": 26, "reward": 1000, "perks": ["verbessern_billig", "start_energie", "wesen_leben"]},
{"name": "Archimagier", "title": "Ewiger Meister", "life": 26, "reward": 1200, "perks": ["start_energie", "element_doppelt", "leben_35"]},
]
const CHAPTERS := [STAGES, STAGES2, STAGES3]
@@ -63,9 +64,9 @@ static func stage(i: int) -> Dictionary:
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
## Boss einer Stufe: handelt nur mit seinen Elementen, mit dem Leben und den Merkmalen der Stufe.
## Boss einer Stufe mit dem Leben und den Merkmalen der Stufe.
static func opponent(stage_index: int) -> Game.Player:
var st := stage(stage_index)
var boss := Game.Player.new(st.name, false, int(st.life), st.title, st.elements)
var boss := Game.Player.new(st.name, false, int(st.life), st.title)
boss.perks = Array(st.perks)
return boss

View File

@@ -1,5 +1,5 @@
class_name Cards
## Elemente, Kombos und der Kartenpool.
## Elemente, Kombonamen, Schlüsselwörter und der Kartenpool.
const ELEMENTS: Array[String] = ["Feuer", "Wasser", "Erde", "Luft"]
const ELEMENT_COLORS := {
@@ -8,8 +8,7 @@ const ELEMENT_COLORS := {
"Erde": Color("73552d"),
"Luft": Color("78aac8"),
}
# Jede Karte hat genau ein Element. Kombos entstehen erst im Spiel: beim Verschmelzen
# (der Name des neuen Wesens) und als Feldkombo zwischen benachbarten Elementen.
# Jede Karte hat genau ein Element. Kombos sind die Namen verschmolzener Wesen.
const COMBOS := {
"Feuer+Feuer": "Inferno",
"Wasser+Wasser": "Tiefsee",
@@ -22,26 +21,8 @@ const COMBOS := {
"Wasser+Luft": "Sturm",
"Erde+Luft": "Sand",
}
# Feldkombos: entstehen, wenn ein Wesen neben einem Wesen mit anderem Element ausgespielt wird.
const FIELD_COMBO_TEXT := {
"Dampf": "Friere das stärkste gegnerische Wesen ein",
"Lava": "1 Schaden an das stärkste gegnerische Wesen",
"Blitz": "2 Schaden an den Gegner",
"Schlamm": "Heile dich um 3",
"Sturm": "Erhalte 1 Mana",
"Sand": "Das neue Wesen erhält +1/+1",
}
# 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 RARITY_COLORS := {
@@ -51,8 +32,10 @@ const RARITY_COLORS := {
"legendär": Color("f0aa28"),
"mythisch": Color("ff5a8c"),
}
const KEYWORDS: Array[String] = ["Wächter", "Flink", "Schild", "Doppelschlag"]
const CHOSEN_TARGETS: Array[String] = ["enemy_creature", "enemy_any", "friendly_creature"]
# Schlüsselwörter haben Karten nicht von Haus aus; sie werden für Mana dazugekauft (Game.upgrade).
const KEYWORDS: Array[String] = ["Schild", "Doppelschlag"]
const KEYWORD_COST := {"Schild": 3, "Doppelschlag": 4}
const KEYWORD_TEXT := {"Schild": "Blockt den ersten Schaden", "Doppelschlag": "Greift zweimal je Zug"}
const DATA_PATH := "res://data/karten.json"
@@ -70,20 +53,49 @@ static func combo_name(elements: Array) -> String:
## Elementvorteil aus Sicht von own: +1 im Vorteil, -1 im Nachteil, sonst 0.
## Mehrfachelemente nehmen den für sie besseren Wert.
## Hat ein Wesen mehrere Elemente, kämpft es mit dem, das gegen die Gegenseite am besten steht.
static func matchup(own: Array, other: Array) -> int:
for e in own:
if BEATS.get(e, "") in other:
var mine := best_element(own, other)
var theirs := best_element(other, own)
if mine == "" or theirs == "":
return 0
return duel(mine, theirs)
## +1, wenn a b schlägt, -1, wenn b a schlägt, sonst 0.
static func duel(a: String, b: String) -> int:
if BEATS.get(a, "") == b:
return 1
for e in other:
if BEATS.get(e, "") in own:
if BEATS.get(b, "") == a:
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)
## Das Element aus own, das gegen die Elemente in other am besten steht.
static func best_element(own: Array, other: Array) -> String:
var best := ""
var best_score := -9
for e in own:
var score := 0
for o in other:
score += duel(e, o)
if score > best_score:
best = e
best_score = score
return best
## Welches Element ein Element schlägt (und von welchem es geschlagen wird).
static func beaten_by(element: String) -> String:
for e in BEATS:
if BEATS[e] == element:
return e
return ""
## Kosten einer Karte: Angriff plus Leben, halbiert und aufgerundet.
static func cost_of(attack: int, health: int) -> int:
return ceili((attack + health) / 2.0)
## Name des verschmolzenen Wesens: die Kombo seiner Elemente (ein Element zählt doppelt).
@@ -91,8 +103,8 @@ 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).
## Karte des verschmolzenen Wesens: Elemente und Schlüsselwörter beider Karten, die Werte
## bestimmt Game.fuse, eine Verschmelzung mehr als der erfahrenere Elternteil.
static func fused(a: Card, b: Card, attack: int, health: int) -> Card:
var els := []
for e in a.elements + b.elements:
@@ -103,99 +115,27 @@ static func fused(a: Card, b: Card, attack: int, health: int) -> Card:
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
c.cost = cost_of(attack, health)
for k in KEYWORDS:
if k in a.keywords or k in b.keywords:
c.keywords.append(k)
c.fusions = maxi(a.fusions, b.fusions) + 1
return c
static func field_combo_effect(name: String) -> Effect:
match name:
"Dampf":
return Effect.new("freeze", "strongest_enemy_creature")
"Lava":
return Effect.new("damage", "strongest_enemy_creature", 1)
"Blitz":
return Effect.new("damage", "weakest_enemy_hero", 2)
"Schlamm":
return Effect.new("heal", "self_hero", 3)
"Sturm":
return Effect.new("mana", "none", 1)
"Sand":
return Effect.new("buff", "self_creature", 1, 1)
return null
class Effect extends RefCounted:
var kind: String # damage, heal, mana, freeze, bounce, buff, shield
var target: String # siehe CHOSEN_TARGETS und Game.resolve()
var amount: int
var amount2: int
func _init(k: String, t: String, a: int = 0, a2: int = 0) -> void:
kind = k
target = t
amount = a
amount2 = a2
func to_dict() -> Dictionary:
var d := {"kind": kind, "target": target}
if amount != 0:
d["amount"] = amount
if amount2 != 0:
d["amount2"] = amount2
return d
static func from_dict(d: Dictionary) -> Effect:
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)))
## Kartentext in der aktuellen Sprache.
func text() -> String:
var n := str(amount)
match kind:
"damage":
match target:
"enemy_creature":
return tr("%s Schaden an ein gegnerisches Wesen") % n
"enemy_any":
return tr("%s Schaden an ein Ziel") % n
"all_enemy_creatures":
return tr("%s Schaden an alle gegnerischen Wesen") % n
"all_enemy_heroes":
return tr("%s Schaden an den Gegner") % n
"all_enemies":
return tr("%s Schaden an den Gegner und seine Wesen") % n
"heal":
return tr("Heile dich um %s") % n
"mana":
if amount == 1:
return tr("Erhalte 1 Mana")
return tr("Erhalte %s Mana") % n
"freeze":
if target == "enemy_creature":
return tr("Friere ein gegnerisches Wesen ein")
if target == "strongest_enemy_creature":
return tr("Friere das stärkste gegnerische Wesen ein")
return tr("Friere alle gegnerischen Wesen ein")
"bounce":
if target == "enemy_creature":
return tr("Ein gegnerisches Wesen zurück auf die Hand")
return tr("Alle gegnerischen Wesen zurück auf die Hand")
"buff":
var b := "+%d/+%d" % [amount, amount2]
if target == "friendly_creature":
return tr("Ein befreundetes Wesen erhält %s") % b
return tr("Alle befreundeten Wesen erhalten %s") % b
"shield":
if target == "friendly_creature":
return tr("Ein befreundetes Wesen erhält Schild")
if target == "self_creature":
return tr("Erhält Schild")
return tr("Alle befreundeten Wesen erhalten Schild")
return ""
## Platzhalter für ein verdecktes gegnerisches Wesen: unbekanntes Element, geschätzte Werte.
static func unknown() -> Card:
var c := Card.new()
c.id = "?"
c.name = "Verdeckt"
c.name_en = "Face-down"
c.elements = []
c.attack = 2
c.health = 3
c.cost = cost_of(2, 3)
return c
class Card extends RefCounted:
@@ -203,19 +143,18 @@ class Card extends RefCounted:
var name: String # deutscher Name (auch im Verlauf und in Tests)
var name_en := "" # englischer Name
var elements: Array
var cost: int
var attack: int = 0
var health: int = 0
var keywords: Array = []
var effects: Array = []
var cost: int = 0 # abgeleitet: (Angriff + Leben) / 2, aufgerundet
var keywords: Array = [] # dazugekauft, je Exemplar
var fusions := 0 # wie oft verschmolzen (Verschmelzen kostet 1, 2, 4, 8 …)
func to_dict() -> Dictionary:
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost,
"attack": attack, "health": health}
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health}
if not keywords.is_empty():
d["keywords"] = keywords
if not effects.is_empty():
d["effects"] = effects.map(func(e): return e.to_dict())
if fusions > 0:
d["fusions"] = fusions
return d
static func from_dict(d: Dictionary) -> Card:
@@ -224,14 +163,17 @@ class Card extends RefCounted:
c.name = d.name
c.name_en = str(d.get("name_en", ""))
c.elements = Array(d.elements)
c.cost = int(d.cost)
c.attack = int(d.get("attack", 0))
c.health = int(d.get("health", 0))
c.cost = Cards.cost_of(c.attack, c.health)
c.keywords = Array(d.get("keywords", []))
for e in d.get("effects", []):
c.effects.append(Effect.from_dict(e))
c.fusions = int(d.get("fusions", 0))
return c
## Eigenes Exemplar (Hand, Brett): Schlüsselwörter gehören dem Exemplar, nicht dem Pool.
func copy() -> Card:
return Card.from_dict(to_dict())
## Anzeigename in der aktuellen Sprache.
func title() -> String:
if name_en != "" and TranslationServer.get_locale().begins_with("en"):
@@ -244,35 +186,19 @@ class Card extends RefCounted:
func combo() -> String:
return Cards.combo_name(elements)
func target_type() -> String:
for fx in effects:
if fx.target in Cards.CHOSEN_TARGETS:
return fx.target
return ""
## Nur die Effekte (die Karte zeigt Schlüsselwörter als Symbole).
func effect_text() -> String:
if effects.is_empty():
return ""
var fx: Array[String] = []
for f in effects:
fx.append(f.text())
return ", ".join(fx) + "."
## Mana für die nächste Verschmelzung mit dieser Karte.
func fusion_cost() -> int:
return 1 << fusions
## Kartentext: die gekauften Schlüsselwörter.
func text() -> String:
var parts: Array[String] = []
if not keywords.is_empty():
if keywords.is_empty():
return ""
var kws: Array[String] = []
for k in Cards.KEYWORDS:
if k in keywords:
kws.append(tr(k))
parts.append(", ".join(kws) + ".")
if not effects.is_empty():
var fx: Array[String] = []
for f in effects:
fx.append(f.text())
parts.append(tr("Beim Ausspielen: %s") % (", ".join(fx) + "."))
return " ".join(parts)
return ", ".join(kws) + "."
static var _all: Array = []

View File

@@ -1,35 +1,18 @@
class_name DeckGen
## Ladenangebot und Gegner (ausgelagert aus den Spielregeln).
## Es gibt keine Decks: jeder kauft jede Runde aus seinem eigenen Laden.
## Der gemeinsame Stapel (jede Karte dreimal) und Gegner.
# Abnehmende Resonanz je Anzahl gleichelementiger Partner (Index = Partner).
const RESONANCE_STEPS := [0, 1, 2, 3, 3]
const COPIES := 3
## 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).
## Mindestens eine Karte ist vom Einkommen allein bezahlbar, sonst wäre der Zug tot.
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))
## Frisch gemischter Stapel; das Ende des Arrays ist oben.
static func deck(r: RandomNumberGenerator) -> Array:
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)]
for c in Cards.all():
for i in COPIES:
out.append(c)
Game.shuffle(out, r)
return out
## Gegner mit eigenem Laden über alle Elemente.
static func random_opponent(life: int = Game.START_LIFE) -> Game.Player:
return Game.Player.new("Gegner", false, life)

View File

@@ -1,27 +1,30 @@
class_name Game
extends RefCounted
## Spielregeln: Spieler, Wesen, Züge, Angriffe, Effekte, Feldkombos, Resonanz.
## Spielregeln: Duell, Mana je Zug, Ziehen aus dem gemeinsamen Stapel, verdeckte Wesen,
## Vier-Elemente-Regel, Verschmelzen und Verbessern.
const MAX_BOARD := 5
const MAX_HAND := 8
const MAX_ENERGY := 10 # Einkommen je Zug, höher steigt es nicht
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.
const MAX_HAND := 5
const START_MANA := 5 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt
const DRAW_COST := 1 # Ziehen: drei Karten sehen, eine behalten
const OFFER_SIZE := 3
const START_OFFER := 5 # zu Beginn fünf sehen, drei behalten
const START_PICKS := 3
const START_LIFE := 30
const ELEMENT_STACK := 4 # so viele offene Wesen eines Elements auf dem Tisch: +1/+1, das geschlagene Element -1/-1
# Merkmale der Kampagnen-Bosse (Player.perks).
const PERKS := {
"feuer_angriff": "Feuerwesen haben +1 Angriff",
"flink_angriff": "Flinke Wesen haben +1 Angriff",
"waechter_leben": "Wächter kommen mit +2 Leben",
"ziehen_gratis": "Ziehen kostet kein Mana",
"wesen_angriff": "Wesen kommen mit +1 Angriff",
"wesen_leben": "Wesen kommen mit +1 Leben",
"extra_mana": "Bekommt jeden Zug 1 Mana mehr",
"leben_35": "Beginnt mit 5 Leben mehr",
"schild_wesen": "Wesen kommen mit Schild",
"alle_flink": "Alle Wesen sind flink",
"start_energie": "Beginnt mit 3 Mana",
"resonanz_doppelt": "Resonanz zählt doppelt",
"verbessern_billig": "Eigenschaften kosten 1 weniger",
"start_energie": "Hat im ersten Zug 2 Mana mehr",
"element_doppelt": "Elementbonus zählt doppelt",
}
## Für wiederholbare Tests: 0 = echter Zufall, sonst Startwert (zählt je Aufruf hoch).
@@ -38,21 +41,18 @@ static func make_rng() -> RandomNumberGenerator:
return r
class Creature extends RefCounted:
var card: Cards.Card
var owner_ref: WeakRef # schwache Referenz: kein Zyklus Spieler <-> Wesen
var name: String
var uid: int
var attack: int
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 max_health: int
var shield: bool
var frozen := false
var ready: bool
var attacks_left: int
var hidden := false # verdeckt gelegt: greift nicht an, zählt nicht für die Elementregel
var aura := 0 # Vier-Elemente-Regel: +1 oder -1 auf den Angriff (Leben wird direkt angepasst)
func _init(c: Cards.Card, o, id: int) -> void:
card = c
@@ -63,43 +63,35 @@ class Creature extends RefCounted:
health = c.health
max_health = c.health
shield = "Schild" in c.keywords
ready = "Flink" in c.keywords
attacks_left = attacks_per_turn() if ready else 0
attacks_left = attacks_per_turn() # frisch gelegte Wesen greifen sofort an
func owner():
return owner_ref.get_ref()
## Angriffswert inklusive Resonanz, Feld und Boss-Merkmalen.
## Angriffswert inklusive Elementbonus und Boss-Merkmalen.
func power() -> int:
var v := attack + resonance + field_bonus
var v := attack + aura
var o = owner()
if o != null:
if "feuer_angriff" in o.perks and "Feuer" in card.elements:
v += 1
if "flink_angriff" in o.perks and "Flink" in card.keywords:
v += 1
return v
func attacks_per_turn() -> int:
return 2 if "Doppelschlag" in card.keywords else 1
func taunt() -> bool:
return "Wächter" in card.keywords
func can_attack() -> bool:
return ready and attacks_left > 0 and not frozen and power() > 0
return attacks_left > 0 and not hidden and power() > 0
func clone(new_owner) -> Creature:
var c := Creature.new(card, new_owner, uid)
c.attack = attack
c.resonance = resonance
c.field_bonus = field_bonus
c.health = health
c.max_health = max_health
c.shield = shield
c.frozen = frozen
c.ready = ready
c.attacks_left = attacks_left
c.hidden = hidden
c.aura = aura
return c
@@ -109,33 +101,33 @@ class Player extends RefCounted:
var is_human: bool
var life: int
var max_life: int
var shop: Array = [] # SHOP_SIZE Plätze, gekaufte sind null
var hand: Array = []
var offer: Array = [] # offene Wahl: angebotene Karten (Start: 5, Ziehen: 3)
var picks_left := 0 # wie viele davon noch zu nehmen sind
var hand: Array = [] # eigene Exemplare (Cards.Card.copy)
var board: Array = []
var energy := 0 # Mana; nicht ausgegebenes bleibt liegen
var max_energy := 0 # Einkommen je Zug
var energy := 0 # Mana in diesem Zug; der Rest verfällt
var max_energy := 0 # Mana zu Zugbeginn (START_MANA + eigene Züge)
var start_bonus := 0 # einmaliges Extra-Mana für den späteren Start
var order := 0 # Platz in der Zugreihenfolge
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
func _init(n: String, h: bool, l: int, t: String = "", els: Array = []) -> void:
func _init(n: String, h: bool, l: int, t: String = "") -> void:
name = n
title = t
is_human = h
life = l
max_life = l
elements = els.duplicate()
func alive() -> bool:
return life > 0
func clone() -> Player:
var p := Player.new(name, is_human, life, title, elements)
var p := Player.new(name, is_human, life, title)
p.max_life = max_life
p.shop = shop.duplicate()
p.offer = offer.duplicate()
p.picks_left = picks_left
p.hand = hand.duplicate()
p.energy = energy
p.max_energy = max_energy
@@ -155,6 +147,7 @@ var opponent: Player # der andere am Tisch
var players: Array = [] # in Zugreihenfolge
var winner_player: Player = null
var rng: RandomNumberGenerator
var deck: Array = [] # gemeinsamer Stapel, das Ende ist oben (nur Host und Einzelspieler)
var turn := 0
var current_idx := 0
var log: Array[String] = []
@@ -162,8 +155,6 @@ var events: Array = [] # strukturierte Ereignisse für Animationen
var winner := "" # "", "human" oder "opponents"
var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse
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, o: Player, r: RandomNumberGenerator = null) -> void:
@@ -174,8 +165,6 @@ func _init(h: Player, o: Player, r: RandomNumberGenerator = null) -> void:
if r == null:
r = make_rng()
rng = r
for e in Cards.ELEMENTS:
field_charge[e] = 0
func current() -> Player:
@@ -198,7 +187,7 @@ func ref_of(target) -> Dictionary:
return {"kind": "player", "index": players.find(target)}
## Der Gegner von p, solange er lebt (als Liste, damit Effekte einheitlich bleiben).
## Der Gegner von p, solange er lebt (als Liste, für Schleifen über Ziele).
func enemies_of(p: Player) -> Array:
return players.filter(func(o): return o != p and o.alive())
@@ -215,7 +204,7 @@ func find_creature(uid: int) -> Creature:
return null
## Unabhängige Kopie des Spielzustands (für die KI-Vorausschau).
## Unabhängige Kopie des Spielzustands (für Rückgängig und die KI-Vorausschau).
func clone() -> Game:
var copies := []
for p in players:
@@ -223,27 +212,46 @@ func clone() -> Game:
var idx := players.find(human)
var g := Game.new(copies[idx], copies[1 - idx], RandomNumberGenerator.new())
g.players = copies # gleiche Zugreihenfolge wie das Original
g.deck = deck.duplicate()
g.turn = turn
g.current_idx = current_idx
g.winner = winner
g.next_uid = next_uid
g.field_element = field_element
g.field_charge = field_charge.duplicate()
g.quiet = true
return g
## Kopie aus Sicht von viewer: verdeckte gegnerische Wesen sind unbekannt (für die KI).
func masked_clone(viewer: Player) -> Game:
var g := clone()
var me: Player = g.players[players.find(viewer)]
for p in g.players:
if p == me:
continue
for c in p.board:
if c.hidden:
c.card = Cards.unknown()
c.name = c.card.name
c.attack = c.card.attack
c.health = c.card.health
c.max_health = c.card.health
c.shield = false
return g
# --- Zugablauf ---------------------------------------------------------------
func start(fixed_order: bool = false) -> void:
if not fixed_order:
shuffle(players, rng) # zufällige Zugreihenfolge
deck = DeckGen.deck(rng)
for i in players.size():
var p: Player = players[i]
p.order = i
# Wer nicht beginnt, bekommt im ersten Zug einmalig 1 Mana extra.
p.start_bonus = 0 if i == 0 else 1
roll_shop(p)
p.offer = draw_cards(START_OFFER, START_MANA + p.start_bonus)
p.picks_left = START_PICKS
turn = 1
current_idx = 0
say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name)))
@@ -253,61 +261,101 @@ func start(fixed_order: bool = false) -> void:
begin_turn(current())
## Füllt den Laden neu: SHOP_SIZE Karten, höchstens so teuer wie das Einkommen plus eins.
func roll_shop(p: Player) -> void:
p.shop = DeckGen.shop_cards(rng, p.max_energy, p.elements)
## Nimmt n Karten vom Stapel, die höchstens max_cost kosten (was man sich in diesem Zug leisten
## kann); zu teure wandern unter den Stapel. Ist der Stapel leer, kommt ein frischer.
func draw_cards(n: int, max_cost: int) -> Array:
var out := []
var skipped := []
var looked := 0
while out.size() < n:
if deck.is_empty():
deck = DeckGen.deck(rng)
say("Der Stapel wird neu gemischt.")
var c: Cards.Card = deck.pop_back()
looked += 1
if c.cost <= max_cost or looked > deck.size() + skipped.size() + n:
out.append(c)
else:
skipped.append(c)
for c in skipped:
deck.insert(0, c)
return out
## Kauft eine Karte aus dem Laden auf die Hand. Gibt "" zurück oder eine Fehlermeldung.
func buy(p: Player, slot: int) -> String:
## Solange eine Wahl offen ist, geht nichts anderes.
func blocked(p: Player) -> String:
return "Erst wählen" if not p.offer.is_empty() else ""
## Ziehen: kostet DRAW_COST, zeigt OFFER_SIZE Karten, eine davon kommt auf die Hand (siehe pick).
func can_draw(p: Player) -> Array:
if winner != "" or p != current():
return "Nicht dein Zug"
if slot < 0 or slot >= p.shop.size() or p.shop[slot] == null:
return "Kein Angebot"
var card: Cards.Card = p.shop[slot]
if p.energy < card.cost:
return "Nicht genug Mana"
return [false, "Nicht dein Zug"]
if blocked(p) != "":
return [false, blocked(p)]
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 [false, "Deine Hand ist voll"]
if p.energy < draw_cost(p):
return [false, "Nicht genug Mana"]
return [true, ""]
func draw_cost(p: Player) -> int:
return 0 if "ziehen_gratis" in p.perks else DRAW_COST
## Preis einer Eigenschaft für p.
func upgrade_cost(p: Player, keyword: String) -> int:
var c := int(Cards.KEYWORD_COST[keyword])
return maxi(1, c - 1) if "verbessern_billig" in p.perks else c
func draw(p: Player) -> String:
var ok := can_draw(p)
if not ok[0]:
return ok[1]
p.energy -= draw_cost(p)
p.offer = draw_cards(OFFER_SIZE, p.max_energy)
p.picks_left = 1
say("%s zieht." % p.name)
emit({"type": "draw", "player": players.find(p)})
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)})
## Nimmt eine der angebotenen Karten auf die Hand; jederzeit erlaubt, auch außerhalb des Zugs.
## Was liegen bleibt, wandert unter den Stapel.
func pick(p: Player, index: int) -> String:
if winner != "":
return "Vorbei"
if p.offer.is_empty():
return "Keine Wahl offen"
if index < 0 or index >= p.offer.size():
return "Keine solche Karte"
var card: Cards.Card = p.offer[index]
p.hand.append(card.copy())
p.offer.remove_at(index)
p.picks_left -= 1
if p.picks_left <= 0 or p.hand.size() >= MAX_HAND:
for rest in p.offer:
deck.insert(0, rest)
p.offer = []
p.picks_left = 0
say("%s nimmt eine Karte." % p.name)
emit({"type": "pick", "player": players.find(p)})
return ""
func begin_turn(p: Player) -> void:
p.max_energy = mini(MAX_ENERGY, p.max_energy + 1)
# Nicht ausgegebenes Mana bleibt liegen.
p.energy += p.max_energy + (1 if "extra_mana" in p.perks else 0)
p.max_energy = START_MANA + p.turns
p.energy = p.max_energy + (1 if "extra_mana" in p.perks else 0) # der Rest vom letzten Zug ist verfallen
if p.turns == 0 and "start_energie" in p.perks:
p.energy += 2 # einmalige Starthilfe, kein dauerhafter Vorsprung
p.energy += 2
if p.start_bonus > 0:
p.energy += 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
roll_shop(p)
for c in p.board:
c.ready = true
if c.frozen:
c.frozen = false
c.attacks_left = 0
say("%s taut auf und setzt aus." % c.name)
else:
c.health = c.max_health # Wesen erholen sich zu Zugbeginn ganz
c.attacks_left = c.attacks_per_turn()
p.turns += 1
say("--- Zug %d: %s ---" % [turn, p.name])
@@ -317,6 +365,8 @@ func begin_turn(p: Player) -> void:
func end_turn() -> void:
if winner != "":
return
while not current().offer.is_empty(): # offene Wahl: die erste Karte, damit nichts verfällt
pick(current(), 0)
var n := players.size()
for i in range(1, n + 1):
var idx := (current_idx + i) % n
@@ -330,302 +380,296 @@ func end_turn() -> void:
# --- Karten spielen ----------------------------------------------------------
## Ausspielen kostet nichts mehr: bezahlt wird beim Kauf im Laden.
## Ausspielen kostet die Kartenkosten; verdeckt gelegt ist es gleich teuer.
func can_play(p: Player, card: Cards.Card) -> Array:
if winner != "" or p != current():
return [false, "Nicht dein Zug"]
if blocked(p) != "":
return [false, blocked(p)]
if not (card in p.hand):
return [false, "Nicht auf der Hand"]
if p.board.size() >= MAX_BOARD:
return [false, "Dein Feld ist voll"]
if p.energy < card.cost:
return [false, "Nicht genug Mana"]
return [true, ""]
func valid_targets(p: Player, target_type: String) -> Array:
var enemies := enemies_of(p)
var creatures := []
for e in enemies:
creatures.append_array(e.board)
match target_type:
"enemy_creature":
return creatures
"enemy_any":
return creatures + enemies
"friendly_creature":
return p.board.duplicate()
return []
## Spielt eine Karte. Gibt "" zurück oder eine Fehlermeldung.
func play_card(p: Player, card: Cards.Card, target = null) -> String:
## Spielt eine Karte, offen oder verdeckt. Gibt "" zurück oder eine Fehlermeldung.
func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String:
var ok := can_play(p, card)
if not ok[0]:
return ok[1]
var tt := card.target_type()
if tt != "":
var valid := valid_targets(p, tt)
if not valid.is_empty() and not (target in valid):
return "Ungültiges Ziel"
if valid.is_empty():
target = null
p.hand.erase(card)
say("%s spielt %s." % [p.name, card.name])
var cr := _enter(p, card)
for fx in card.effects:
resolve(p, fx, target, cr, card.elements)
if cr in p.board:
trigger_field_combos(p, cr)
charge_field(card)
update_resonance()
cleanup()
check_end()
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
## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus.
func trigger_field_combos(p: Player, cr: Creature) -> void:
for other in p.board:
if other == cr:
continue
for e1 in cr.card.elements:
for e2 in other.card.elements:
if e1 == e2:
continue
var name := Cards.combo_name([e1, e2])
say("Feldkombo %s: %s." % [name, Cards.FIELD_COMBO_TEXT[name]])
emit({"type": "combo", "name": name, "player": players.find(p), "uid": cr.uid})
resolve(p, Cards.field_combo_effect(name), null, cr)
return
## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt.
func _enter(p: Player, card: Cards.Card) -> Creature:
p.energy -= card.cost
var cr := Creature.new(card, p, next_uid)
next_uid += 1
if "waechter_leben" in p.perks and cr.taunt():
cr.health += 2
cr.max_health += 2
cr.hidden = face_down
if "wesen_angriff" in p.perks:
cr.attack += 1
if "wesen_leben" in p.perks:
cr.health += 1
cr.max_health += 1
if "schild_wesen" in p.perks:
cr.shield = true
if "alle_flink" in p.perks:
cr.ready = true
cr.attacks_left = cr.attacks_per_turn()
p.board.append(cr)
emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card})
return cr
if face_down:
say("%s legt ein Wesen verdeckt." % p.name)
else:
say("%s spielt %s." % [p.name, card.name])
emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card, "hidden": face_down})
apply_auras()
cleanup()
check_end()
return ""
## Prüft eine Verschmelzung: zwei eigene Wesen, genug Mana.
func can_fuse(p: Player, a: Creature, b: Creature) -> Array:
## Ein eigenes verdecktes Wesen aufdecken: kostenlos, im eigenen Zug.
func can_reveal(p: Player, cr: Creature) -> Array:
if winner != "" or p != current():
return [false, "Nicht dein Zug"]
if a == null or b == null or a == b:
return [false, "Zwei verschiedene Wesen wählen"]
if not (a in p.board) or not (b in p.board):
if blocked(p) != "":
return [false, blocked(p)]
if cr == null or not (cr in p.board):
return [false, "Nur eigene Wesen"]
if p.energy < Cards.fusion_cost(a.card, b.card):
if not cr.hidden:
return [false, "%s liegt schon offen" % cr.name]
return [true, ""]
func reveal(p: Player, cr: Creature) -> String:
var ok := can_reveal(p, cr)
if not ok[0]:
return ok[1]
_flip(cr)
cleanup()
check_end()
return ""
## Deckt ein Wesen auf; die Elementregel gilt sofort.
func _flip(cr: Creature) -> void:
cr.hidden = false
say("%s wird aufgedeckt." % cr.name)
emit({"type": "reveal", "uid": cr.uid, "card": cr.card})
apply_auras()
# --- Verbessern: Schlüsselwörter für Mana -----------------------------------
## target: eigenes Wesen auf dem Brett oder eigene Handkarte.
func can_upgrade(p: Player, target, keyword: String) -> Array:
if winner != "" or p != current():
return [false, "Nicht dein Zug"]
if blocked(p) != "":
return [false, blocked(p)]
if not Cards.KEYWORD_COST.has(keyword):
return [false, "Unbekannte Eigenschaft"]
var card: Cards.Card = target.card if target is Creature else target
if target is Creature:
if not (target in p.board):
return [false, "Nur eigene Wesen"]
elif not (target in p.hand):
return [false, "Nicht auf der Hand"]
if keyword in card.keywords:
return [false, "%s hat schon %s" % [card.name, keyword]]
if p.energy < upgrade_cost(p, keyword):
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 upgrade(p: Player, target, keyword: String) -> String:
var ok := can_upgrade(p, target, keyword)
if not ok[0]:
return ok[1]
p.energy -= upgrade_cost(p, keyword)
var card: Cards.Card = (target.card if target is Creature else target).copy() # Kopien (KI) teilen sich Karten
card.keywords.append(keyword)
if target is Creature:
target.card = card
else:
p.hand[p.hand.find(target)] = card
if target is Creature:
match keyword:
"Schild":
target.shield = true
"Doppelschlag":
target.attacks_left = mini(target.attacks_per_turn(), target.attacks_left + 1)
say("%s bekommt %s." % [card.name, keyword])
emit({"type": "upgrade", "player": players.find(p), "keyword": keyword, "uid": target.uid if target is Creature else -1})
return ""
# --- Verschmelzen ------------------------------------------------------------
## Kosten: 1 Mana, verdoppelt je Verschmelzung, die schon in einem der beiden steckt.
static func fusion_cost(a: Creature, b: Creature) -> int:
return 1 << maxi(a.card.fusions, b.card.fusions)
func can_fuse(p: Player, a: Creature, b: Creature) -> Array:
if winner != "" or p != current():
return [false, "Nicht dein Zug"]
if blocked(p) != "":
return [false, blocked(p)]
if a == null or b == null or a == b:
return [false, "Zwei verschiedene Wesen wählen"]
if not (a in p.board) or not (b in p.board):
return [false, "Nur eigene Wesen"]
if p.energy < fusion_cost(a, b):
return [false, "Nicht genug Mana"]
return [true, ""]
## Verschmilzt zwei eigene Wesen zu einem offenen Wesen: Elemente und Schlüsselwörter beider,
## jeweils der bessere Wert (Angriff, Leben, Lebenshöchstwert) plus 1. Der Elementbonus der
## Eltern wird herausgerechnet, das neue Wesen bekommt seinen eigenen.
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)
p.energy -= fusion_cost(a, b)
var card := Cards.fused(a.card, b.card, maxi(a.attack, b.attack) + 1,
maxi(a.max_health - a.aura, b.max_health - b.aura) + 1)
var cr := Creature.new(card, p, next_uid)
next_uid += 1
cr.health = maxi(a.health - a.aura, b.health - b.aura) + 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)
cr.attacks_left = 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()
apply_auras()
cleanup()
check_end()
return ""
## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3.
static func resonance_for(partners: int) -> int:
return DeckGen.RESONANCE_STEPS[mini(partners, DeckGen.RESONANCE_STEPS.size() - 1)]
# --- Vier-Elemente-Regel -----------------------------------------------------
## Vorschau fürs Ausspielen: Angriffsbonus aus Resonanz und Feld sowie die Feldkombo,
## die die Karte mit dem ersten eigenen Wesen aus anderem Element auslösen würde.
func forecast(p: Player, card: Cards.Card) -> Dictionary:
var partners := 0
var combo := ""
var partner: Creature = null
for o in p.board:
if o.card.elements.any(func(e): return e in card.elements):
partners += 1
if combo == "":
for e1 in card.elements:
for e2 in o.card.elements:
if e1 != e2 and combo == "":
combo = Cards.combo_name([e1, e2])
partner = o
var resonance := resonance_for(partners) * (2 if "resonanz_doppelt" in p.perks else 1)
var field := 1 if field_element != "" and field_element in card.elements else 0
return {"resonance": resonance, "field": field, "combo": combo, "partner": partner}
## Resonanz: abnehmender Angriffsbonus je Partner mit gleichem Element (siehe resonance_for).
func update_resonance() -> void:
## Offene Wesen je Element auf dem ganzen Tisch.
func element_counts() -> Dictionary:
var counts := {}
for e in Cards.ELEMENTS:
counts[e] = 0
for p in players:
for c in p.board:
var bonus := 0
for o in p.board:
if o != c and o.card.elements.any(func(e): return e in c.card.elements):
bonus += 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
if not c.hidden:
for e in c.card.elements:
counts[e] += 1
return counts
func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, elements: Array = []) -> void:
var enemies := enemies_of(p)
var enemy_creatures := []
for e in enemies:
enemy_creatures.append_array(e.board)
var targets := []
if fx.target in Cards.CHOSEN_TARGETS:
targets = [target] if target != null else []
elif fx.target == "all_enemy_creatures":
targets = enemy_creatures
elif fx.target == "all_enemy_heroes":
targets = enemies.duplicate()
elif fx.target == "all_enemies":
targets = enemy_creatures + enemies
elif fx.target == "all_friendly_creatures":
targets = p.board.duplicate()
elif fx.target == "self_hero":
targets = [p]
elif fx.target == "self_creature":
targets = [source] if source != null else []
elif fx.target == "strongest_enemy_creature":
var best: Creature = null
for c in enemy_creatures:
if best == null or c.power() > best.power():
best = c
targets = [best] if best != null else []
elif fx.target == "weakest_enemy_hero":
var weakest: Player = null
for e in enemies:
if weakest == null or e.life < weakest.life:
weakest = e
targets = [weakest] if weakest != null else []
else:
targets = [null]
for t in targets:
match fx.kind:
"damage":
damage(t, fx.amount + (Cards.matchup(elements, t.card.elements) if t is Creature else 0))
"heal":
t.life = mini(t.max_life, t.life + fx.amount)
say("%s heilt sich um %d." % [t.name, fx.amount])
emit({"type": "heal", "target": ref_of(t), "amount": fx.amount})
"mana":
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":
t.frozen = true
t.attacks_left = 0
say("%s ist eingefroren." % t.name)
emit({"type": "freeze", "target": ref_of(t)})
"bounce":
var o = t.owner()
o.board.erase(t)
emit({"type": "bounce", "target": ref_of(t)})
if o.hand.size() < MAX_HAND:
o.hand.append(t.card)
say("%s kehrt auf die Hand zurück." % t.name)
else:
say("%s wird weggeweht (Hand voll)." % t.name)
"buff":
t.attack += fx.amount
t.health += fx.amount2
t.max_health += fx.amount2
say("%s erhält +%d/+%d." % [t.name, fx.amount, fx.amount2])
emit({"type": "buff", "target": ref_of(t), "text": "+%d/+%d" % [fx.amount, fx.amount2]})
"shield":
t.shield = true
say("%s erhält Schild." % t.name)
emit({"type": "buff", "target": ref_of(t), "text": "Schild"})
## Bonus aus der Elementregel für diese Elemente: +1, wenn ELEMENT_STACK offene Wesen des
## Elements stehen, -1, wenn das Element, das es schlägt, den Stapel voll hat.
## Mehrere Elemente nehmen den besten Wert.
static func aura_of(elements: Array, counts: Dictionary) -> int:
var best := 0
for i in elements.size():
var e: String = elements[i]
var a := 0
if int(counts[e]) >= ELEMENT_STACK:
a += 1
var predator := Cards.beaten_by(e)
if predator != "" and int(counts[predator]) >= ELEMENT_STACK:
a -= 1
best = a if i == 0 else maxi(best, a)
return best
## Bonus eines offenen Wesens aus der Elementregel; verdeckte zählen nicht.
func aura_for(c: Creature, counts: Dictionary) -> int:
if c.hidden:
return 0
var a := aura_of(c.card.elements, counts)
var o = c.owner()
if o != null and "element_doppelt" in o.perks:
a *= 2
return a
## Rechnet die Elementregel neu; Leben folgt dem Bonus direkt (+1/+1 bzw. -1/-1).
func apply_auras() -> void:
var counts := element_counts()
for p in players:
for c in p.board:
var a := aura_for(c, counts)
if a != c.aura:
var delta: int = a - c.aura
c.health += delta
c.max_health += delta
c.aura = a
## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme.
func forecast(p: Player, card: Cards.Card) -> int:
var counts := element_counts()
for e in card.elements:
counts[e] += 1
return aura_of(card.elements, counts) * (2 if "element_doppelt" in p.perks else 1)
# --- Kampf -------------------------------------------------------------------
## Leben lassen sich erst angreifen, wenn der Gegner kein Wesen mehr hat.
func can_attack(cr: Creature, target) -> Array:
if winner != "" or cr.owner() != current():
return [false, "Nicht dein Zug"]
if blocked(cr.owner()) != "":
return [false, blocked(cr.owner())]
if cr.hidden:
return [false, "%s liegt verdeckt" % cr.name]
if not cr.can_attack():
return [false, "%s kann nicht angreifen" % cr.name]
var tgt_owner = target.owner() if target is Creature else target
if not (tgt_owner in enemies_of(cr.owner())):
return [false, "Kein gültiges Ziel"]
var has_taunt := false
for c in tgt_owner.board:
if c.taunt():
has_taunt = true
if has_taunt and not (target is Creature and target.taunt()):
return [false, "Ein Wächter muss zuerst angegriffen werden"]
if not (target is Creature) and not tgt_owner.board.is_empty():
return [false, "Erst die Wesen"]
return [true, ""]
## Greift an. Gibt "" zurück oder eine Fehlermeldung.
## Greift an. Ein verdecktes Ziel wird zuerst aufgedeckt. Schaden über das Leben eines Wesens
## hinaus schlägt auf seinen Besitzer durch (sonst wäre eine Mauer unverwundbar).
## Gibt "" zurück oder eine Fehlermeldung.
func attack(cr: Creature, target) -> String:
var ok := can_attack(cr, target)
if not ok[0]:
return ok[1]
cr.attacks_left -= 1
if target is Creature and target.hidden:
_flip(target)
if target.health <= 0: # der Elementbonus hat es schon erledigt
cleanup()
check_end()
return ""
say("%s greift %s an." % [cr.name, target.name])
emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)})
damage(target, cr.power() + matchup_bonus(cr, target))
if target is Creature and target.power() > 0: # wer nichts austeilt, kontert auch nicht
damage(cr, target.power() + matchup_bonus(target, cr))
var dealt := cr.power() + matchup_bonus(cr, target)
if target is Creature:
var had_shield: bool = target.shield
var hp_before: int = target.health
damage(target, dealt)
if not had_shield and dealt > hp_before:
var o = target.owner()
var excess := dealt - hp_before
o.life -= excess
say("%d Schaden schlagen durch auf %s." % [excess, o.name])
emit({"type": "damage", "target": ref_of(o), "amount": excess})
if target.power() > 0: # wer nichts austeilt, kontert auch nicht; der Konter kennt keinen Elementvorteil
damage(cr, target.power())
else:
damage(target, dealt)
cleanup()
check_end()
return ""
## Elementvorteil beim Zuschlagen: gegen Helden gibt es keinen.
## Elementvorteil beim Zuschlagen: +1 im Vorteil, -1 im Nachteil (die Verteidigung des
## Ziels), gegen Helden gibt es keinen.
static func matchup_bonus(source: Creature, target) -> int:
if not (target is Creature):
return 0
@@ -648,7 +692,9 @@ func damage(target, amount: int) -> void:
emit({"type": "damage", "target": ref_of(target), "amount": amount})
## Räumt Tote ab; danach gilt die Elementregel neu, was weitere Tote nach sich ziehen kann.
func cleanup() -> void:
for round in 8:
var died := false
for p in players:
for c in p.board.duplicate():
@@ -661,8 +707,9 @@ func cleanup() -> void:
say("%s stirbt." % c.name)
emit({"type": "death", "target": ref_of(c)})
died = true
if died:
update_resonance()
if not died:
return
apply_auras()
## Ende: einer von beiden hat kein Leben mehr.

View File

@@ -384,23 +384,37 @@ func _apply(id: int, action: Dictionary) -> void:
if game == null or not seats.has(id):
return
var p: Game.Player = game.players[seats[id]]
if game.current() != p or game.winner != "":
if game.winner != "":
return
if str(action.get("type", "")) == "pick": # wählen geht jederzeit, auch außerhalb des Zugs
game.pick(p, int(action.get("index", -1)))
action_applied.emit()
return
if game.current() != p:
return
match str(action.get("type", "")):
"play":
var idx := int(action.get("hand", -1))
if idx < 0 or idx >= p.hand.size():
return
game.play_card(p, p.hand[idx], resolve_ref(action.get("target")))
game.play_card(p, p.hand[idx], bool(action.get("face_down", false)))
"reveal":
var cr := game.find_creature(int(action.get("uid", -1)))
if cr != null:
game.reveal(p, cr)
"upgrade":
var cr := game.find_creature(int(action.get("uid", -1)))
var hi := int(action.get("hand", -1))
var target = cr if cr != null else (p.hand[hi] if hi >= 0 and hi < p.hand.size() else null)
if target != null:
game.upgrade(p, target, str(action.get("keyword", "")))
"attack":
var cr := game.find_creature(int(action.get("uid", -1)))
var t = resolve_ref(action.get("target"))
if cr != null and t != null:
game.attack(cr, t)
"buy":
game.buy(p, int(action.get("slot", -1)))
"reroll":
game.reroll(p)
"draw":
game.draw(p)
"fuse":
var a := game.find_creature(int(action.get("uid", -1)))
var b := game.find_creature(int(action.get("other", -1)))

View File

@@ -2,17 +2,17 @@ class_name NetState
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln).
## 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).
## Karten reisen als ID; verschmolzene stehen nicht im Pool und gehen als Ganzes mit.
## Karten reisen als ID; eigene Exemplare mit Schlüsselwörtern und verschmolzene als Ganzes.
static func card_ref(c: Cards.Card):
return c.id if Cards.has_id(c.id) else c.to_dict()
return c.id if Cards.has_id(c.id) and c.keywords.is_empty() and c.fusions == 0 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)
return Cards.Card.from_dict(ref) if ref is Dictionary else Cards.by_id(ref).copy()
## Zustand aus Sicht eines Spielers: fremde Hand nur als Anzahl, fremde Wahl gar nicht, fremde
## verdeckte Wesen ohne Karte und Werte. viewer -1: alles sichtbar (Host-Wechsel); -2: Zuschauer.
static func snapshot(g: Game, viewer: int) -> Dictionary:
var ps := []
for i in g.players.size():
@@ -20,15 +20,16 @@ static func snapshot(g: Game, viewer: int) -> Dictionary:
var mine := i == viewer or viewer == -1
var board := []
for c in p.board:
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,
"ready": c.ready, "attacks_left": c.attacks_left})
var masked: bool = c.hidden and not mine
board.append({"uid": c.uid, "card": card_ref(Cards.unknown() if masked else c.card),
"attack": 0 if masked else c.attack, "health": 0 if masked else c.health,
"max_health": 0 if masked else c.max_health, "shield": c.shield and not masked,
"attacks_left": c.attacks_left, "hidden": c.hidden, "aura": c.aura})
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, "start_bonus": p.start_bonus,
"order": p.order, "perks": p.perks, "elements": p.elements, "looks": p.looks, "turns": p.turns,
"order": p.order, "perks": p.perks, "looks": p.looks, "turns": p.turns,
"hand": p.hand.map(card_ref) if mine else p.hand.size(),
"shop": p.shop.map(func(c): return c.id if c != null else "") if mine else [],
"offer": p.offer.map(func(c): return c.id) if mine else [], "picks_left": p.picks_left,
"board": board})
var evs := []
for ev in g.events:
@@ -38,7 +39,7 @@ static func snapshot(g: Game, viewer: int) -> Dictionary:
evs.append(e)
return {"players": ps, "human": g.players.find(g.human), "turn": g.turn, "current_idx": g.current_idx,
"winner": g.winner, "winner_player": g.players.find(g.winner_player) if g.winner_player != null else -1,
"next_uid": g.next_uid, "field_element": g.field_element, "field_charge": g.field_charge,
"next_uid": g.next_uid, "deck_size": g.deck.size(),
"log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs}
@@ -47,7 +48,7 @@ static func from_snapshot(d: Dictionary) -> Game:
var placeholder: Cards.Card = Cards.all()[0]
var list := []
for pd in d.players:
var p := Game.Player.new(pd.name, pd.is_human, int(pd.life), pd.title, Array(pd.get("elements", [])))
var p := Game.Player.new(pd.name, pd.is_human, int(pd.life), pd.title)
p.max_life = int(pd.max_life)
p.energy = int(pd.energy)
p.max_energy = int(pd.max_energy)
@@ -63,20 +64,19 @@ static func from_snapshot(d: Dictionary) -> Game:
else:
for i in int(pd.hand):
p.hand.append(placeholder)
for cid in pd.get("shop", []):
p.shop.append(Cards.by_id(cid) if str(cid) != "" else null)
for cid in pd.get("offer", []):
p.offer.append(Cards.by_id(cid))
p.picks_left = int(pd.get("picks_left", 0))
for cd in pd.board:
var card := card_of(cd.card)
var c := Game.Creature.new(card, p, int(cd.uid))
c.attack = int(cd.attack)
c.resonance = int(cd.resonance)
c.field_bonus = int(cd.get("field_bonus", 0))
c.health = int(cd.health)
c.max_health = int(cd.max_health)
c.shield = bool(cd.shield)
c.frozen = bool(cd.frozen)
c.ready = bool(cd.ready)
c.attacks_left = int(cd.attacks_left)
c.hidden = bool(cd.get("hidden", false))
c.aura = int(cd.get("aura", 0))
p.board.append(c)
list.append(p)
var hi := int(d.human)
@@ -87,9 +87,8 @@ static func from_snapshot(d: Dictionary) -> Game:
g.winner = d.winner
g.winner_player = list[int(d.winner_player)] if int(d.winner_player) >= 0 else null
g.next_uid = int(d.next_uid)
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 i in int(d.get("deck_size", 0)):
g.deck.append(placeholder) # nur die Größe zählt; gezogen wird beim Host
for line in d.log:
g.log.append(str(line))
for ev in d.events:

View File

@@ -32,32 +32,21 @@ func play_events(evs: Array, centers: Dictionary) -> void:
"damage":
sfx("damage")
popup("-%d" % ev.amount, Style.RED, center_of(ev.target, centers), delay)
"heal":
sfx("heal")
popup("+%d" % ev.amount, Style.GREEN, center_of(ev.target, centers), delay)
"buff":
popup(ev.text, Style.GREEN, center_of(ev.target, centers), delay)
"blocked":
popup("Schild!", Style.BLUE, center_of(ev.target, centers), delay)
"freeze":
popup("Eis", Color("aaddff"), center_of(ev.target, centers), delay)
"combo":
sfx("combo")
var where: Vector2 = screen.target_center({"kind": "creature", "uid": ev.uid})
if where.x < 0:
where = screen.target_center({"kind": "player", "index": ev.player})
popup("%s!" % tr(ev.name), Style.GOLD, where + Vector2(0, -30), 0.1)
"play":
sfx("play")
sfx("click" if ev.get("hidden", false) else "play")
"reveal":
sfx("combo")
popup(tr("Aufgedeckt!"), Style.GOLD, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
"upgrade":
sfx("heal")
if ev.uid >= 0:
popup("+" + tr(ev.keyword), Style.GREEN, screen.target_center({"kind": "creature", "uid": ev.uid}), 0.0)
"fuse":
sfx("combo")
popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
"field":
sfx("combo")
banner("%s: %s" % [tr(Cards.FIELD_NAMES[ev.element]), tr(Cards.FIELD_TEXT[ev.element])], Cards.ELEMENT_COLORS[ev.element])
"buy":
sfx("click") # die Karte landet sichtbar auf der Hand, das reicht
"reroll":
"draw":
sfx("click")

View File

@@ -1,12 +1,12 @@
class_name BattleGamepad
extends RefCounted
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Laden, Hand, eigene Wesen
## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge,
## X macht rückgängig.
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Stapel, Hand, eigene Wesen
## und Ziele (bei offener Wahl über deren Karten), A bestätigt, B bricht ab, Start beendet den
## Zug, Y öffnet das Kartenmenü (im Gegnerzug: überspringen), X macht rückgängig.
var screen # BattleScreen
var active := false
var zone := "shop"
var zone := "deck"
var index := 0
var targets: Array = []
@@ -19,6 +19,12 @@ func button(button: int) -> void:
active = true
if screen.game == null:
return
if not screen.pick_views.is_empty() and zone != "pick":
zone = "pick"
index = 0
elif screen.pick_views.is_empty() and zone == "pick":
zone = "hand"
index = 0
match button:
JOY_BUTTON_START:
if screen.my_turn() and not screen.any_overlay():
@@ -27,10 +33,20 @@ func button(button: int) -> void:
if screen.confirm.visible:
screen.confirm.visible = false
screen.cancel_all()
zone = "shop"
zone = "deck"
JOY_BUTTON_Y:
if screen.b_skip.visible:
screen.skip_ai = true
elif screen.menu_layer.visible:
screen.close_menu()
else:
var items := items()
if not items.is_empty() and zone in ["hand", "own"]:
var item = items[clampi(index, 0, items.size() - 1)]
if zone == "hand":
screen.open_menu(item.card, Rect2(item.position, item.size))
else:
screen.open_menu(item.creature, Rect2(item.slot, item.size))
JOY_BUTTON_X:
if screen.b_undo.visible:
screen.undo()
@@ -49,7 +65,7 @@ func button(button: int) -> void:
## 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 order := ["deck", "hand", "own"]
var at := order.find(zone)
index = 0
if step == 0 and not items().is_empty():
@@ -64,8 +80,10 @@ func _next_zone(step: int) -> void:
func items() -> Array:
match zone:
"shop":
return screen.shop_views
"deck":
return [screen.deck] if screen.deck.visible else []
"pick":
return screen.pick_views
"hand":
return screen.hand_views
"own":
@@ -95,24 +113,13 @@ func confirm() -> void:
index = clampi(index, 0, items.size() - 1)
var item = items[index]
match zone:
"shop":
screen.on_click("shop", int(item.get_meta("slot", 0)))
"deck":
screen.on_click("deck", null)
"pick":
screen.do_pick(index)
index = 0
"hand":
screen.on_click("hand", item.card)
if screen.pending != null:
targets = []
var valid: Array = screen.game.valid_targets(screen.perspective, screen.pending.target_type())
for v in screen.enemy_views:
if v.creature in valid:
targets.append(v)
for v in screen.own_views:
if v.creature in valid:
targets.append(v)
for o in screen.others():
if o in valid:
targets.append(screen.hero_panels[o])
zone = "target"
index = 0
"own":
screen.on_click("own", item.creature)
if screen.selected != null:

View File

@@ -1,7 +1,7 @@
class_name BattleLayout
## Feste Bildschirmaufteilung des Duells (1280x800), von unten nach oben: Laden mit Neuwürfeln
## und Zug beenden, Hand, eigenes Brett (mit eigener Leiste links), Feldband, oben der Gegner
## mit seinem Brett.
## Feste Bildschirmaufteilung des Duells (1280x800), von unten nach oben: Hand mit dem
## Zieh-Stapel links und Zug beenden rechts, eigenes Brett (mit eigener Leiste links),
## Feldband, oben der Gegner mit seinem Brett. Eine offene Wahl liegt als Überlagerung darüber.
const B_GIVEUP := Rect2(10, 10, 96, 32)
const B_LOG := Rect2(112, 10, 96, 32)
@@ -13,15 +13,14 @@ const STATUS := Rect2(20, 288, 210, 160)
const B_FUSE := Rect2(1050, 298, 210, 40)
const B_UNDO := Rect2(1050, 346, 210, 40)
const OWN_X := 345
const HAND_W := 96
const HAND_H := 62
const HAND_GAP := 4
const HAND_Y := 458
const SHOP_Y := 532
const SHOP_W := 150
const SHOP_H := 205
const B_REROLL := Rect2(20, 606, 200, 52)
const HAND_W := 150
const HAND_H := 205
const HAND_GAP := 10
const HAND_Y := 532
const DECK := Rect2(50, 540, 130, 182)
const B_END := Rect2(1060, 600, 200, 60)
const PICK_PANEL := Rect2(120, 150, 1040, 440)
const PICK_CARD := Vector2(180, 252)
const LOG_PANEL := Rect2(240, 60, 800, 560)
const PREVIEW_SIZE := Vector2(180, 252)
const CENTER_X := 240
@@ -37,12 +36,15 @@ static func opp_slot(j: int) -> Rect2:
return Rect2(OWN_X + j * 120, 76, 110, 140)
## Laden: fünf große Angebote unten, wie in TFT.
static func shop_rect(i: int) -> Rect2:
return Rect2(245 + i * (SHOP_W + 10), SHOP_Y, SHOP_W, SHOP_H)
## Angebotene Karten einer Wahl, mittig im Wahlfenster.
static func pick_rect(count: int, i: int) -> Rect2:
var gap := 20
var total := count * PICK_CARD.x + maxi(0, count - 1) * gap
var x := PICK_PANEL.position.x + (PICK_PANEL.size.x - total) / 2
return Rect2(Vector2(x + i * (PICK_CARD.x + gap), PICK_PANEL.position.y + 96), PICK_CARD)
## Hand: flache Kacheln zwischen Brett und Laden (die Bank).
## Hand: bis zu fünf große Karten unten, mittig.
static func hand_rect(count: int, i: int) -> Rect2:
var total := count * HAND_W + maxi(0, count - 1) * HAND_GAP
var x := CENTER_X + (CENTER_W - total) / 2

View File

@@ -1,6 +1,8 @@
class_name BattleScreen
extends Control
## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx.
## Rechtsklick (oder Y) auf eine Handkarte oder ein eigenes Wesen öffnet das Kartenmenü:
## verdeckt legen, aufdecken, Eigenschaften kaufen.
## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer).
var app
@@ -12,7 +14,6 @@ var game: Game
var perspective: Game.Player # der Mensch, dessen Hand unten liegt
var fx: BattleFx
var selected = null # Game.Creature
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
@@ -39,7 +40,7 @@ var undo_t := 0.0
# Ansichten (auch für Tests)
var tiles := {}
var hand_views: Array = []
var shop_views: Array = []
var pick_views: Array = [] # Karten der offenen Wahl
var own_views: Array = []
var enemy_views: Array = []
var hero_panels := {} # Player -> Panel
@@ -49,7 +50,10 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
var hero_layer: Control
var tile_layer: Control
var hand_layer: Control
var shop_layer: Control
var pick_layer: Control # Überlagerung der offenen Wahl
var pick_box: Control # ihr Inhalt (wird je Wahl neu aufgebaut)
var pick_key := "" # welche Wahl gerade aufgebaut ist
var deck: Deck
var log_overlay: Control
var b_log: Button
var status_panel: Panel
@@ -64,15 +68,14 @@ var status_label: Label
var msg_label: Label
var b_end: Button
var b_fuse: Button
var b_reroll: Button
var b_giveup: Button
var field_view: FieldView
var link_view: Links # Resonanzlinien unter den Feldreihen
var info_label: Label # Vorschau: Resonanz, Feld, Feldkombo
var board_panel: Panel # eigenes Brett, färbt sich mit dem Feld
var element_view: ElementView
var info_label: Label # Vorschau: Elementbonus, Merkmale
var board_panel: Panel # eigenes Brett
var menu_layer: Control # Kartenmenü (Rechtsklick)
var menu_target = null # Karte oder Wesen, für das das Menü offen ist
var arrivals := {} # uid -> Rect2: Wesen, das gerade aus der Hand aufs Brett fliegt
var banner_turn := -1 # Zug, für den die Zugansage schon lief
var deal_shop := false # nächster Aufbau teilt den Laden animiert aus
var overlay: Control
var result_label: Label
var reward_label: Label
@@ -172,10 +175,10 @@ func _build_static() -> void:
b_giveup.add_theme_font_size_override("font_size", 13)
b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log)
b_log.add_theme_font_size_override("font_size", 13)
# Mitte: Feldband, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben
# Mitte: Elementzähler, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben
Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8)
field_view = FieldView.new(L.FIELD)
add_child(field_view)
element_view = ElementView.new(L.FIELD)
add_child(element_view)
msg_label = Style.label(self, "", 15, Style.ORANGE, L.MSG, "center", true)
board_panel = Style.panel(self, L.OWN_PANEL, Style.darker(Style.PANEL, 0.8), 10)
var slots := Control.new()
@@ -200,9 +203,10 @@ func _build_static() -> void:
b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo)
b_undo.add_theme_font_size_override("font_size", 15)
b_undo.visible = false
# unten: Laden mit Neuwürfeln links und Zug beenden rechts
b_reroll = Style.button(self, "", L.B_REROLL, do_reroll, Color("285a5a"))
b_reroll.add_theme_font_size_override("font_size", 15)
# unten: Zieh-Stapel links, Hand in der Mitte, Zug beenden rechts
deck = Deck.new(L.DECK)
deck.clicked.connect(func(): on_click("deck", null))
add_child(deck)
b_end = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY)
b_end.add_theme_font_size_override("font_size", 20)
b_skip = Style.button(self, "Gegnerzüge überspringen", L.B_END, func(): skip_ai = true)
@@ -211,16 +215,27 @@ func _build_static() -> void:
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false
hero_layer = _layer()
link_view = Links.new()
add_child(link_view)
tile_layer = _layer()
hand_layer = _layer()
shop_layer = _layer()
preview_holder = _layer()
info_label = Style.label(self, "", 13, Style.TEXT, Rect2(0, 0, 220, 60), "center")
info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
info_label.visible = false
fx_layer = _layer()
# offene Wahl: drei sehen, eine nehmen (zu Beginn fünf sehen, drei nehmen)
pick_layer = Style.overlay(self)
pick_box = Control.new()
pick_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
pick_layer.add_child(pick_box)
# Kartenmenü: durchsichtiger Fänger, der das Menü beim Klick daneben schließt
menu_layer = Control.new()
menu_layer.set_anchors_preset(Control.PRESET_FULL_RECT)
menu_layer.mouse_filter = Control.MOUSE_FILTER_STOP
menu_layer.visible = false
menu_layer.gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed and ev.button_index == MOUSE_BUTTON_LEFT:
close_menu())
add_child(menu_layer)
cursor_layer = _layer()
# Verlauf als Überlagerung
log_overlay = Style.overlay(self)
@@ -339,14 +354,23 @@ func my_turn() -> bool:
func any_overlay() -> bool:
return confirm.visible or overlay.visible or log_overlay.visible
return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible
func _input(event: InputEvent) -> void:
if game == null:
return
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
var under := get_viewport().gui_get_hovered_control()
if menu_layer.visible:
close_menu()
elif under is CardView and under in hand_views and my_turn():
open_menu(under.card, Rect2(under.position, under.size))
elif under is CreatureView and under in own_views and my_turn():
open_menu(under.creature, Rect2(under.slot, under.size))
else:
cancel_all()
get_viewport().set_input_as_handled() # sonst schließt derselbe Klick das Menü gleich wieder
return
if event is InputEventJoypadButton and event.pressed:
pad.button(event.button_index)
@@ -357,9 +381,11 @@ func _input(event: InputEvent) -> void:
KEY_ESCAPE:
if confirm.visible:
confirm.visible = false
elif menu_layer.visible:
close_menu()
elif log_overlay.visible:
log_overlay.visible = false
elif selected != null or pending != null or fusing:
elif selected != null or fusing:
cancel_all()
elif not any_overlay():
confirm.visible = true
@@ -375,15 +401,20 @@ func _input(event: InputEvent) -> void:
if overlay.visible:
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 not spectating() and not any_overlay():
var idx: int = event.keycode - KEY_1
if idx < perspective.hand.size():
if not perspective.offer.is_empty():
do_pick(idx)
elif my_turn() and idx < perspective.hand.size():
on_click("hand", perspective.hand[idx])
elif event.keycode == KEY_Z and my_turn() and not any_overlay():
on_click("deck", null)
func cancel_all() -> void:
if confirm.visible:
confirm.visible = false
close_menu()
cancel()
refresh()
@@ -438,7 +469,7 @@ func _process(delta: float) -> void:
func _start_ai(cur: Game.Player) -> void:
ai_copy = game.clone()
ai_copy = game.masked_clone(cur) # verdeckte Wesen des Menschen kennt die KI nicht
var copy := ai_copy
var idx := game.current_idx
var box := ai_box
@@ -472,25 +503,24 @@ func say(m: String) -> void:
func cancel() -> void:
selected = null
pending = null
fusing = false
fuse_first = null
func play(card: Cards.Card, target) -> void:
func play(card: Cards.Card, face_down: bool = false) -> void:
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), "face_down": face_down})
return
var before: Game = game.clone() if not is_online() else null
var log_len := game.log.size()
var from := _hand_rect_of(card)
var err := game.play_card(perspective, card, target)
var err := game.play_card(perspective, card, face_down)
if err != "":
say(tr(err))
return
if from.size.x > 0 and not perspective.board.is_empty() and perspective.board[-1].card == card:
arrivals[perspective.board[-1].uid] = from
# Rückgängig nur, wenn nichts weiter passiert ist (keine Effekte und keine Kombos)
# Rückgängig nur, wenn nichts weiter passiert ist (kein Wesen gestorben)
if before != null and game.events.size() == 1 and game.events[0].type == "play":
undo_state = before
undo_log_len = log_len
@@ -499,6 +529,84 @@ func play(card: Cards.Card, target) -> void:
clear_undo()
## Deckt ein eigenes verdecktes Wesen auf (kostenlos).
func do_reveal(cr: Game.Creature) -> void:
clear_undo()
if is_client():
net.send_action({"type": "reveal", "uid": cr.uid})
return
var err := game.reveal(perspective, cr)
if err != "":
say(tr(err))
after_action()
## Kauft eine Eigenschaft für eine Handkarte oder ein eigenes Wesen.
func do_upgrade(target, keyword: String) -> void:
clear_undo()
if is_client():
if target is Game.Creature:
net.send_action({"type": "upgrade", "uid": target.uid, "keyword": keyword})
else:
net.send_action({"type": "upgrade", "hand": perspective.hand.find(target), "keyword": keyword})
return
var err := game.upgrade(perspective, target, keyword)
if err != "":
say(tr(err))
after_action()
## Kartenmenü neben einer Handkarte oder einem eigenen Wesen.
func open_menu(target, anchor: Rect2) -> void:
if spectating() or not my_turn():
return
Style.clear(menu_layer)
menu_target = target
var entries := [] # [Text, Callable, aktiv]
var is_card: bool = target is Cards.Card
var card: Cards.Card = target if is_card else target.card
if is_card:
var ok := game.can_play(perspective, card)
entries.append([tr("Verdeckt legen ◆ %d") % card.cost, func():
play(card, true)
after_action(), ok[0]])
elif target.hidden:
entries.append([tr("Aufdecken"), func(): do_reveal(target), game.can_reveal(perspective, target)[0]])
for kw in Cards.KEYWORDS:
var k: String = kw
var ok := game.can_upgrade(perspective, target, k)
var text := "%s ◆ %d" % [tr(k), game.upgrade_cost(perspective, k)]
if k in card.keywords:
text = "%s ✓" % tr(k)
entries.append([text, func(): do_upgrade(target, k), ok[0]])
var w := 230.0
var h := 18.0 + entries.size() * 44 + 34
var x := clampf(anchor.end.x + 8, 10, 1270 - w)
if x + w > anchor.position.x and x < anchor.end.x:
x = clampf(anchor.position.x - 8 - w, 10, 1270 - w)
var y := clampf(anchor.position.y, 10, 790 - h)
var box := Rect2(x, y, w, h)
Style.panel(menu_layer, box, Style.PANEL, 10, Style.GOLD, 2)
Style.label(menu_layer, card.title(), 15, Style.GOLD, Rect2(box.position.x + 12, box.position.y + 8, w - 24, 22), "left", true)
var by := box.position.y + 36
for e in entries:
var act: Callable = e[1]
var b := Style.button(menu_layer, e[0], Rect2(box.position.x + 12, by, w - 24, 38), func():
close_menu()
act.call())
b.add_theme_font_size_override("font_size", 15)
b.disabled = not e[2]
by += 44
menu_layer.visible = true
func close_menu() -> void:
if menu_layer.visible:
menu_layer.visible = false
Style.clear(menu_layer)
menu_target = null
## Verschmelzen an- und abwählen (zwei eigene Wesen, siehe Game.fuse).
func toggle_fuse() -> void:
var on := not fusing
@@ -508,37 +616,36 @@ func toggle_fuse() -> void:
after_action()
## Kauft ein Angebot aus dem Laden auf die Hand.
func do_buy(slot: int) -> void:
## Ziehen: kostet Mana, öffnet die Wahl aus drei Karten.
func do_draw() -> void:
clear_undo()
if is_client():
net.send_action({"type": "buy", "slot": slot})
net.send_action({"type": "draw"})
return
var from := BattleLayout.shop_rect(slot)
var offer = perspective.shop[slot] if slot < perspective.shop.size() else null
var err := game.buy(perspective, slot)
if err != "":
say(tr(err))
elif offer != null:
fx.sfx("buy")
fx.fly_card(offer, from, BattleLayout.hand_rect(perspective.hand.size(), perspective.hand.size() - 1),
Cards.RARITIES[int(perspective.looks.get(offer.id, 0))])
## 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)
var err := game.draw(perspective)
if err != "":
say(tr(err))
else:
deal_shop = true
fx.sfx("buy")
## Nimmt eine Karte der offenen Wahl auf die Hand (jederzeit, auch außerhalb des Zugs).
func do_pick(index: int) -> void:
if spectating() or index < 0 or index >= pick_views.size():
return
clear_undo()
var from := Rect2(pick_views[index].position, pick_views[index].size)
var card: Cards.Card = pick_views[index].card
if is_client():
net.send_action({"type": "pick", "index": index})
return
var err := game.pick(perspective, index)
if err != "":
say(tr(err))
return
fx.sfx("play")
fx.fly_card(card, from, BattleLayout.hand_rect(perspective.hand.size(), perspective.hand.size() - 1),
Cards.RARITIES[int(perspective.looks.get(card.id, 0))])
after_action()
@@ -609,15 +716,6 @@ func on_click(kind: String, obj) -> void:
do_fuse(fuse_first, obj)
after_action()
return
if pending != null:
var card = pending
pending = null
if obj in g.valid_targets(perspective, card.target_type()):
play(card, obj)
else:
say(tr("Abgebrochen"))
after_action()
return
if selected != null:
var cr = selected
selected = null
@@ -632,18 +730,18 @@ func on_click(kind: String, obj) -> void:
after_action()
return
match kind:
"shop":
do_buy(int(obj))
"deck":
do_draw()
"hand":
var ok := g.can_play(perspective, obj)
if not ok[0]:
say(tr(ok[1]))
elif obj.target_type() != "" and not g.valid_targets(perspective, obj.target_type()).is_empty():
pending = obj
say(tr("Wähle ein Ziel"))
else:
play(obj, null)
play(obj)
"own":
if obj.hidden:
do_reveal(obj)
return
if obj.can_attack():
selected = obj
say(tr("Wähle ein Angriffsziel"))
@@ -669,8 +767,6 @@ func announce_turn() -> void:
banner_turn = game.turn
fx.banner(tr("Dein Zug"), Style.GOLD)
fx.sfx("turn")
deal_shop = true
refresh()
func _hand_rect_of(card: Cards.Card) -> Rect2:
@@ -696,87 +792,109 @@ func show_preview(card, entered: bool, owner_looks: Dictionary = {}, info: Strin
info_label.size = ir.size
## Eigene Hand- und Ladenkarte: zeigt, was das Ausspielen jetzt brächte, und markiert den Kombo-Partner.
## Eigene Hand- und Wahlkarte: zeigt den Elementbonus, den sie offen gelegt bekäme.
func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void:
var f := game.forecast(perspective, card)
var lines: Array[String] = []
var parts: Array[String] = []
if f.resonance > 0:
parts.append(tr("Resonanz +%d") % f.resonance)
if f.field > 0:
parts.append(tr("Feld +%d") % f.field)
if not parts.is_empty():
lines.append(" · ".join(parts))
if f.combo != "":
lines.append(tr("Feldkombo %s mit %s: %s") % [tr(f.combo), f.partner.card.title(), tr(Cards.FIELD_COMBO_TEXT[f.combo])])
for t in tiles.values():
t.mark = ""
t.queue_redraw()
if entered and f.partner != null and tiles.has(f.partner.uid):
tiles[f.partner.uid].mark = f.combo
tiles[f.partner.uid].queue_redraw()
show_preview(card, entered, perspective.looks, "\n".join(lines), anchor)
var a := game.forecast(perspective, card)
var info := ""
if a > 0:
info = tr("Elementbonus +%d/+%d") % [a, a]
elif a < 0:
info = tr("Elementbonus %d/%d") % [a, a]
show_preview(card, entered, perspective.looks, info, anchor)
## Hand- und Ladenkarte tragen ihren Bonus und ihre Feldkombo als Vorschau.
## Offene Wahl als Überlagerung: nur neu aufbauen, wenn sich das Angebot geändert hat.
func build_pick() -> void:
var h := perspective
var key := ""
if not spectating() and not h.offer.is_empty():
key = "%d:%s" % [h.picks_left, ",".join(h.offer.map(func(c): return c.id))]
if key == pick_key:
return
pick_key = key
Style.clear(pick_box)
pick_views = []
if key == "":
pick_layer.visible = false
return
var box := BattleLayout.PICK_PANEL
Style.panel(pick_box, box, Style.PANEL, 12, Style.GOLD, 2)
Style.label(pick_box, tr("Wähle %d von %d") % [h.picks_left, h.offer.size()], 26, Style.GOLD,
Rect2(box.position.x, box.position.y + 24, box.size.x, 40), "center", true)
for i in h.offer.size():
var card: Cards.Card = h.offer[i]
var r := BattleLayout.pick_rect(h.offer.size(), i)
var v := CardView.new(card, r.size, false, Cards.RARITIES[int(h.looks.get(card.id, 0))])
v.position = r.position
v.lift = true
apply_forecast(v)
var idx := i
v.clicked.connect(func(_view): do_pick(idx))
pick_box.add_child(v)
pick_views.append(v)
pick_layer.visible = true
if Style.animations:
for i in pick_views.size():
fx.deal(pick_views[i], i)
## Hand- und Wahlkarten tragen den Elementbonus als Vorschau.
func apply_forecast(v: CardView) -> void:
var f := game.forecast(perspective, v.card)
v.bonus = f.resonance + f.field
v.tag = f.combo
v.bonus = game.forecast(perspective, v.card)
## Was den Angriff eines Wesens gerade hebt.
## Was den Angriff eines Wesens gerade hebt oder senkt.
func creature_info(cr: Game.Creature) -> String:
var parts: Array[String] = []
if cr.resonance > 0:
parts.append(tr("Resonanz +%d") % cr.resonance)
if cr.field_bonus > 0:
parts.append(tr("Feld +%d") % cr.field_bonus)
var perk := cr.power() - cr.attack - cr.resonance - cr.field_bonus
if cr.aura > 0:
parts.append(tr("Elementbonus +%d/+%d") % [cr.aura, cr.aura])
elif cr.aura < 0:
parts.append(tr("Elementbonus %d/%d") % [cr.aura, cr.aura])
var perk := cr.power() - cr.attack - cr.aura
if perk > 0:
parts.append(tr("Merkmal +%d") % perk)
if cr.hidden:
parts.append(tr("verdeckt"))
return " · ".join(parts)
## Elementvorteil gegen ein mögliches Ziel: des gewählten Angreifers oder eines Schadenszaubers auf der Hand.
func matchup_for(cr: Game.Creature, valid: Array) -> int:
if selected != null and game.can_attack(selected, cr)[0]:
## Elementvorteil des gewählten Angreifers gegen ein mögliches Ziel; gegen Verdeckte unbekannt.
func matchup_for(cr: Game.Creature) -> int:
if selected != null and not cr.hidden and game.can_attack(selected, cr)[0]:
return Game.matchup_bonus(selected, cr)
if pending != null and cr in valid and pending.effects.any(func(fx): return fx.kind == "damage" and fx.target in Cards.CHOSEN_TARGETS):
return Cards.matchup(pending.elements, cr.card.elements)
return 0
## Resonanzgruppen je Reihe: Wesen mit gleichem Element werden unter der Reihe verbunden.
func resonance_groups() -> Array:
var out := []
for p in game.players:
var k := 0
for e in Cards.ELEMENTS:
var xs: Array[float] = []
for j in p.board.size():
if e in p.board[j].card.elements:
xs.append(slot_rect(p, j).get_center().x)
if xs.size() < 2:
continue
out.append({"color": Style.lighter(Cards.ELEMENT_COLORS[e], 0.25), "y": slot_rect(p, 0).end.y + 4 + k * 6, "xs": xs})
k += 1
return out
class Deck extends Control:
signal clicked
var enabled := true
var count := 0
var cost := Game.DRAW_COST
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_STOP
class Links extends Control:
var groups: Array = []
func _init() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit()
func _draw() -> void:
for gr in groups:
var y: float = gr.y
draw_line(Vector2(gr.xs[0], y), Vector2(gr.xs[-1], y), gr.color, 3.0)
for x in gr.xs:
draw_circle(Vector2(x, y), 4.0, gr.color)
for k in range(2, -1, -1):
var r := Rect2(Vector2(k * 3, -k * 3), size - Vector2(6, 12)).grow(0)
r.position.y += 6
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD if enabled else Style.MUTED, 2), r)
if k == 0:
Style.border(self, r.grow(-12), Color("5a3c82"), 2, 6)
Style.text(self, tr("Ziehen"), 16, Style.TEXT if enabled else Style.MUTED, r.get_center() + Vector2(0, -12), "center", true)
Style.text(self, "◆ %d" % cost, 20, Style.BLUE if enabled else Style.MUTED, r.get_center() + Vector2(0, 14), "center", true)
Style.text(self, str(count), 12, Style.MUTED, Vector2(r.get_center().x, r.end.y - 18), "center")
if not enabled:
draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35))
# --- Netzwerk -------------------------------------------------------------------------------
@@ -845,9 +963,7 @@ func refresh() -> void:
var h := perspective
var now := Time.get_ticks_msec()
var mine := my_turn()
var valid: Array = g.valid_targets(h, pending.target_type()) if pending != null else []
hand_views = []
shop_views = []
own_views = []
enemy_views = []
@@ -858,7 +974,7 @@ func refresh() -> void:
if g.current() == o:
bcol = Style.BLUE
bw = 3
if o in valid or (selected != null and o.alive() and g.can_attack(selected, o)[0]):
if selected != null and o.alive() and g.can_attack(selected, o)[0]:
bcol = Style.ORANGE
bw = 3
if int(flash_until.get(g.players.find(o), 0)) > now:
@@ -870,7 +986,7 @@ func refresh() -> void:
labels.name.add_theme_color_override("font_color", Style.TEXT if o.alive() else Style.MUTED)
if o.alive():
labels.life.text = "♥ %d" % maxi(o.life, 0)
labels.stats.text = "Hand %d ◆ %d (+%d)" % [o.hand.size(), o.energy, o.max_energy]
labels.stats.text = "Hand %d ◆ %d" % [o.hand.size(), o.energy]
else:
labels.life.text = "besiegt"
labels.life.add_theme_color_override("font_color", Style.MUTED)
@@ -898,7 +1014,11 @@ func refresh() -> void:
tiles[cr.uid] = tile
tile_layer.add_child(tile)
var looks: Dictionary = p.looks
tile.hover.connect(func(card, entered): show_preview(card, entered, looks, creature_info(tile.creature) if entered else "", Rect2(tile.slot, tile.size)))
tile.hover.connect(func(card, entered):
if tile.secret:
show_preview(null, false)
else:
show_preview(card, entered, looks, creature_info(tile.creature) if entered else "", Rect2(tile.slot, tile.size)))
var uid := cr.uid
tile.clicked.connect(func(_v):
var c := game.find_creature(uid)
@@ -920,7 +1040,7 @@ func refresh() -> void:
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 selected != null and g.can_attack(selected, cr)[0]:
b = Style.ORANGE
w = 3
elif p == h and mine and cr.can_attack():
@@ -928,8 +1048,8 @@ func refresh() -> void:
w = 3
tile.border = b
tile.width = w
tile.mark = ""
tile.matchup = matchup_for(cr, valid)
tile.secret = cr.hidden and p != h
tile.matchup = matchup_for(cr)
tile.queue_redraw()
if p == h:
own_views.append(tile)
@@ -940,31 +1060,12 @@ func refresh() -> void:
var t: CreatureView = tiles[uid]
tiles.erase(uid)
fx.fade_out(t)
link_view.groups = resonance_groups()
link_view.queue_redraw()
Style.clear(shop_layer)
if not spectating():
for i in h.shop.size():
var offer = h.shop[i]
var sr := BattleLayout.shop_rect(i)
if offer == null:
Style.panel(shop_layer, sr, Style.darker(Style.PANEL, 0.8), 8, Style.PANEL_LIGHT, 2)
continue
var sv := CardView.new(offer, sr.size, false, Cards.RARITIES[int(h.looks.get(offer.id, 0))])
sv.position = sr.position
sv.lift = mine
apply_forecast(sv)
sv.dim = not mine or h.energy < offer.cost or h.hand.size() >= Game.MAX_HAND
sv.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(sv.position, sv.size)))
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)
if deal_shop:
fx.deal(sv, i)
deal_shop = false
build_pick()
deck.visible = not spectating()
deck.enabled = mine and g.can_draw(h)[0]
deck.count = g.deck.size()
deck.cost = g.draw_cost(h)
deck.queue_redraw()
Style.clear(hand_layer)
if spectating():
@@ -978,9 +1079,7 @@ func refresh() -> void:
v.position = r.position
v.lift = mine
apply_forecast(v)
if card == pending:
v.border = Style.YELLOW
elif playable:
if playable:
v.border = Style.GREEN
v.dim = not playable
v.hover.connect(func(c, e): show_hand_preview(c, e, Rect2(v.position, v.size)))
@@ -992,14 +1091,10 @@ func refresh() -> void:
if int(flash_until.get(g.players.find(h), 0)) > now:
bar_border = Style.RED
status_panel.add_theme_stylebox_override("panel", Style.box(Style.PANEL, 8, bar_border, 3 if bar_border != null else 0))
var board_col := Style.darker(Style.PANEL, 0.8)
if g.field_element != "":
board_col = board_col.lerp(Cards.ELEMENT_COLORS[g.field_element], 0.22) # das Feld färbt das Brett
board_panel.add_theme_stylebox_override("panel", Style.box(board_col, 10))
name_label.text = h.name
life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d" % h.energy
income_label.text = tr("+%d je Zug") % h.max_energy
income_label.text = tr("nächster Zug ◆ %d") % (h.max_energy + 1)
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():
status_label.text = tr("Zuschauer") + " · " + status_label.text
@@ -1009,21 +1104,18 @@ func refresh() -> void:
b_end.visible = not ai_running and not spectating()
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")
field_view.update_from(g)
element_view.update_from(g)
var lines: Array[String] = []
var start := maxi(0, g.log.size() - 60)
for i in range(start, g.log.size()):
var line := g.log[i]
if line.begins_with("---"):
lines.append("[color=#6eaaf0]%s[/color]" % line)
elif line.begins_with("Das Feld wird"):
elif " verschmelzen zu " in line or " wird aufgedeckt." in line:
lines.append("[color=#8c78dc]%s[/color]" % line)
elif line.begins_with("Feldkombo"):
lines.append("[color=#f5c850]%s[/color]" % line)
elif " bekommt " in line:
lines.append("[color=#5ad282]%s[/color]" % line)
else:
lines.append(line)
log_box.text = "\n".join(lines)

View File

@@ -88,17 +88,9 @@ func build() -> void:
var next := selected == p.campaign_stage
Style.panel(self, Rect2(240, 270, 800, 300))
Style.label(self, "%s · %s" % [st.name, st.title], 28, Style.TEXT, Rect2(270, 290, 740, 40), "center", true)
var dots := Control.new()
dots.mouse_filter = Control.MOUSE_FILTER_IGNORE
var elems: Array = st.elements
dots.draw.connect(func():
var w := elems.size() * 26 - 6
for m in elems.size():
dots.draw_circle(Vector2(640 - w / 2.0 + 10 + m * 26, 348), 10, Cards.ELEMENT_COLORS[elems[m]]))
add_child(dots)
Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 370, 740, 24), "center")
Style.label(self, "%s · %s" % [tr("%d Leben") % int(st.life), tr("%d Münzen") % st.reward], 16, Style.MUTED, Rect2(270, 350, 740, 24), "center")
var perks: Array = st.perks
var py := 402
var py := 390
for k in perks:
Style.label(self, "★ " + tr(Game.PERKS[k]), 15, Style.GOLD, Rect2(270, py, 740, 22), "center")
py += 24

View File

@@ -12,8 +12,7 @@ var card: Cards.Card
var border = null # Color oder null (dann Rahmen der Seltenheitsstufe)
var rarity := "gewöhnlich" # nur Optik
var dim := false
var bonus := 0 # Vorschau im Kampf: Angriff, den die Karte auf dem Feld dazubekäme
var tag := "" # Vorschau im Kampf: Feldkombo, die das Ausspielen auslöst
var bonus := 0 # Vorschau im Kampf: Elementbonus, den die Karte offen gelegt bekäme (+1 oder -1)
var lift := false # hebt sich beim Zeigen an (Hand und Laden)
@@ -132,32 +131,27 @@ func _draw_full(rect: Rect2, frame: Color, seed: int) -> void:
y += icon * 2 + 8
# Effekttext: so hoch wie nötig, endet über den Wertkreisen
var box := Rect2()
var fx := card.effect_text()
var fx := card.text()
var box_bottom: float = size.y - r_stat * 2 - 4
if fx != "" and box_bottom - y > Style.fs(f_text) + 6:
var tsize := Style.fs(f_text)
var heading := big
var needed: float = Style.FONT.get_multiline_string_size(fx, HORIZONTAL_ALIGNMENT_LEFT, size.x - 20, tsize).y + 8 + (tsize + 2 if heading else 0)
var needed: float = Style.FONT.get_multiline_string_size(fx, HORIZONTAL_ALIGNMENT_LEFT, size.x - 20, tsize).y + 8
var box_top: float = maxf(y, y + (box_bottom - y - needed) / 2)
box = Rect2(6, box_top, size.x - 12, box_bottom - box_top)
draw_rect(box, Color(0.06, 0.06, 0.09, 0.72))
var ty := box.position.y + 4
if heading:
Style.text(self, tr("Beim Ausspielen"), f_text - 2, Style.MUTED, Vector2(box.position.x + 4, ty), "topleft")
ty += tsize + 2
var max_lines := maxi(1, int((box.end.y - ty - 2) / Style.FONT.get_height(tsize)))
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, ty + Style.FONT.get_ascent(tsize)),
fx, HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
# Werte springen aus den unteren Ecken, dazwischen der Edelstein
Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat)
Style.stat_circle(self, Vector2(size.x - r_stat + 2, size.y - r_stat + 2), r_stat, str(card.health), Style.CRIMSON, f_stat)
if bonus > 0:
Style.text_outlined(self, "+%d" % bonus, f_text, Style.GREEN, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3)
if bonus != 0:
Style.text_outlined(self, ("+%d/+%d" % [bonus, bonus]) if bonus > 0 else ("%d/%d" % [bonus, bonus]), f_text,
Style.GREEN if bonus > 0 else Style.RED, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3)
Style.rarity_gem(self, rarity, Vector2(size.x / 2, size.y - r_stat + 1), 8 if big else 6, seed)
Style.border(self, rect, frame, 3 if big else 2, 8)
Style.cost_crystal(self, Vector2(r_cost + 2, r_cost + 2), r_cost, card.cost, f_stat)
if tag != "":
_draw_tag(Vector2(size.x - 6, 6), f_text - 1)
Style.rarity_sparkle(self, rect, rarity, seed, box)
@@ -170,10 +164,6 @@ func _draw_compact(rect: Rect2, frame: Color, seed: int) -> void:
var nf := Style.fit_size(card.title(), true, 12, name_w)
Style.text(self, Style.fit(card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(28, 13), "midleft", true)
Style.rarity_gem(self, rarity, Vector2(size.x - 9, 13), 5, seed)
if tag != "":
var w := Style.FONT_BOLD.get_string_size(tr(tag), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(9)).x + 12
_draw_tag(Vector2(size.x / 2 + w / 2, size.y - 21), 9)
else:
var kws: Array[String] = []
for k in Cards.KEYWORDS:
if k in card.keywords:
@@ -184,15 +174,6 @@ func _draw_compact(rect: Rect2, frame: Color, seed: int) -> void:
x += 18
Style.stat_circle(self, Vector2(11, size.y - 11), 10, str(card.attack), Style.AMBER, 12)
Style.stat_circle(self, Vector2(size.x - 11, size.y - 11), 10, str(card.health), Style.CRIMSON, 12)
if bonus > 0:
Style.text_outlined(self, "+%d" % bonus, 10, Style.GREEN, Vector2(4, size.y - 29), "midleft", 3)
if bonus != 0:
Style.text_outlined(self, ("+%d" % bonus) if bonus > 0 else str(bonus), 10, Style.GREEN if bonus > 0 else Style.RED, Vector2(4, size.y - 29), "midleft", 3)
Style.rarity_sparkle(self, rect, rarity, seed)
## Goldenes Schild mit der Feldkombo, rechts oben.
func _draw_tag(top_right: Vector2, font: int) -> void:
var w := Style.FONT_BOLD.get_string_size(tr(tag), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(font)).x + 12
var h := Style.fs(font) + 6
var r := Rect2(top_right - Vector2(w, 0), Vector2(w, h))
draw_style_box(Style.box(Color("14141c"), 6, Style.GOLD, 1), r)
Style.text(self, tr(tag), font, Style.GOLD, r.get_center(), "center", true)

View File

@@ -11,8 +11,8 @@ var width := 2
var flash := false # kurz rot: wurde gerade angegriffen
var slot: Vector2 # Zielposition im Raster
var busy := false # wird gerade animiert (kein Neupositionieren)
var mark := "" # Feldkombo, die dieses Wesen als Partner auslösen würde
var matchup := 0 # Elementvorteil des gewählten Angreifers gegen dieses Wesen (+1/-1)
var secret := false # verdecktes fremdes Wesen: nur der Rücken ist zu sehen
func _init(cr: Game.Creature, sz: Vector2, b = null, w: int = 2) -> void:
@@ -45,36 +45,31 @@ func rarity() -> String:
func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size)
if secret:
_draw_back(rect)
return
var small := size.x < 100
var mini := size.x < 60
var rar := rarity()
var seed := hash(creature.card.id) + creature.uid
if rar != "gewöhnlich":
Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 4 if mini else 6)
Style.glow(self, rect, Style.rarity_color(rar, seed), 6, 6)
Style.fill_elements(self, rect, creature.card.elements, 6)
if not mini:
Style.glyph(self, creature.card, Vector2(size.x / 2, size.y * 0.5), size.x * 0.75, 0.3)
Style.rarity_sheen(self, rect, rar, seed)
var bcol: Color = border if border != null else Style.rarity_frame(rar, seed)
if flash:
bcol = Style.RED
Style.border(self, rect, bcol, 4 if flash else width, 6)
if mark != "":
Style.border(self, rect.grow(3), Style.GOLD, 3, 8)
if mini:
_draw_mini()
Style.rarity_sparkle(self, rect, rar, seed)
return
var fs := 11 if small else 13
var name_w: float = size.x - (30 if matchup != 0 else 6) # Platz für den Vorteilskreis oben rechts
var nf := Style.fit_size(creature.card.title(), true, fs, name_w)
draw_rect(Rect2(2, 2, size.x - 4, Style.fs(fs) + 8), Color(0.05, 0.05, 0.08, 0.7))
Style.text(self, Style.fit(creature.card.title(), true, Style.fs(nf), name_w), nf, Style.TEXT, Vector2(3 + name_w / 2, 6), "midtop", true)
# Schlüsselwörter als Symbole
# Schlüsselwörter als Symbole (Schild nur, solange er steht)
var icon := 7.0 if small else 9.0
var kws: Array[String] = []
for k in Cards.KEYWORDS:
if k in creature.card.keywords:
if k in creature.card.keywords and (k != "Schild" or creature.shield):
kws.append(k)
if creature.shield and not ("Schild" in kws):
kws.append("Schild")
@@ -85,23 +80,31 @@ func _draw() -> void:
x += icon * 2 + 6
_draw_stats(15 if small else 17, 10 if small else 12, 12.0 if small else 14.0)
Style.rarity_gem(self, rar, Vector2(size.x / 2, size.y - 9), 5, seed)
if mark != "":
var w := Style.FONT_BOLD.get_string_size(tr(mark), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(11)).x + 14
draw_style_box(Style.box(Color("14141c"), 6, Style.GOLD, 1), Rect2(size.x / 2 - w / 2, -9, w, 18))
Style.text(self, tr(mark), 11, Style.GOLD, Vector2(size.x / 2, 0), "center", true)
if creature.frozen:
draw_rect(rect, Color(0.55, 0.78, 1.0, 0.35))
Style.text_outlined(self, "❄", 22 if small else 28, Color("dff2ff"), size / 2, "center", 3)
if creature.hidden:
draw_rect(rect, Color(0.05, 0.05, 0.1, 0.55))
Style.text_outlined(self, tr("verdeckt"), 12, Style.TEXT, Vector2(size.x / 2, size.y / 2), "center", 3)
Style.rarity_sparkle(self, rect, rar, seed)
## Angriff (grün mit „+n“, wenn Resonanz, Feld oder Merkmale ihn heben), Leben, Elementvorteil.
## Verdecktes fremdes Wesen: ein Kartenrücken.
func _draw_back(rect: Rect2) -> void:
var bcol: Color = border if border != null else Style.GOLD
if flash:
bcol = Style.RED
draw_style_box(Style.box(Color("3c285a"), 6, bcol, 4 if flash else maxi(width, 2)), rect)
Style.border(self, rect.grow(-10), Color("5a3c82"), 2, 6)
Style.text(self, "?", 36, Style.GOLD, size / 2, "center", true)
if matchup != 0:
pass # unbekanntes Element: kein Vorteil anzeigen
## Angriff (grün oder rot, wenn die Elementregel ihn hebt oder senkt), Leben, Elementvorteil.
func _draw_stats(f: int, f_small: int, r: float) -> void:
var extra := creature.power() - creature.attack
var atk_col := Style.GREEN if extra > 0 else Style.AMBER
var atk_col := Style.GREEN if extra > 0 else (Style.RED if extra < 0 else Style.AMBER)
Style.stat_circle(self, Vector2(r - 1, size.y - r + 1), r, str(creature.power()), atk_col, f)
if extra > 0:
Style.text_outlined(self, "+%d" % extra, f_small, Style.GREEN, Vector2(r * 2 + 3, size.y - r + 1), "midleft", 3)
if extra != 0:
Style.text_outlined(self, ("+%d" % extra) if extra > 0 else ("−%d" % -extra), f_small, Style.GREEN if extra > 0 else Style.RED, Vector2(r * 2 + 3, size.y - r + 1), "midleft", 3)
var hp_col := Style.CRIMSON if creature.health < creature.max_health else Color("b84a4a")
Style.stat_circle(self, Vector2(size.x - r + 1, size.y - r + 1), r, str(creature.health), hp_col, f)
if matchup != 0:
@@ -109,26 +112,3 @@ func _draw_stats(f: int, f_small: int, r: float) -> void:
draw_circle(c, 11, Color("14141c"))
Style.text(self, ("+%d" % matchup) if matchup > 0 else ("−%d" % -matchup), f_small + 1,
Style.GREEN if matchup > 0 else Style.RED, c, "center", true)
## Sehr kleine Kacheln (4 oder 5 Gegner): nur Kürzel und Werte, Details per Vorschau oder Lupe.
func _draw_mini() -> void:
var letters: Array[String] = []
if creature.taunt():
letters.append("W")
if creature.shield:
letters.append("S")
if creature.attacks_per_turn() == 2:
letters.append("2")
if creature.frozen:
letters.append("❄")
Style.text(self, " ".join(letters), 10, Color("ebebf5"), Vector2(size.x / 2, 3), "midtop", true)
var extra := creature.power() - creature.attack
Style.text_outlined(self, str(creature.power()), 13, Style.GREEN if extra > 0 else Style.YELLOW, Vector2(9, size.y - 9), "center", 3)
var hp_col := Style.RED if creature.health < creature.max_health else Color("ffc8c8")
Style.text_outlined(self, str(creature.health), 13, hp_col, Vector2(size.x - 9, size.y - 9), "center", 3)
if matchup != 0:
Style.text(self, ("+%d" % matchup) if matchup > 0 else ("−%d" % -matchup), 10,
Style.GREEN if matchup > 0 else Style.RED, Vector2(size.x - 3, 2), "topright", true)
if creature.frozen:
draw_rect(Rect2(Vector2.ZERO, size), Color(0.55, 0.78, 1.0, 0.35))

View File

@@ -0,0 +1,73 @@
class_name ElementView
extends Control
## Die Vier-Elemente-Regel auf einen Blick: offene Wesen je Element auf dem Tisch. Ab vier
## bekommt das Element +1/+1 und das Element, das es schlägt, -1/-1.
var counts := {}
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
for e in Cards.ELEMENTS:
counts[e] = 0
func update_from(g: Game) -> void:
counts = g.element_counts()
queue_redraw()
func strong(e: String) -> bool:
return int(counts.get(e, 0)) >= Game.ELEMENT_STACK
func weak(e: String) -> bool:
var predator := Cards.beaten_by(e)
return predator != "" and strong(predator)
## Der Inhalt als Text (Tests, Protokoll).
func text() -> String:
var parts: Array[String] = []
for e in Cards.ELEMENTS:
var s := "%s %d" % [tr(e), int(counts.get(e, 0))]
if strong(e):
s += " (+1/+1)"
if weak(e):
s += " (-1/-1)"
parts.append(s)
return " · ".join(parts)
func _draw() -> void:
var w := size.x / Cards.ELEMENTS.size()
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var col: Color = Cards.ELEMENT_COLORS[e]
var x0 := i * w
var cy := size.y / 2
var n := int(counts.get(e, 0))
if strong(e):
draw_style_box(Style.box(Color(col, 0.35), 6, col, 2), Rect2(x0 + 2, 0, w - 4, size.y))
elif weak(e):
draw_style_box(Style.box(Color(Style.RED, 0.12), 6, Style.RED, 1), Rect2(x0 + 2, 0, w - 4, size.y))
Style.text(self, tr(e), 13, Style.TEXT if strong(e) else Style.MUTED, Vector2(x0 + 12, cy), "midleft", strong(e))
var px := x0 + 12 + Style.FONT_BOLD.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 12
for k in Game.ELEMENT_STACK:
var c := Vector2(px + k * 13, cy)
if k < n:
draw_circle(c, 4.5, col)
else:
draw_circle(c, 4.5, Color(0, 0, 0, 0.35))
draw_arc(c, 4.5, 0, TAU, 16, Color(col, 0.6), 1.0)
if n > Game.ELEMENT_STACK:
Style.text(self, "+%d" % (n - Game.ELEMENT_STACK), 11, Style.MUTED, Vector2(px + Game.ELEMENT_STACK * 13 + 2, cy), "midleft")
var tag := ""
if strong(e):
tag = "+1/+1"
elif weak(e):
tag = "-1/-1"
if tag != "":
Style.text(self, tag, 13, Style.GREEN if strong(e) else Style.RED, Vector2(x0 + w - 12, cy), "midright", true)

View File

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

View File

@@ -1,53 +0,0 @@
class_name FieldView
extends Control
## Das gemeinsame Feld: je Element vier Ladepunkte, bei vollen Punkten kippt das Feld.
## Das aktive Feld steht farbig mit seinem Bonus da.
var element := ""
var charge := {}
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from(g: Game) -> void:
element = g.field_element
charge = g.field_charge.duplicate()
queue_redraw()
## Der Inhalt als Text (Tests, Protokoll).
func text() -> String:
var parts: Array[String] = []
for e in Cards.ELEMENTS:
if e == element:
parts.append("%s: +1 %s" % [tr(Cards.FIELD_NAMES[e]), tr("Angriff")])
else:
parts.append("%s %d/%d" % [tr(e), int(charge.get(e, 0)), Cards.FIELD_CHARGE])
return " · ".join(parts)
func _draw() -> void:
var w := size.x / Cards.ELEMENTS.size()
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var col: Color = Cards.ELEMENT_COLORS[e]
var x0 := i * w
var cy := size.y / 2
if e == element:
draw_style_box(Style.box(Color(col, 0.35), 6, col, 2), Rect2(x0 + 2, 0, w - 4, size.y))
Style.text(self, "%s +1 %s" % [tr(Cards.FIELD_NAMES[e]), tr("Angriff")], 13, Style.lighter(col, 0.4), Vector2(x0 + w / 2, cy), "center", true)
continue
Style.text(self, tr(e), 13, Style.MUTED, Vector2(x0 + 12, cy), "midleft")
var n := int(charge.get(e, 0))
var px := x0 + 12 + Style.FONT.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 14
for k in Cards.FIELD_CHARGE:
var c := Vector2(px + k * 13, cy)
if k < n:
draw_circle(c, 4.5, col)
else:
draw_circle(c, 4.5, Color(0, 0, 0, 0.35))
draw_arc(c, 4.5, 0, TAU, 16, Color(col, 0.6), 1.0)

View File

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

View File

@@ -1,7 +1,7 @@
class_name OpponentSelectScreen
extends Control
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf
## aus dem Laden (siehe DeckGen.shop_cards).
## per Ziehen (siehe DeckGen.offer_cards).
var app
var diff_buttons := {}

View File

@@ -344,9 +344,19 @@ static func rarity_sheen(ci: CanvasItem, rect: Rect2, rar: String, seed: int) ->
var lx := x0 + (w - lw) / 2
var band := PackedVector2Array([Vector2(lx, rect.position.y), Vector2(lx + lw, rect.position.y), Vector2(lx + lw - k, rect.end.y), Vector2(lx - k, rect.end.y)])
for poly in Geometry2D.intersect_polygons(band, frame):
if poly.size() >= 3 and _area(poly) > 2.0: # Splitter am Rand lassen sich nicht zeichnen
ci.draw_colored_polygon(poly, Color(1, 1, 1, layer[1]))
static func _area(poly: PackedVector2Array) -> float:
var a := 0.0
for i in poly.size():
var p := poly[i]
var q := poly[(i + 1) % poly.size()]
a += p.x * q.y - q.x * p.y
return absf(a) / 2.0
## Funkeln ab legendär: kleine Sterne, die aufblitzen und vergehen; ganz zum Schluss zeichnen.
## avoid: Bereich, der frei bleibt (der Kartentext).
static func rarity_sparkle(ci: CanvasItem, rect: Rect2, rar: String, seed: int, avoid: Rect2 = Rect2()) -> void:
@@ -434,8 +444,7 @@ static func stat_circle(ci: CanvasItem, c: Vector2, r: float, value: String, col
text_outlined(ci, value, font, Color.WHITE, c, "center", 3)
## Schlüsselwort als Symbol auf dunklem Kreis: Wächter Mauer, Schild Schild, Flink Doppelpfeil,
## Doppelschlag gekreuzte Klingen.
## Schlüsselwort als Symbol auf dunklem Kreis: Schild Schild, Doppelschlag gekreuzte Klingen.
static func keyword_icon(ci: CanvasItem, kw: String, c: Vector2, r: float, col: Color = Color("f2f2f6")) -> void:
ci.draw_circle(c, r + 3, Color(0.05, 0.05, 0.08, 0.7))
var w := maxf(1.5, r * 0.22)

View File

@@ -19,11 +19,15 @@ func _ready() -> void:
await get_tree().process_frame
await get_tree().process_frame
var h: Game.Player = scr.game.human
scr.game.buy(h, 0)
for i in 3:
scr.game.pick(h, 0)
h.energy = 10
scr.game.draw(h)
scr.game.pick(h, 0)
var t0 := Time.get_ticks_usec()
for i in 30:
scr.refresh()
print("refresh() mit 8 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
print("refresh() mit 4 Handkarten: %.2f ms" % ((Time.get_ticks_usec() - t0) / 1000.0 / 30))
scr.ai_delay = 0.2
measuring = true
var start := Time.get_ticks_msec()

View File

@@ -73,22 +73,20 @@ func _act_host() -> void:
_decide(game.players[seat])
## Einfache Züge mit kurzer Bedenkzeit: erst das billigste bezahlbare Angebot kaufen, dann das
## erste spielbare Wesen ohne Ziel, dann angreifen, sonst Zug beenden.
## Einfache Züge mit kurzer Bedenkzeit: erst eine offene Wahl treffen, dann das erste
## spielbare Wesen ohne Ziel legen, dann angreifen, dann ziehen, sonst Zug beenden.
func _decide(me: Game.Player) -> void:
var g := game
await get_tree().create_timer(0.25).timeout
if game != g or (hosting and (net.game == null or net.game.current_idx != seat)):
return # inzwischen ein neuer Zustand
for i in me.shop.size():
var offer = me.shop[i]
if offer != null and me.energy >= offer.cost and me.hand.size() < Game.MAX_HAND:
net.send_action({"type": "buy", "slot": i})
if not me.offer.is_empty():
net.send_action({"type": "pick", "index": 0})
return
for i in me.hand.size():
var card: Cards.Card = me.hand[i]
if game.can_play(me, card)[0] and card.target_type() == "":
net.send_action({"type": "play", "hand": i, "target": null})
if game.can_play(me, card)[0]:
net.send_action({"type": "play", "hand": i})
return
for cr in me.board:
if cr.can_attack():
@@ -100,4 +98,7 @@ func _decide(me: Game.Player) -> void:
if game.can_attack(cr, c)[0]:
net.send_action({"type": "attack", "uid": cr.uid, "target": {"kind": "creature", "uid": c.uid}})
return
if game.can_draw(me)[0]:
net.send_action({"type": "draw"})
return
net.send_action({"type": "end"})

View File

@@ -14,7 +14,7 @@ func _initialize() -> void:
print("Kartenpool laden: %.1f ms" % t)
var r := RandomNumberGenerator.new()
r.seed = 3
print("Ladenangebot: %.2f ms" % ms(func(): DeckGen.shop_cards(r, 5), 50))
print("Stapel mischen: %.2f ms" % ms(func(): DeckGen.deck(r), 20))
print("Gegner erzeugen: %.2f ms" % ms(func(): DeckGen.random_opponent(), 20))
for n in [1]:
var g := Game.new_battle(r)

File diff suppressed because it is too large Load Diff

View File

@@ -51,9 +51,13 @@ func click(node: Control) -> void:
func right_click() -> void:
await right_click_at(Vector2(5, 5))
func right_click_at(pos: Vector2) -> void:
var down := InputEventMouseButton.new()
down.position = Vector2(5, 5)
down.global_position = down.position
down.position = pos
down.global_position = pos
down.button_index = MOUSE_BUTTON_RIGHT
down.pressed = true
get_viewport().push_input(down)
@@ -149,7 +153,7 @@ func _ready() -> void:
await frames(3)
var fast := OS.get_environment("DINGER_FAST") != ""
var suite := [test_menu, test_gamepad_menue, test_booster, test_gegnerauswahl, test_einstellungen,
test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_feld_und_verschmelzen, test_kampf_grenzen]
test_kampagne, test_sammlung, test_sprache, test_kampf_steuerung, test_kampf_elemente_und_verschmelzen, test_kampf_grenzen]
if not fast:
suite += [test_kampf.bind(3), test_kampf_abbrechen]
# Die Netzwerktests starten Bot-Prozesse und warten auf Verbindungen; das dauert Minuten.
@@ -215,18 +219,25 @@ func target_view(scr, valid: Array) -> Control:
return null
func human_turn(scr) -> void:
## Nimmt alle offenen Wahlen des Spielers (immer die erste Karte), auch über das Netz.
func pick_all(scr) -> void:
var guard := 0
while scr.game != null and not scr.perspective.offer.is_empty() and guard < 60:
guard += 1
if scr.pick_views.is_empty():
await frames(1)
continue
var n: int = scr.perspective.offer.size()
await click(scr.pick_views[0])
var w := 0
while scr.game != null and scr.perspective.offer.size() == n and w < 120:
await frames(1)
w += 1
## Legt, was bezahlbar ist.
func play_hand(scr) -> void:
var g: Game = scr.game
# 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:
var playable: Array = scr.hand_views.filter(func(v): return g.can_play(g.human, v.card)[0])
if playable.is_empty():
@@ -234,9 +245,22 @@ func human_turn(scr) -> void:
var card: Cards.Card = playable[0].card
var board_before: int = g.human.board.size()
await click(playable[0])
if scr.pending != null:
await click(target_view(scr, g.valid_targets(g.human, card.target_type())))
check(g.human.board.size() > board_before, "Karte gespielt: " + card.name)
func human_turn(scr) -> void:
var g: Game = scr.game
# erst legen, was bezahlbar ist, dann mit dem Rest ziehen (und wieder legen)
for round in 2:
await play_hand(scr)
for i in 6:
if g.winner != "" or not g.can_draw(g.human)[0]:
break
var mana: int = g.human.energy
await click(scr.deck)
check(g.human.energy == mana - Game.DRAW_COST and not scr.pick_views.is_empty(), "gezogen: drei zur Wahl")
await pick_all(scr)
check(scr.pick_views.is_empty(), "gewählt")
for i in 12:
var ready: Array = scr.own_views.filter(func(v): return v.creature.can_attack())
if ready.is_empty() or g.winner != "":
@@ -249,7 +273,7 @@ func human_turn(scr) -> void:
if scr.selected != cr:
print("DEBUG Auswahl fehlgeschlagen: rect=", ready[0].get_global_rect(), " scale=", ready[0].scale,
" busy=", ready[0].busy, " unter Maus=", under, " Meldung='", scr.msg_label.text,
"' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " pending=", scr.pending, " selected=", scr.selected)
"' Zug Mensch=", g.current().is_human, " kann=", cr.can_attack(), " selected=", scr.selected)
check(scr.selected == cr, "Wesen gewählt")
var targets := []
for o in [g.opponent]:
@@ -269,6 +293,7 @@ func start_battle() -> void:
await click(find_button("Einzelspieler"))
check(screen() is OpponentSelectScreen, "Gegnerauswahl")
await click(find_button("Starten"))
await pick_all(screen())
func test_gegnerauswahl() -> void:
@@ -285,8 +310,12 @@ func test_gegnerauswahl() -> void:
check(hard is BattleScreen and hard.game.players.size() == 2, "Duell gestartet")
var opp: Game.Player = hard.game.opponent
check(opp.max_life == Profile.DIFFICULTIES.schwer.life and hard.smart_ai, "Schwer: Gegner mit %d Leben, Vorausschau-KI" % Profile.DIFFICULTIES.schwer.life)
check(hard.game.human.shop.size() == Game.SHOP_SIZE and hard.game.human.hand.is_empty(),
"Start mit vollem Laden und leerer Hand")
check(hard.game.human.offer.size() == Game.START_OFFER and hard.game.human.hand.is_empty() and hard.pick_layer.visible,
"Start: fünf Karten zur Wahl, Hand leer")
check(hard.game.human.offer.all(func(c): return c.cost <= Game.START_MANA + 1), "alle fünf sind im ersten Zug spielbar")
await shot("05_startwahl")
await pick_all(hard)
check(hard.game.human.hand.size() == Game.START_PICKS and not hard.pick_layer.visible, "drei behalten, Wahl geschlossen")
check(hard.hero_panels[opp].position == BattleLayout.OPP_BOX.position, "der Gegner steht oben in der Mitte")
await shot("11_kampf")
await give_up()
@@ -294,9 +323,10 @@ func test_gegnerauswahl() -> void:
await click(find_button("Leicht"))
await click(find_button("Starten"))
var easy = screen()
await pick_all(easy)
opp = easy.game.opponent
check(opp.max_life == Profile.DIFFICULTIES.leicht.life and not easy.smart_ai, "Leicht: Gegner mit %d Leben, einfache KI" % Profile.DIFFICULTIES.leicht.life)
check(easy.game.players[0].max_energy == 1 and easy.game.human.life == Game.START_LIFE, "gleiche Regeln für beide")
check(easy.game.players[0].max_energy == Game.START_MANA and easy.game.human.life == Game.START_LIFE, "gleiche Regeln für beide")
await give_up()
await click(find_button("Einzelspieler"))
await click(find_button("Normal"))
@@ -457,39 +487,47 @@ func test_kampf_steuerung() -> void:
scr.ai_delay = 0.0
await wait_for_human(scr)
var g: Game = scr.game
g.human.energy = 20 # das Angebot ist zufällig, hier soll nur die Steuerung geprüft werden
g.human.energy = 20 # die Wahl ist zufällig, hier soll nur die Steuerung geprüft werden
g.human.hand = [Cards.by_id("f_wolf")]
scr.refresh()
check(scr.shop_views.size() == Game.SHOP_SIZE and g.human.hand.is_empty(), "voller Laden, leere Hand")
# Kaufen: Mana weg, Karte auf der Hand
var offer: Cards.Card = scr.shop_views[0].card
check(scr.hand_views.size() == 1 and scr.deck.enabled and scr.pick_views.is_empty(), "eine Handkarte, Stapel bereit, keine Wahl offen")
# Ziehen: 1 Mana weg, drei zur Wahl, eine landet auf der Hand
var mana: int = g.human.energy
await click(scr.shop_views[0])
check(g.human.hand.size() == 1 and g.human.hand[0] == offer and g.human.energy == mana - offer.cost, "Angebot gekauft")
# Ausspielen kostet nichts
await click(scr.deck)
check(g.human.energy == mana - Game.DRAW_COST and scr.pick_views.size() == Game.OFFER_SIZE and scr.pick_layer.visible, "gezogen: drei Karten zur Wahl")
var wanted: Cards.Card = scr.pick_views[1].card
await click(scr.pick_views[1])
check(g.human.hand.size() == 2 and g.human.hand[1].id == wanted.id and not scr.pick_layer.visible, "gewählt, liegt auf der Hand")
# Ausspielen kostet die Kartenkosten
var board_before: int = g.human.board.size()
mana = g.human.energy
await key(KEY_1)
check(g.human.board.size() == board_before + 1 and g.human.energy == mana, "Wesen per Taste gespielt, ohne Mana")
# Rückgängig: Wesen zurück auf die Hand
check(g.human.board.size() == board_before + 1 and g.human.energy == mana - Cards.by_id("f_wolf").cost, "Wesen per Taste gespielt, Kosten bezahlt")
# Rückgängig: Wesen zurück auf die Hand, Mana zurück
check(scr.b_undo.visible, "Rückgängig angeboten")
await click(scr.b_undo)
g = scr.game
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")
check(g.human.board.size() == board_before and g.human.hand.any(func(c): return c.id == "f_wolf") and g.human.energy == mana, "Rückgängig stellt Hand und Mana her")
check(not scr.b_undo.visible and g.log.back() == "Rückgängig.", "Rückgängig verbraucht")
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()
# Ziehen per Taste
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 key(KEY_Z)
check(g.human.energy == mana - Game.DRAW_COST and scr.pick_layer.visible, "Z zieht")
await key(KEY_1)
check(not scr.pick_layer.visible, "1 wählt die erste Karte")
await shot("22_kampf_steuerung")
# Gamepad: Zeiger im Laden bewegen, A kauft
await pad(JOY_BUTTON_DPAD_RIGHT)
check(scr.gp_zone == "shop" and scr.gp_index == 1 and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf zweitem Angebot")
# Gamepad: A auf dem Stapel zieht, Steuerkreuz wählt die zweite Karte, A nimmt sie
await pad(JOY_BUTTON_DPAD_LEFT)
check(scr.gp_zone == "deck" and scr.cursor_layer.get_child_count() == 1, "Gamepad-Zeiger auf dem Stapel")
var hand_before: int = g.human.hand.size()
await pad(JOY_BUTTON_A)
check(g.human.hand.size() == hand_before + 1, "Gamepad A kauft das Angebot")
check(scr.pick_layer.visible, "Gamepad A zieht")
await pad(JOY_BUTTON_DPAD_RIGHT)
check(scr.gp_zone == "pick" and scr.gp_index == 1, "Zeiger auf der zweiten Karte der Wahl")
await pad(JOY_BUTTON_A)
check(g.human.hand.size() == hand_before + 1 and not scr.pick_layer.visible, "Gamepad A nimmt die Karte")
scr.ai_delay = 0.75
await key(KEY_SPACE)
check(g.current() != g.human or g.turn >= 2, "Zug per Leertaste beendet")
@@ -519,31 +557,28 @@ func test_kampf_grenzen() -> void:
g.human.energy = 100
scr.refresh()
await frames(1)
# Hand voll kaufen
# Hand voll ziehen
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])
await click(scr.deck)
await pick_all(scr)
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)
check(not scr.deck.enabled, "bei voller Hand lässt sich nichts ziehen")
await click(scr.deck)
check(scr.msg_label.text == tr("Deine Hand ist 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
if g.human.hand.is_empty():
await click(scr.deck)
await pick_all(scr)
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)
if g.human.hand.is_empty():
await click(scr.deck)
await pick_all(scr)
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")
@@ -553,16 +588,16 @@ func test_kampf_grenzen() -> void:
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
# Ohne Mana kein Ziehen
g.human.energy = 0
scr.refresh()
await frames(1)
check(scr.b_reroll.disabled, "ohne Mana kein Neuwürfeln")
check(not scr.deck.enabled, "ohne Mana kein Ziehen")
await give_up()
## Feldanzeige und Verschmelzen im Kampf.
func test_kampf_feld_und_verschmelzen() -> void:
## Elementregel, verdeckt legen, Kartenmenü und Verschmelzen im Kampf.
func test_kampf_elemente_und_verschmelzen() -> void:
await start_battle()
var scr = screen()
check(scr is BattleScreen, "Kampf gestartet")
@@ -570,47 +605,71 @@ func test_kampf_feld_und_verschmelzen() -> void:
await wait_for_human(scr)
var g: Game = scr.game
g.human.board.clear()
var put := func(p: Game.Player, id: String) -> Game.Creature:
var cr := Game.Creature.new(Cards.by_id(id), p, g.next_uid)
scr.opponent().board.clear() # was die KI im ersten Zug gelegt hat, stört die Zähler
var put := func(p: Game.Player, id: String, hidden: bool = false) -> Game.Creature:
var cr := Game.Creature.new(Cards.by_id(id).copy(), p, g.next_uid)
g.next_uid += 1
cr.ready = true
cr.attacks_left = 1
cr.hidden = hidden
p.board.append(cr)
return cr
put.call(g.human, "f_wolf")
put.call(g.human, "f_asche")
put.call(g.human, "w_reiter")
var falke: Game.Creature = put.call(scr.others()[0], "l_falke")
g.human.hand = [Cards.by_id("f_kobold")]
g.human.energy = 10
g.field_charge["Wasser"] = 2
var falke: Game.Creature = put.call(scr.opponent(), "l_falke")
var secret: Game.Creature = put.call(scr.opponent(), "f_kobold", true)
g.human.hand = [Cards.by_id("f_kobold").copy(), Cards.by_id("l_wind").copy()]
g.human.energy = 20
g.apply_auras()
scr.refresh()
await frames(2)
check(scr.field_view.text() == "Feuer 0/4 · Wasser 2/4 · Erde 0/4 · Luft 0/4", "neutrales Feld zeigt die Ladung: " + scr.field_view.text())
for i in Cards.FIELD_CHARGE:
g.charge_field(Cards.by_id("f_kobold"))
scr.refresh()
await frames(2)
check(scr.field_view.text().begins_with("Vulkan: +1 Angriff"), "Feldanzeige nennt das Feld: " + scr.field_view.text())
check(g.human.board[0].field_bonus == 1 and g.human.board[0].power() == 4, "das Feuerwesen bekommt +1 Angriff")
check(scr.hand_views[0].bonus == 2 and scr.hand_views[0].tag == "Dampf", "die Handkarte zeigt +2 (Resonanz und Feld) und die Kombo Dampf")
check(scr.b_fuse.visible and scr.b_fuse.text == "Verschmelzen", "Verschmelzen wird angeboten")
await shot("23_feld")
check(scr.element_view.text() == "Feuer 2 · Wasser 1 · Erde 0 · Luft 1", "Zähler ohne das verdeckte Wesen: " + scr.element_view.text())
check(scr.tiles[secret.uid].secret and scr.hand_views[0].bonus == 0, "fremdes verdecktes Wesen zeigt nur den Rücken, noch kein Bonus in Sicht")
# verdeckt legen über das Kartenmenü (Rechtsklick auf die Handkarte)
var menu_text := "Verdeckt legen ◆ %d" % Cards.by_id("f_kobold").cost
await hover(scr.hand_views[0])
check(scr.tiles[g.human.board[1].uid].mark == "Dampf" and "Dampf" in scr.info_label.text, "beim Zeigen auf die Handkarte ist der Kombo-Partner markiert")
await right_click_at(scr.hand_views[0].get_global_rect().get_center())
check(scr.menu_layer.visible and find_button(menu_text, scr.menu_layer) != null, "Rechtsklick öffnet das Kartenmenü mit „Verdeckt legen“")
await shot("23_kartenmenue")
await click(find_button(menu_text, scr.menu_layer))
var mine_hidden: Game.Creature = g.human.board[3]
check(mine_hidden.hidden and not scr.menu_layer.visible and scr.element_view.text().begins_with("Feuer 2"), "verdeckt gelegt, zählt nicht")
# aufdecken per Klick auf das eigene verdeckte Wesen (erst, wenn es aus der Hand angekommen ist)
while scr.own_views[3].busy:
await frames(1)
await click(scr.own_views[3])
check(not mine_hidden.hidden and scr.element_view.text().begins_with("Feuer 3"), "aufgedeckt, zählt jetzt")
put.call(g.human, "f_kobold")
g.apply_auras()
scr.refresh()
await frames(2)
check(scr.element_view.text().begins_with("Feuer 4 (+1/+1)") and "Luft 1 (-1/-1)" in scr.element_view.text(), "vier Feuer: Bonus und Malus angezeigt: " + scr.element_view.text())
check(g.human.board[0].power() == 4 and falke.power() == 2, "Feuerwesen +1, Luftwesen -1")
check(scr.hand_views[0].bonus == -1, "die Luftkarte auf der Hand zeigt -1")
await shot("23b_vier_feuer")
# Verbessern über das Menü
var wolf: Game.Creature = g.human.board[0]
scr.open_menu(wolf, Rect2(scr.own_views[0].slot, scr.own_views[0].size))
var mana: int = g.human.energy
await click(find_button("Schild ◆ %d" % Cards.KEYWORD_COST["Schild"], scr.menu_layer))
check(wolf.shield and "Schild" in wolf.card.keywords and g.human.energy == mana - Cards.KEYWORD_COST["Schild"], "Schild gekauft")
# Angriffsziel: Elementvorteil, verdeckte ohne Anzeige, Held gesperrt
await click(scr.own_views[0])
check(scr.selected == g.human.board[0] and scr.tiles[falke.uid].matchup == 1, "Angreifer gewählt: das Luftwesen zeigt +1 für Feuer")
await shot("23b_angriffsziel")
await right_click() # Auswahl wieder aufheben
check(scr.selected == null and scr.tiles[falke.uid].matchup == 0, "Auswahl aufgehoben, Vorteil verschwindet")
check(scr.selected == wolf and scr.tiles[falke.uid].matchup == 1 and scr.tiles[secret.uid].matchup == 0, "Angreifer gewählt: +1 gegen Luft, nichts gegen Verdeckte")
check(not g.can_attack(wolf, scr.opponent())[0], "Held nicht angreifbar, solange Wesen stehen")
await click(scr.tiles[secret.uid])
check(not secret.hidden and not (secret in scr.opponent().board), "Angriff deckt auf und tötet den Kobold")
await right_click()
# Verschmelzen
await click(scr.b_fuse)
check(scr.fusing and scr.b_fuse.text == "Abbrechen", "Verschmelzen ausgewählt")
await click(scr.own_views[0])
await click(scr.own_views[1])
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")
mana = g.human.energy
await click(scr.own_views[2])
check(g.human.board.any(func(c): return c.name == "Dampf") and g.human.energy == mana - 1, "zu Dampf verschmolzen, 1 Mana bezahlt")
check(not scr.fusing, "Modus beendet")
await give_up()
@@ -642,6 +701,7 @@ func test_netzwerk() -> void:
var b = screen()
check(b is BattleScreen and b.is_online() and not b.is_client(), "Netzwerkkampf als Host")
b.ai_delay = 0.0
await pick_all(b)
var rounds := 0
while b.game != null and b.game.winner == "" and rounds < 2:
if b.game.current() == b.perspective:
@@ -747,6 +807,7 @@ func test_host_wechsel() -> void:
await frames(1)
w += 1
check(b.game != null and main.net.i_am_successor and not main.net.backup.is_empty(), "Nachfolger kennt den vollen Spielstand")
await pick_all(b)
main.net.rejoin_seconds = 3.0
_bot_quit(true)
w = 0
@@ -820,6 +881,7 @@ func test_kampagne() -> void:
await click(find_button("Kämpfen"))
var battle = screen()
check(battle is BattleScreen and battle.stage == 0, "Kampagnenkampf gestartet")
await pick_all(battle)
check(battle.game.opponent.name == "Fenn", "Fenn ist der Gegner")
check(battle.game.opponent.perks == ["feuer_angriff"] and battle.game.log.any(func(l): return l.begins_with("Fenn: Feuerwesen")), "Boss-Merkmal aktiv")
await shot("16_kampagne_kampf")
@@ -835,7 +897,8 @@ func test_kampagne() -> void:
await shot("27_kampagne_kapitel2")
await click(find_button("Kämpfen"))
var b2 = screen()
check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponent.perks.size() == 2 and b2.game.opponent.life == 30, "Kapitel-2-Boss mit zwei Merkmalen und 30 Leben")
await pick_all(b2)
check(b2 is BattleScreen and b2.stage == 10 and b2.game.opponent.perks.size() == 2 and b2.game.opponent.life == 22, "Kapitel-2-Boss mit zwei Merkmalen und 22 Leben")
check(b2.game.human.perks == [main.profile.perk], "eigenes Merkmal im Kampf")
await give_up()
main.profile.campaign_stage = 0
@@ -884,7 +947,7 @@ func test_kampf_abbrechen() -> void:
scr.ai_delay = 0.0
var g: Game = scr.game
await wait_for_human(scr)
check(g.opponent != null and g.human.max_energy == 1, "Duell läuft")
check(g.opponent != null and g.human.max_energy == Game.START_MANA, "Duell läuft")
# Kartenvorschau: aufgewertete Karten zeigen ihre Seltenheit
g.human.energy = 10
var wolf := Game.Creature.new(Cards.by_id("f_wolf"), g.human, 9000)
@@ -894,18 +957,15 @@ func test_kampf_abbrechen() -> void:
scr.refresh()
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.field_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird
await hover(scr.element_view) # erst weg von der Hand, damit das Betreten sicher ausgelöst wird
await hover(reiter_view)
var previews = scr.preview_holder.get_children().filter(func(c): return c is CardView)
check(previews.size() == 1 and previews[0].rarity == "episch", "Vorschau ebenfalls")
await shot("21_kampf_vorschau")
var targeted: Array = scr.hand_views.filter(func(v): return v.card.target_type() != "" and g.can_play(g.human, v.card)[0])
if not targeted.is_empty():
var card: Cards.Card = targeted[0].card
await click(targeted[0])
check(scr.pending == card, "Ziel wird erwartet")
if not scr.own_views.is_empty():
await click(scr.own_views[0])
await right_click()
check(scr.pending == null and card in g.human.hand, "Rechtsklick bricht ab")
check(scr.selected == null, "Rechtsklick bricht ab")
await hover(scr.hand_views[0])
if scr.preview_holder.get_child_count() == 0:
print("DEBUG Hover: overlay=", scr.any_overlay(), " hand=", scr.hand_views.size(),

View File

@@ -43,7 +43,6 @@ Kapitel 2,Chapter 2
"Münzen: %d · Staub: %d","Coins: %d · Dust: %d"
Zug %d · Du bist dran,Turn %d · Your turn
Zug %d · %s ist dran …,Turn %d · %s is playing …
Wähle ein Ziel,Choose a target
Wähle ein Angriffsziel,Choose an attack target
Abgebrochen,Cancelled
%s kann nicht angreifen,%s cannot attack
@@ -65,26 +64,6 @@ Große Schrift,Large text
%d Münzen,%d coins
Abbrechen,Cancel
"Ja, zurücksetzen","Yes, reset"
%s Schaden an ein gegnerisches Wesen,%s damage to an enemy creature
%s Schaden an ein Ziel,%s damage to a target
%s Schaden an alle gegnerischen Wesen,%s damage to all enemy creatures
%s Schaden an den Gegner,%s damage to the opponent
%s Schaden an den Gegner und seine Wesen,%s damage to the opponent and their creatures
Heile dich um %s,Heal yourself for %s
Erhalte 1 Mana,Gain 1 mana
Erhalte %s Mana,Gain %s mana
Friere ein gegnerisches Wesen ein,Freeze an enemy creature
Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature
Friere alle gegnerischen Wesen ein,Freeze all enemy creatures
Ein gegnerisches Wesen zurück auf die Hand,Return an enemy creature to its owner's hand
Alle gegnerischen Wesen zurück auf die Hand,Return all enemy creatures to their owners' hands
Ein befreundetes Wesen erhält %s,A friendly creature gets %s
Alle befreundeten Wesen erhalten %s,All friendly creatures get %s
Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield
Erhält Schild,Gets Shield
Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield
Beim Ausspielen: %s,On play: %s
Wächter,Guardian
Flink,Swift
Schild,Shield
Doppelschlag,Double Strike
@@ -97,16 +76,6 @@ Feuer,Fire
Wasser,Water
Erde,Earth
Luft,Air
Inferno,Inferno
Tiefsee,Deep Sea
Berg,Mountain
Orkan,Hurricane
Dampf,Steam
Lava,Lava
Blitz,Lightning
Schlamm,Mud
Sturm,Storm
Sand,Sand
%s zerlegt (+%d Staub),%s dismantled (+%d dust)
Kapitel %d,Chapter %d
Zuschauen,Watch
@@ -119,14 +88,6 @@ Zwei eigene Wesen wählen,Pick two of your creatures
Zweites Wesen wählen,Pick the second creature
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
@@ -141,27 +102,53 @@ 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
Dein Zug,Your turn
Beim Ausspielen,On play
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
Angriff,attack
Eis,Ice
Resonanz +%d,Resonance +%d
Feld +%d,Field +%d
Merkmal +%d,Perk +%d
Feldkombo %s mit %s: %s,Field combo %s with %s: %s
1 Schaden an das stärkste gegnerische Wesen,1 damage to the strongest enemy creature
2 Schaden an den Gegner,2 damage to the opponent
Heile dich um 3,Heal yourself for 3
Das neue Wesen erhält +1/+1,The new creature gets +1/+1
%d Leben,%d life
Gegner mit %d Leben · %d Münzen bei Sieg,Opponent with %d life · %d coins for a win
Start gegen die KI,Start against the AI
Wähle %d von %d,Pick %d of %d
Ziehen,Draw
Erst wählen,Pick first
Deine Hand ist voll,Your hand is full
Keine Wahl offen,Nothing to pick
Keine solche Karte,No such card
Vorbei,Over
Verdeckt legen ◆ %d,Set face-down ◆ %d
Aufdecken,Reveal
verdeckt,face-down
Aufgedeckt!,Revealed!
Elementbonus +%d/+%d,Element bonus +%d/+%d
Elementbonus %d/%d,Element bonus %d/%d
Merkmal +%d,Perk +%d
nächster Zug ◆ %d,next turn ◆ %d
Erst die Wesen,Creatures first
%s liegt verdeckt,%s is face-down
%s liegt schon offen,%s is already face-up
Unbekannte Eigenschaft,Unknown trait
%s hat schon %s,%s already has %s
Wesen kommen mit +1 Angriff,Creatures enter with +1 attack
Hat im ersten Zug 2 Mana mehr,Has 2 extra mana on the first turn
Elementbonus zählt doppelt,Element bonus counts twice
Beginnt mit 5 Leben mehr,Starts with 5 extra life
Blockt den ersten Schaden,Blocks the first damage
Greift zweimal je Zug,Attacks twice per turn
Verdeckt,Face-down
Inferno,Inferno
Tiefsee,Deep Sea
Berg,Mountain
Orkan,Hurricane
Dampf,Steam
Lava,Lava
Blitz,Lightning
Schlamm,Mud
Sturm,Storm
Sand,Sand
Feuerwesen haben +1 Angriff,Fire creatures have +1 attack
Wesen kommen mit +1 Leben,Creatures enter with +1 health
Bekommt jeden Zug 1 Mana mehr,Gets 1 extra mana each turn
Wesen kommen mit Schild,Creatures enter with a shield
Ziehen kostet kein Mana,Drawing costs no mana
Eigenschaften kosten 1 weniger,Traits cost 1 less
1 keys en
43 Münzen: %d · Staub: %d Coins: %d · Dust: %d
44 Zug %d · Du bist dran Turn %d · Your turn
45 Zug %d · %s ist dran … Turn %d · %s is playing …
Wähle ein Ziel Choose a target
46 Wähle ein Angriffsziel Choose an attack target
47 Abgebrochen Cancelled
48 %s kann nicht angreifen %s cannot attack
64 %d Münzen %d coins
65 Abbrechen Cancel
66 Ja, zurücksetzen Yes, reset
%s Schaden an ein gegnerisches Wesen %s damage to an enemy creature
%s Schaden an ein Ziel %s damage to a target
%s Schaden an alle gegnerischen Wesen %s damage to all enemy creatures
%s Schaden an den Gegner %s damage to the opponent
%s Schaden an den Gegner und seine Wesen %s damage to the opponent and their creatures
Heile dich um %s Heal yourself for %s
Erhalte 1 Mana Gain 1 mana
Erhalte %s Mana Gain %s mana
Friere ein gegnerisches Wesen ein Freeze an enemy creature
Friere das stärkste gegnerische Wesen ein Freeze the strongest enemy creature
Friere alle gegnerischen Wesen ein Freeze all enemy creatures
Ein gegnerisches Wesen zurück auf die Hand Return an enemy creature to its owner's hand
Alle gegnerischen Wesen zurück auf die Hand Return all enemy creatures to their owners' hands
Ein befreundetes Wesen erhält %s A friendly creature gets %s
Alle befreundeten Wesen erhalten %s All friendly creatures get %s
Ein befreundetes Wesen erhält Schild A friendly creature gets Shield
Erhält Schild Gets Shield
Alle befreundeten Wesen erhalten Schild All friendly creatures get Shield
Beim Ausspielen: %s On play: %s
Wächter Guardian
67 Flink Swift
68 Schild Shield
69 Doppelschlag Double Strike
76 Wasser Water
77 Erde Earth
78 Luft Air
Inferno Inferno
Tiefsee Deep Sea
Berg Mountain
Orkan Hurricane
Dampf Steam
Lava Lava
Blitz Lightning
Schlamm Mud
Sturm Storm
Sand Sand
79 %s zerlegt (+%d Staub) %s dismantled (+%d dust)
80 Kapitel %d Chapter %d
81 Zuschauen Watch
88 Zweites Wesen wählen Pick the second creature
89 Zwei verschiedene Wesen wählen Pick two different creatures
90 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
91 Aufgewertet: %d von %d Karten · Kampagne: %d/%d Upgraded: %d of %d cards · Campaign: %d/%d
92 %s ist jetzt %s %s is now %s
93 Alle Kapitel abgeschlossen All chapters completed
102 Gewöhnliche Karten lassen sich nicht zerlegen Common cards cannot be dismantled
103 Aufgewertet Upgraded
104 +%d Staub +%d dust
+%d je Zug +%d per turn
Neu würfeln ◆ %d Reroll ◆ %d
105 Dein Zug Your turn
Beim Ausspielen On play
106 Nicht genug Mana Not enough mana
Kein Angebot No offer
Hand voll Hand full
107 Profil wirklich zurücksetzen? Really reset your profile?
108 Sammlung, Münzen und Fortschritt gehen verloren. Collection, coins and progress are lost.
Chimäre Chimera
Angriff attack
Eis Ice
Resonanz +%d Resonance +%d
Feld +%d Field +%d
Merkmal +%d Perk +%d
Feldkombo %s mit %s: %s Field combo %s with %s: %s
1 Schaden an das stärkste gegnerische Wesen 1 damage to the strongest enemy creature
2 Schaden an den Gegner 2 damage to the opponent
Heile dich um 3 Heal yourself for 3
Das neue Wesen erhält +1/+1 The new creature gets +1/+1
109 %d Leben %d life
110 Gegner mit %d Leben · %d Münzen bei Sieg Opponent with %d life · %d coins for a win
111 Start gegen die KI Start against the AI
112 Wähle %d von %d Pick %d of %d
113 Ziehen Draw
114 Erst wählen Pick first
115 Deine Hand ist voll Your hand is full
116 Keine Wahl offen Nothing to pick
117 Keine solche Karte No such card
118 Vorbei Over
119 Verdeckt legen ◆ %d Set face-down ◆ %d
120 Aufdecken Reveal
121 verdeckt face-down
122 Aufgedeckt! Revealed!
123 Elementbonus +%d/+%d Element bonus +%d/+%d
124 Elementbonus %d/%d Element bonus %d/%d
125 Merkmal +%d Perk +%d
126 nächster Zug ◆ %d next turn ◆ %d
127 Erst die Wesen Creatures first
128 %s liegt verdeckt %s is face-down
129 %s liegt schon offen %s is already face-up
130 Unbekannte Eigenschaft Unknown trait
131 %s hat schon %s %s already has %s
132 Wesen kommen mit +1 Angriff Creatures enter with +1 attack
133 Hat im ersten Zug 2 Mana mehr Has 2 extra mana on the first turn
134 Elementbonus zählt doppelt Element bonus counts twice
135 Beginnt mit 5 Leben mehr Starts with 5 extra life
136 Blockt den ersten Schaden Blocks the first damage
137 Greift zweimal je Zug Attacks twice per turn
138 Verdeckt Face-down
139 Inferno Inferno
140 Tiefsee Deep Sea
141 Berg Mountain
142 Orkan Hurricane
143 Dampf Steam
144 Lava Lava
145 Blitz Lightning
146 Schlamm Mud
147 Sturm Storm
148 Sand Sand
149 Feuerwesen haben +1 Angriff Fire creatures have +1 attack
150 Wesen kommen mit +1 Leben Creatures enter with +1 health
151 Bekommt jeden Zug 1 Mana mehr Gets 1 extra mana each turn
152 Wesen kommen mit Schild Creatures enter with a shield
153 Ziehen kostet kein Mana Drawing costs no mana
154 Eigenschaften kosten 1 weniger Traits cost 1 less

Binary file not shown.