Kampf fortsetzen, Gamepad im Deckbau, Deck-Codes, Kapitel 3, Elementbooster, englische Karten, Host-Wechsel und Zuschauer

- KI-Bewertung mit Bedrohung und Führenden-Bremse (62:38 gegen die alte im Duell, gleichauf zu viert)
- Balance: Sand +1/+1 statt Schild, Erde+Luft und Erde geschwächt, Wasser angehoben, Messlauf mit N=200 möglich
- Einzelspieler-Kämpfe werden laufend gespeichert (voller Spielstand, viewer -1) und im Menü fortgesetzt
- Karten, Deckzeilen und Kartenrücken fokussierbar: Gamepad in Deckbau, Werkstatt und Booster
- Kartennamen (name_en) und Kartentexte übersetzbar, Übersetzungstest deckt cards.gd mit ab
- Deck-Code DINGER1:id*anzahl mit Zwischenablage; acht neue Karten; Kampagne Kapitel 3; Elementbooster 120 Münzen
- Netzwerk: Nachfolge-Host mit vollem Spielstand, Neuverbindung der anderen, Zuschauer (Sicht -2); Bot kann hosten
- Große Schrift: Kartennamen, Kartentext, Knopfleisten, Gegnervorschau, Dialogtext
- Anleitung 1 garantiert ein 1-Energie-Wesen; Tests: DINGER_ONLY für einzelne Oberflächentests
- Oberflächentest auch auf Xephyr mit llvmpipe (wie Xvfb in der CI) grün

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
This commit is contained in:
Team3
2026-09-16 18:50:09 +02:00
parent b6d5916a8d
commit 4c6bf96eeb
32 changed files with 1355 additions and 174 deletions

View File

@@ -41,6 +41,15 @@ Der Spielstand liegt unter `user://spielstand.json`
- Lava trifft nur noch das stärkste gegnerische Wesen; Archetypen-Werte kommen aus `data/archetypen.json` (Messlauf schreibt sie) - Lava trifft nur noch das stärkste gegnerische Wesen; Archetypen-Werte kommen aus `data/archetypen.json` (Messlauf schreibt sie)
- Kartentext passt sich der Schriftgröße an, Gamepad auch in Menüs (Fokusrahmen), Anleitung 2 erzwingt Maras Angriff in die Falle - Kartentext passt sich der Schriftgröße an, Gamepad auch in Menüs (Fokusrahmen), Anleitung 2 erzwingt Maras Angriff in die Falle
- KI-Vorausschau in zwei Stufen (erst alle Züge mit den besten Zielen, dann die acht besten vertieft): bei 5 Gegnern 0,1 s statt 2 s, und sie rechnet im Hintergrund-Thread, Musik ebenso - KI-Vorausschau in zwei Stufen (erst alle Züge mit den besten Zielen, dann die acht besten vertieft): bei 5 Gegnern 0,1 s statt 2 s, und sie rechnet im Hintergrund-Thread, Musik ebenso
- KI-Bewertung mit Bedrohung (gegnerische Angriffskraft ohne eigenen Wächter, tödliche besonders) und Führenden-Bremse; im Duell 62:38 gegen die alte Bewertung
- Unterbrochene Einzelspieler-Kämpfe (auch Kampagne und Herausforderung) werden laufend gespeichert und lassen sich im Menü fortsetzen
- Gamepad im Deckbau, in der Werkstatt und beim Booster: Karten, Deckzeilen und Kartenrücken sind fokussierbar, A wählt, B geht zurück
- Englisch auch für Kartennamen und Kartentexte (`name_en` in `data/karten.json`, Textbausteine über `tr()`)
- Deck-Codes zum Teilen (`DINGER1:id*anzahl,…`) mit Zwischenablage im Deckbau
- Kapitel 3 der Kampagne (drei Merkmale, volle Tische), acht neue Karten (je Elementpaar eine, zwei Fallen), Elementbooster für 120 Münzen
- Netzwerk: fällt der Host aus, übernimmt der Mitspieler mit der kleinsten Kennung (er bekommt laufend den vollen Spielstand), die anderen verbinden neu; Zuschauer sehen die Partie ohne Hände
- Balance: Sand gibt +1/+1 statt Schild, Erde+Luft und Erde leicht geschwächt, Wasser angehoben; Archetypen mit 200 Partien je Kombination gemessen
- Große Schrift: Kartennamen neben dem Kostenkreis, Kartentext läuft nicht mehr über, breitere Knöpfe in Booster, Deckbau und Werkstatt
- Übersetzungsprüfung: jeder tr()-Text und jeder Anleitungsschritt braucht einen englischen Eintrag - Übersetzungsprüfung: jeder tr()-Text und jeder Anleitungsschritt braucht einen englischen Eintrag
## Tests ## Tests
@@ -57,6 +66,8 @@ godot --headless --path . -s tests/simulate.gd # Balance: KI spielt gegen K
godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar godot --headless --path . -s tests/matchup.gd # Startvorteil und Mono gegen Paar
godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH) godot --headless --path . -s tests/strength_probe.gd # Stärke der Elementkombinationen messen (Werte in DeckGen.ARCHETYPE_STRENGTH)
DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots DINGER_SHOTS=/tmp/shots godot --path . tests/ui_test.tscn # Oberfläche mit Fenster und Screenshots
DINGER_ONLY=test_host_wechsel,test_zuschauer godot --path . tests/ui_test.tscn # nur einzelne Oberflächentests
N=200 godot --headless --path . -s tests/strength_probe.gd # Messlauf mit 200 statt 60 Partien je Archetyp
godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Deckerzeugung, Musik godot --headless --path . -s tests/perf_probe.gd # Laufzeiten: KI-Entscheidung, Deckerzeugung, Musik
godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern godot --path . tests/frame_probe.tscn # Bildzeiten im Kampf mit 5 Gegnern
``` ```

View File

@@ -6,14 +6,15 @@
- Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln - Jeder gegen jeden: Spieler und 1 bis 5 KI-Gegner (oder Spieler), alle mit gleichen Regeln
- Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl - Einzelspieler-Knopf führt zur Auswahl der Gegnerzahl
- Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt - Kampagne gegen benannte Magier, Erfolge und Tagesaufgaben als Fortschritt
- Lokal zu mehreren am selben Rechner und im lokalen Netz möglich - Lokal zu mehreren am selben Rechner und im lokalen Netz möglich, auch als Zuschauer
- Ein unterbrochener Kampf lässt sich später fortsetzen
- Geführte erste Partie als Einstieg - Geführte erste Partie als Einstieg
- Wöchentliche Herausforderungen und Bestenliste als Langzeitziel - Wöchentliche Herausforderungen und Bestenliste als Langzeitziel
## Sammeln ## Sammeln
- Booster öffnen - Booster öffnen
- Karten haben Seltenheiten - Karten haben Seltenheiten
- Aus der Sammlung werden Decks gebaut (mehrere Slots) - Aus der Sammlung werden Decks gebaut (mehrere Slots) und als Code geteilt
- Überschüssige Karten lassen sich zerlegen und neue herstellen - Überschüssige Karten lassen sich zerlegen und neue herstellen
## Elemente ## Elemente

File diff suppressed because it is too large Load Diff

View File

@@ -30,13 +30,16 @@ static func board_value(p: Game.Player) -> float:
return v return v
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, ## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, die der Gegner
## die der Gegner negativ. Ein besiegter Gegner ist viel wert. ## negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden bremsen).
## Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders.
static func evaluate(g: Game, p: Game.Player) -> float: static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive(): if not p.alive():
return -10000.0 return -10000.0
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5 var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
var alive_enemies := 0 var alive_enemies := 0
var threat := 0.0
var leader := 0.0
for e in g.players: for e in g.players:
if e == p: if e == p:
continue continue
@@ -44,11 +47,28 @@ static func evaluate(g: Game, p: Game.Player) -> float:
score += 40.0 score += 40.0
continue continue
alive_enemies += 1 alive_enemies += 1
score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2 var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
score -= ev
leader = maxf(leader, ev)
if e.life <= 6: if e.life <= 6:
score += 3.0 # fast besiegt: dranbleiben score += 3.0 # fast besiegt: dranbleiben
for c in e.board:
if not c.frozen:
threat += c.power() * c.attacks_per_turn()
if alive_enemies == 0: if alive_enemies == 0:
return 10000.0 return 10000.0
score -= leader * 0.25
var guarded := false
for c in p.board:
if c.taunt():
guarded = true
break
if guarded:
score -= threat * 0.1
elif threat >= p.life:
score -= 25.0
else:
score -= threat * 0.3
return score return score

View File

@@ -1,5 +1,5 @@
class_name Campaign class_name Campaign
## Kampagne: zehn benannte Magier, jede Stufe mit mehr Gegnern am Tisch. ## Kampagne: zehn benannte Magier in drei Kapiteln, jede Stufe mit mehr Gegnern am Tisch.
const STAGES := [ const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"}, {"name": "Fenn", "title": "Kobold-Zähmer", "elements": ["Feuer"], "extra": 0, "reward": 100, "perk": "feuer_angriff"},
@@ -30,22 +30,40 @@ const STAGES2 := [
] ]
## Kapitel 3: drei Merkmale, volle Tische, die stärksten Decks.
const STAGES3 := [
{"name": "Fenn", "title": "Aschefürst", "elements": ["Feuer"], "extra": 2, "reward": 600, "perks": ["feuer_angriff", "start_energie", "zauber_schaden"]},
{"name": "Mara", "title": "Sturmflut", "elements": ["Wasser"], "extra": 2, "reward": 600, "perks": ["leben_35", "extra_karten", "schild_wesen"]},
{"name": "Borin", "title": "Berg selbst", "elements": ["Erde"], "extra": 3, "reward": 700, "perks": ["waechter_leben", "schild_wesen", "leben_35"]},
{"name": "Liska", "title": "Windbraut", "elements": ["Luft"], "extra": 3, "reward": 700, "perks": ["flink_angriff", "alle_flink", "extra_karten"]},
{"name": "Vulkar", "title": "Magmakaiser", "elements": ["Feuer", "Erde"], "extra": 4, "reward": 800, "perks": ["zauber_schaden", "feuer_angriff", "waechter_leben"]},
{"name": "Nyx", "title": "Orkanmutter", "elements": ["Wasser", "Luft"], "extra": 4, "reward": 800, "perks": ["extra_karten", "flink_angriff", "resonanz_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "elements": ["Feuer"], "extra": 5, "reward": 900, "perks": ["resonanz_doppelt", "zauber_schaden", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "elements": ["Wasser"], "extra": 5, "reward": 900, "perks": ["schild_wesen", "leben_35", "resonanz_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "elements": ["Feuer", "Luft"], "extra": 5, "reward": 1000, "perks": ["alle_flink", "start_energie", "zauber_schaden"]},
{"name": "Archimagier", "title": "Ewiger Meister", "elements": ["Erde", "Luft"], "extra": 5, "reward": 1200, "perks": ["start_energie", "resonanz_doppelt", "extra_karten", "leben_35"]},
]
const CHAPTERS := [STAGES, STAGES2, STAGES3]
# Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks. # Mindeststärke der Gegnerdecks je Stufe; nie unter der Stärke des eigenen Decks.
const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6] const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6]
const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4] const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4]
const MIN_RATING3 := [3.2, 3.3, 3.5, 3.6, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4]
const MIN_RATINGS := [MIN_RATING, MIN_RATING2, MIN_RATING3]
static func target_rating(stage_index: int, player_rating: float) -> float: static func target_rating(stage_index: int, player_rating: float) -> float:
var base: float = MIN_RATING[stage_index] if stage_index < STAGES.size() else MIN_RATING2[stage_index - STAGES.size()] var base: float = MIN_RATINGS[chapter(stage_index) - 1][stage_index % STAGES.size()]
return maxf(base, player_rating) return maxf(base, player_rating)
static func total() -> int: static func total() -> int:
return STAGES.size() + STAGES2.size() return STAGES.size() * CHAPTERS.size()
static func chapter(stage: int) -> int: static func chapter(stage: int) -> int:
return 1 if stage < STAGES.size() else 2 return stage / STAGES.size() + 1
## Stufe mit einheitlichem Feld "perks". ## Stufe mit einheitlichem Feld "perks".
@@ -54,7 +72,7 @@ static func stage(i: int) -> Dictionary:
var st: Dictionary = STAGES[i].duplicate() var st: Dictionary = STAGES[i].duplicate()
st["perks"] = [st.perk] st["perks"] = [st.perk]
return st return st
return STAGES2[i - STAGES.size()] return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist). ## Gegner einer Stufe, Decks auf die Zielstärke (Stufe oder eigenes Deck, was höher ist).

View File

@@ -33,7 +33,7 @@ const FIELD_COMBO_TEXT := {
"Blitz": "2 Schaden an den schwächsten Gegner", "Blitz": "2 Schaden an den schwächsten Gegner",
"Schlamm": "Heile dich um 3", "Schlamm": "Heile dich um 3",
"Sturm": "Ziehe 1 Karte", "Sturm": "Ziehe 1 Karte",
"Sand": "Das neue Wesen erhält Schild", "Sand": "Das neue Wesen erhält +1/+1",
} }
const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern." const RESONANCE_TEXT := "Resonanz: Ein Wesen bekommt Angriff für eigene Wesen mit gleichem Element: +1, +2, +3, +3 bei ein bis vier Partnern."
const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"] const RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
@@ -82,7 +82,7 @@ static func field_combo_effect(name: String) -> Effect:
"Sturm": "Sturm":
return Effect.new("draw", "none", 1) return Effect.new("draw", "none", 1)
"Sand": "Sand":
return Effect.new("shield", "self_creature") return Effect.new("buff", "self_creature", 1, 1)
return null return null
@@ -119,62 +119,73 @@ class Effect extends RefCounted:
static func from_dict(d: Dictionary) -> Effect: static func from_dict(d: Dictionary) -> Effect:
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", ""))) return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
func text() -> String: ## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl).
func text(trigger: String = "") -> String:
var n := str(amount) var n := str(amount)
if target == "trigger_source": if target == "trigger_source":
var played := trigger == "creature_played"
match kind: match kind:
"damage": "damage":
return n + " Schaden an den Angreifer" if played:
return tr("%s Schaden an dieses Wesen") % n
return tr("%s Schaden an den Angreifer") % n
"freeze": "freeze":
return "Der Angreifer wird eingefroren" if played:
return tr("Es wird eingefroren")
return tr("Der Angreifer wird eingefroren")
"bounce": "bounce":
return "Es geht zurück auf die Hand" return tr("Es geht zurück auf die Hand")
match kind: match kind:
"cancel": "cancel":
return "Der Angriff verpufft" return tr("Der Angriff verpufft")
"summon": "summon":
return "Beschwöre einen " + Cards.by_id(card_id).name return tr("Beschwöre einen %s") % Cards.by_id(card_id).title()
"damage": "damage":
match target: match target:
"enemy_creature": "enemy_creature":
return n + " Schaden an ein gegnerisches Wesen" return tr("%s Schaden an ein gegnerisches Wesen") % n
"enemy_any": "enemy_any":
return n + " Schaden an ein Ziel" return tr("%s Schaden an ein Ziel") % n
"all_enemy_creatures": "all_enemy_creatures":
return n + " Schaden an alle gegnerischen Wesen" return tr("%s Schaden an alle gegnerischen Wesen") % n
"all_enemy_heroes": "all_enemy_heroes":
return n + " Schaden an alle Gegner" return tr("%s Schaden an alle Gegner") % n
"all_enemies": "all_enemies":
return n + " Schaden an alle Gegner und ihre Wesen" return tr("%s Schaden an alle Gegner und ihre Wesen") % n
"heal": "heal":
return "Heile dich um " + n return tr("Heile dich um %s") % n
"draw": "draw":
return "Ziehe 1 Karte" if amount == 1 else "Ziehe " + n + " Karten" if amount == 1:
return tr("Ziehe 1 Karte")
return tr("Ziehe %s Karten") % n
"freeze": "freeze":
if target == "enemy_creature": if target == "enemy_creature":
return "Friere ein gegnerisches Wesen ein" return tr("Friere ein gegnerisches Wesen ein")
return "Friere alle gegnerischen Wesen ein" if target == "strongest_enemy_creature":
return tr("Friere das stärkste gegnerische Wesen ein")
return tr("Friere alle gegnerischen Wesen ein")
"bounce": "bounce":
if target == "enemy_creature": if target == "enemy_creature":
return "Ein gegnerisches Wesen zurück auf die Hand" return tr("Ein gegnerisches Wesen zurück auf die Hand")
return "Alle gegnerischen Wesen zurück auf die Hand" return tr("Alle gegnerischen Wesen zurück auf die Hand")
"buff": "buff":
var b := "+%d/+%d" % [amount, amount2] var b := "+%d/+%d" % [amount, amount2]
if target == "friendly_creature": if target == "friendly_creature":
return "Ein befreundetes Wesen erhält " + b return tr("Ein befreundetes Wesen erhält %s") % b
return "Alle befreundeten Wesen erhalten " + b return tr("Alle befreundeten Wesen erhalten %s") % b
"shield": "shield":
if target == "friendly_creature": if target == "friendly_creature":
return "Ein befreundetes Wesen erhält Schild" return tr("Ein befreundetes Wesen erhält Schild")
if target == "self_creature": if target == "self_creature":
return "Erhält Schild" return tr("Erhält Schild")
return "Alle befreundeten Wesen erhalten Schild" return tr("Alle befreundeten Wesen erhalten Schild")
return "" return ""
class Card extends RefCounted: class Card extends RefCounted:
var id: String var id: String
var name: String var name: String # deutscher Name (auch im Verlauf und in Tests)
var name_en := "" # englischer Name
var elements: Array var elements: Array
var cost: int var cost: int
var rarity: String var rarity: String
@@ -192,7 +203,7 @@ class Card extends RefCounted:
return kind == "Falle" return kind == "Falle"
func to_dict() -> Dictionary: func to_dict() -> Dictionary:
var d := {"id": id, "name": name, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind} var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "cost": cost, "rarity": rarity, "kind": kind}
if is_creature(): if is_creature():
d["attack"] = attack d["attack"] = attack
d["health"] = health d["health"] = health
@@ -208,6 +219,7 @@ class Card extends RefCounted:
var c := Card.new() var c := Card.new()
c.id = d.id c.id = d.id
c.name = d.name c.name = d.name
c.name_en = str(d.get("name_en", ""))
c.elements = Array(d.elements) c.elements = Array(d.elements)
c.cost = int(d.cost) c.cost = int(d.cost)
c.rarity = d.rarity c.rarity = d.rarity
@@ -220,6 +232,12 @@ class Card extends RefCounted:
c.trigger = str(d.get("trigger", "")) c.trigger = str(d.get("trigger", ""))
return c return c
## Anzeigename in der aktuellen Sprache.
func title() -> String:
if name_en != "" and TranslationServer.get_locale().begins_with("en"):
return name_en
return name
func is_combo() -> bool: func is_combo() -> bool:
return elements.size() >= 2 return elements.size() >= 2
@@ -238,19 +256,19 @@ class Card extends RefCounted:
var kws: Array[String] = [] var kws: Array[String] = []
for k in Cards.KEYWORDS: for k in Cards.KEYWORDS:
if k in keywords: if k in keywords:
kws.append(k) kws.append(tr(k))
parts.append(", ".join(kws) + ".") parts.append(", ".join(kws) + ".")
if not effects.is_empty(): if not effects.is_empty():
var fx: Array[String] = [] var fx: Array[String] = []
for f in effects: for f in effects:
fx.append(f.text()) fx.append(f.text(trigger))
var s := ", ".join(fx) + "." var s := ", ".join(fx) + "."
if is_trap(): if is_trap():
if trigger == "creature_played": parts.append(tr("Falle. %s: %s") % [tr(Cards.TRIGGER_TEXT[trigger]), s])
s = s.replace("an den Angreifer", "an dieses Wesen").replace("Der Angreifer wird", "Es wird") elif is_creature():
parts.append("Falle. %s: %s" % [Cards.TRIGGER_TEXT[trigger], s]) parts.append(tr("Beim Ausspielen: %s") % s)
else: else:
parts.append(("Beim Ausspielen: " + s) if is_creature() else s) parts.append(s)
return " ".join(parts) return " ".join(parts)

View File

@@ -315,7 +315,7 @@ func mulligan(p: Player, cards: Array) -> String:
if n > 0: if n > 0:
shuffle(p.deck, rng) shuffle(p.deck, rng)
draw(p, n) draw(p, n)
say("%s tauscht %d Karten." % [p.name, n] if n > 0 else "%s behält die Hand." % p.name) say(("%s tauscht 1 Karte." % p.name if n == 1 else "%s tauscht %d Karten." % [p.name, n]) if n > 0 else "%s behält die Hand." % p.name)
emit({"type": "mulligan", "player": players.find(p), "count": n}) emit({"type": "mulligan", "player": players.find(p), "count": n})
return "" return ""

View File

@@ -8,6 +8,8 @@ signal started(seat: int)
signal snapshot_received(snap: Dictionary) signal snapshot_received(snap: Dictionary)
signal action_applied signal action_applied
signal ended(reason: String) signal ended(reason: String)
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
signal status_changed
const PORT := 24455 const PORT := 24455
@@ -23,15 +25,25 @@ var my_seat := -1
var game: Game # Host var game: Game # Host
var status := "" var status := ""
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
const REJOIN_SECONDS := 60.0 var rejoin_seconds := 60.0
var spectator := false # eigene Rolle: nur zuschauen
var spectators := {} # Host: peer_id -> Name
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
var i_am_successor := false
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
var reconnecting := false
func _ready() -> void: func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected) multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected) multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(func(): _fail("Verbindung fehlgeschlagen")) multiplayer.connection_failed.connect(func():
multiplayer.server_disconnected.connect(func(): _fail("Host hat das Spiel beendet")) if not reconnecting:
_fail("Verbindung fehlgeschlagen"))
multiplayer.server_disconnected.connect(_on_server_lost)
func host(port: int = PORT) -> Error: func host(port: int = PORT) -> Error:
@@ -45,6 +57,7 @@ func host(port: int = PORT) -> Error:
connected = true connected = true
names = {1: my_name} names = {1: my_name}
decks = {1: my_deck} decks = {1: my_deck}
spectators = {}
status = "Warte auf Mitspieler …" status = "Warte auf Mitspieler …"
lobby_changed.emit() lobby_changed.emit()
return OK return OK
@@ -76,6 +89,13 @@ func leave() -> void:
my_seat = -1 my_seat = -1
game = null game = null
status = "" status = ""
spectator = false
spectators = {}
successor_ip = ""
i_am_successor = false
backup = {}
backup_names = {}
last_winner = ""
func local_addresses() -> Array: func local_addresses() -> Array:
@@ -91,7 +111,114 @@ func _fail(reason: String) -> void:
func _on_connected() -> void: func _on_connected() -> void:
connected = true connected = true
status = "Verbunden" status = "Verbunden"
register.rpc_id(1, my_name, my_deck) register.rpc_id(1, my_name, my_deck, spectator)
# --- Host-Wechsel ---------------------------------------------------------------
# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen
# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der
# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück.
func _successor_id() -> int:
var ids: Array = seats.keys().filter(func(id): return id != 1)
ids.sort()
return ids[0] if not ids.is_empty() else -1
func _update_successor() -> void:
if not is_host or game == null:
return
var succ := _successor_id()
var ip := ""
if succ > 0 and peer != null and peer.get_peer(succ) != null:
ip = peer.get_peer(succ).get_remote_address()
for id in seats:
if id != 1:
set_successor.rpc_id(id, ip, id == succ)
## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel.
func _seat_names() -> Dictionary:
var out := {}
for id in seats:
out[seats[id]] = names.get(id, "")
for pname in waiting:
out[waiting[pname].seat] = pname
return out
@rpc("authority", "reliable")
func set_successor(ip: String, me: bool) -> void:
successor_ip = ip
i_am_successor = me
@rpc("authority", "reliable")
func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void:
backup = snap
backup_names = seat_names
func _on_server_lost() -> void:
if is_host:
return
if last_winner != "":
_fail("Host hat das Spiel beendet")
elif i_am_successor and not backup.is_empty():
_become_host()
elif successor_ip != "":
status = "Host weg, verbinde mit dem Nachfolger …"
status_changed.emit()
_reconnect(successor_ip)
else:
_fail("Host hat das Spiel beendet")
func _become_host() -> void:
var snap := backup
var seat_names := backup_names
var seat := my_seat
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
var err := FAILED
for attempt in 5:
err = host()
if err == OK:
break
await get_tree().create_timer(1.0).timeout
if err != OK:
_fail("Host weg, Übernahme fehlgeschlagen")
return
game = Game.from_snapshot(snap)
seats = {1: seat}
my_seat = seat
waiting = {}
for s in seat_names:
if int(s) != seat:
waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()}
game.say("%s übernimmt als Host." % my_name)
status = "Du bist jetzt Host, die anderen verbinden neu …"
successor_ip = ""
i_am_successor = false
backup = {}
migrated.emit()
func _reconnect(ip: String) -> void:
reconnecting = true
await get_tree().create_timer(2.0).timeout
for attempt in 5:
if join(ip) == OK:
var t := 0.0
while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING:
await get_tree().process_frame
t += get_process_delta_time()
if connected:
reconnecting = false
return
await get_tree().create_timer(1.0).timeout
reconnecting = false
_fail("Nachfolge-Host nicht erreichbar")
func _on_peer_connected(_id: int) -> void: func _on_peer_connected(_id: int) -> void:
@@ -104,14 +231,16 @@ func _on_peer_disconnected(id: int) -> void:
var pname: String = names.get(id, "") var pname: String = names.get(id, "")
names.erase(id) names.erase(id)
decks.erase(id) decks.erase(id)
spectators.erase(id)
if game != null and seats.has(id): if game != null and seats.has(id):
var p: Game.Player = game.players[seats[id]] var p: Game.Player = game.players[seats[id]]
if game.winner == "": if game.winner == "":
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()} waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(REJOIN_SECONDS)]) game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)])
seats.erase(id) seats.erase(id)
_update_successor()
action_applied.emit() action_applied.emit()
lobby_update.rpc(names) lobby_update.rpc(names, spectators)
lobby_changed.emit() lobby_changed.emit()
@@ -123,7 +252,7 @@ func tick() -> void:
for pname in waiting.keys(): for pname in waiting.keys():
var w: Dictionary = waiting[pname] var w: Dictionary = waiting[pname]
var p: Game.Player = game.players[w.seat] var p: Game.Player = game.players[w.seat]
if now - float(w.since) > REJOIN_SECONDS: if now - float(w.since) > rejoin_seconds:
waiting.erase(pname) waiting.erase(pname)
game.say("%s kehrt nicht zurück und scheidet aus." % p.name) game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
game.damage(p, 999) game.damage(p, 999)
@@ -137,10 +266,18 @@ func tick() -> void:
@rpc("any_peer", "reliable") @rpc("any_peer", "reliable")
func register(name: String, deck_ids: Array) -> void: func register(name: String, deck_ids: Array, as_spectator: bool = false) -> void:
if not is_host: if not is_host:
return return
var id := multiplayer.get_remote_sender_id() var id := multiplayer.get_remote_sender_id()
if as_spectator:
spectators[id] = name
lobby_update.rpc(names, spectators)
lobby_changed.emit()
if game != null:
start_game.rpc_id(id, -1)
receive_snapshot.rpc_id(id, game.snapshot(-2))
return
if game != null: if game != null:
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz # Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
if waiting.has(name): if waiting.has(name):
@@ -150,19 +287,21 @@ func register(name: String, deck_ids: Array) -> void:
game.say("%s ist zurück." % name) game.say("%s ist zurück." % name)
start_game.rpc_id(id, seats[id]) start_game.rpc_id(id, seats[id])
receive_snapshot.rpc_id(id, game.snapshot(seats[id])) receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
_update_successor()
action_applied.emit() action_applied.emit()
else: else:
status = "%s wollte beitreten, das Spiel läuft schon" % name status = "%s wollte beitreten, das Spiel läuft schon" % name
return return
names[id] = name names[id] = name
decks[id] = deck_ids decks[id] = deck_ids
lobby_update.rpc(names) lobby_update.rpc(names, spectators)
lobby_changed.emit() lobby_changed.emit()
@rpc("authority", "reliable") @rpc("authority", "reliable")
func lobby_update(n: Dictionary) -> void: func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
names = n names = n
spectators = sp
lobby_changed.emit() lobby_changed.emit()
@@ -194,6 +333,9 @@ func start_battle(n_ai: int) -> void:
for id in ids: for id in ids:
if id != 1: if id != 1:
start_game.rpc_id(id, seats[id]) start_game.rpc_id(id, seats[id])
for id in spectators:
start_game.rpc_id(id, -1)
_update_successor()
started.emit(my_seat) started.emit(my_seat)
@@ -203,17 +345,24 @@ func start_game(seat: int) -> void:
started.emit(seat) started.emit(seat)
## Host: Zustand an alle Mitspieler senden (jeder aus seiner Sicht). ## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes,
## der Nachfolger zusätzlich den vollen Spielstand).
func broadcast() -> void: func broadcast() -> void:
if not is_host or game == null: if not is_host or game == null:
return return
for id in seats: for id in seats:
if id != 1: if id != 1:
receive_snapshot.rpc_id(id, game.snapshot(seats[id])) receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
for id in spectators:
receive_snapshot.rpc_id(id, game.snapshot(-2))
var succ := _successor_id()
if succ > 0:
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
@rpc("authority", "reliable") @rpc("authority", "reliable")
func receive_snapshot(snap: Dictionary) -> void: func receive_snapshot(snap: Dictionary) -> void:
last_winner = str(snap.get("winner", ""))
snapshot_received.emit(snap) snapshot_received.emit(snap)

View File

@@ -3,11 +3,12 @@ class_name NetState
## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl. ## Serialisierter Zustand aus Sicht eines Spielers: fremde Hände, Decks und Fallen nur als Anzahl.
## viewer -1: alles sichtbar (Spielstand, Host-Wechsel); -2: nichts Verborgenes (Zuschauer).
static func snapshot(g: Game, viewer: int) -> Dictionary: static func snapshot(g: Game, viewer: int) -> Dictionary:
var ps := [] var ps := []
for i in g.players.size(): for i in g.players.size():
var p: Game.Player = g.players[i] var p: Game.Player = g.players[i]
var mine := i == viewer var mine := i == viewer or viewer == -1
var board := [] var board := []
for c in p.board: for c in p.board:
board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance, board.append({"uid": c.uid, "card": c.card.id, "attack": c.attack, "resonance": c.resonance,
@@ -17,7 +18,8 @@ static func snapshot(g: Game, viewer: int) -> Dictionary:
"energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus, "energy": p.energy, "max_energy": p.max_energy, "fatigue": p.fatigue, "start_bonus": p.start_bonus,
"order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done, "order": p.order, "perks": p.perks, "turns": p.turns, "mulligan_done": p.mulligan_done,
"hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(), "hand": p.hand.map(func(c): return c.id) if mine else p.hand.size(),
"deck": p.deck.size(), "traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(), "deck": p.deck.map(func(c): return c.id) if viewer == -1 else p.deck.size(),
"traps": p.traps.map(func(c): return c.id) if mine else p.traps.size(),
"board": board}) "board": board})
var evs := [] var evs := []
for ev in g.events: for ev in g.events:
@@ -51,8 +53,12 @@ static func from_snapshot(d: Dictionary) -> Game:
else: else:
for i in int(pd.hand): for i in int(pd.hand):
p.hand.append(placeholder) p.hand.append(placeholder)
for i in int(pd.deck): if pd.deck is Array:
p.deck.append(placeholder) for cid in pd.deck:
p.deck.append(Cards.by_id(cid))
else:
for i in int(pd.deck):
p.deck.append(placeholder)
if pd.traps is Array: if pd.traps is Array:
for cid in pd.traps: for cid in pd.traps:
p.traps.append(Cards.by_id(cid)) p.traps.append(Cards.by_id(cid))

View File

@@ -6,6 +6,8 @@ const SAVE_VERSION := 5
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
const SAVE_PATH := "user://spielstand.json" const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100 const BOOSTER_COST := 100
const ELEMENT_BOOSTER_COST := 120 # nur Karten eines Elements (auch seine Kombos)
const CODE_PREFIX := "DINGER1:" # Deck-Code zum Teilen
const BOOSTER_SIZE := 5 const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5} const RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
const START_COINS := 150 const START_COINS := 150
@@ -34,6 +36,7 @@ const ACHIEVEMENTS := [
{"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100}, {"id": "kombokoenig", "name": "Kombokönig", "text": "Gewinne mit mindestens 6 Kombokarten im Deck.", "reward": 100},
{"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300}, {"id": "kampagnenheld", "name": "Kampagnenheld", "text": "Schließe Kapitel 1 der Kampagne ab.", "reward": 300},
{"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500}, {"id": "elementmeister", "name": "Meister der Elemente", "text": "Schließe Kapitel 2 der Kampagne ab.", "reward": 500},
{"id": "archimagier", "name": "Archimagier", "text": "Schließe Kapitel 3 der Kampagne ab.", "reward": 800},
{"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150}, {"id": "serie", "name": "Siegesserie", "text": "Gewinne fünf Kämpfe in Folge.", "reward": 150},
{"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100}, {"id": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100},
] ]
@@ -336,7 +339,15 @@ func dismantle_extras() -> int:
# --- Booster ---------------------------------------------------------------------- # --- Booster ----------------------------------------------------------------------
static func roll_booster(r: RandomNumberGenerator) -> Array: ## Kartenpool einer Seltenheit, bei Elementboostern nur Karten mit diesem Element.
static func booster_pool(rarity: String, element: String) -> Array:
var pool := Cards.of_rarity(rarity)
if element != "":
pool = pool.filter(func(c): return element in c.elements)
return pool
static func roll_booster(r: RandomNumberGenerator, element: String = "") -> Array:
var rarities := RARITY_WEIGHTS.keys() var rarities := RARITY_WEIGHTS.keys()
var weights := PackedFloat32Array(RARITY_WEIGHTS.values()) var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
var cards := [] var cards := []
@@ -345,23 +356,49 @@ static func roll_booster(r: RandomNumberGenerator) -> Array:
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \ if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
and cards.all(func(c): return c.rarity == "gewöhnlich"): and cards.all(func(c): return c.rarity == "gewöhnlich"):
rarity = "selten" # mindestens eine seltene Karte pro Booster rarity = "selten" # mindestens eine seltene Karte pro Booster
var pool := Cards.of_rarity(rarity) var pool := booster_pool(rarity, element)
cards.append(pool[r.randi_range(0, pool.size() - 1)]) cards.append(pool[r.randi_range(0, pool.size() - 1)])
return cards return cards
## Öffnet einen Booster. Gibt die Karten zurück, leer wenn die Münzen nicht reichen. ## Deck-Code zum Teilen: "DINGER1:id*anzahl,…" (sortiert, daher vergleichbar).
func open_booster(r: RandomNumberGenerator = null) -> Array: static func deck_code(card_ids: Array) -> String:
if coins < BOOSTER_COST: var counts := {}
for cid in card_ids:
counts[cid] = counts.get(cid, 0) + 1
var keys := counts.keys()
keys.sort()
return CODE_PREFIX + ",".join(keys.map(func(k): return "%s*%d" % [k, counts[k]]))
## Karten-IDs aus einem Deck-Code; leer bei ungültigem Code.
static func parse_deck_code(code: String) -> Array:
var s := code.strip_edges()
if not s.begins_with(CODE_PREFIX):
return []
var out := []
for part in s.substr(CODE_PREFIX.length()).split(",", false):
var kv: PackedStringArray = part.strip_edges().split("*")
if kv.size() != 2 or not Cards.has_id(kv[0]) or not kv[1].is_valid_int():
return []
for i in clampi(int(kv[1]), 0, Game.MAX_COPIES):
out.append(kv[0])
return out
## Öffnet einen Booster (element: Elementbooster). Gibt die Karten zurück, leer wenn die Münzen nicht reichen.
func open_booster(r: RandomNumberGenerator = null, element: String = "") -> Array:
var cost := ELEMENT_BOOSTER_COST if element != "" else BOOSTER_COST
if coins < cost:
return [] return []
if r == null: if r == null:
r = Game.make_rng() r = Game.make_rng()
coins -= BOOSTER_COST coins -= cost
var cards := roll_booster(r) var cards := roll_booster(r, element)
booster_since_mythic += 1 booster_since_mythic += 1
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch") var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch")
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic: if booster_since_mythic >= PITY_BOOSTERS and not has_mythic:
var pool := Cards.of_rarity("mythisch") var pool := booster_pool("mythisch", element)
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)] cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)]
has_mythic = true has_mythic = true
if has_mythic: if has_mythic:
@@ -478,6 +515,8 @@ func _achieved(id: String, ctx: Dictionary) -> bool:
"kampagnenheld": "kampagnenheld":
return campaign_stage >= Campaign.STAGES.size() return campaign_stage >= Campaign.STAGES.size()
"elementmeister": "elementmeister":
return campaign_stage >= 2 * Campaign.STAGES.size()
"archimagier":
return campaign_stage >= Campaign.total() return campaign_stage >= Campaign.total()
"serie": "serie":
return records.best_streak >= 5 return records.best_streak >= 5

View File

@@ -63,7 +63,7 @@ func play_events(evs: Array, centers: Dictionary) -> void:
screen.preview_timer = 1.8 screen.preview_timer = 1.8
"mulligan": "mulligan":
if ev.count > 0: if ev.count > 0:
popup("tauscht %d Karten" % ev.count, Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0) popup(("tauscht 1 Karte" if int(ev.count) == 1 else "tauscht %d Karten" % ev.count), Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2: func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:

View File

@@ -9,7 +9,8 @@ var stage := -1 # Kampagnenstufe oder -1
var tutorial := false var tutorial := false
var hotseat_decks: Array = [] var hotseat_decks: Array = []
var hotseat_ai := 0 var hotseat_ai := 0
var online_seat := -1 # Netzwerk: eigener Platz, sonst -1 var net_play := false # Netzwerkspiel (auch als Zuschauer)
var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1
var net: Net var net: Net
var game: Game var game: Game
var perspective: Game.Player # der Mensch, dessen Hand unten liegt var perspective: Game.Player # der Mensch, dessen Hand unten liegt
@@ -44,6 +45,7 @@ var preset_opponents: Array = [] # aus der Gegnervorschau übernommene Gegner
var skip_ai := false # Gegnerzüge im Schnelldurchlauf var skip_ai := false # Gegnerzüge im Schnelldurchlauf
var smart_ai := true # Vorausschau-KI (Leicht: einfache KI) var smart_ai := true # Vorausschau-KI (Leicht: einfache KI)
var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion var undo_state: Game = null # Zustand vor der letzten rückgängig machbaren Aktion
var saved_game: Game = null # fortgesetzter Kampf (siehe resume)
var undo_log_len := 0 var undo_log_len := 0
var undo_t := 0.0 var undo_t := 0.0
@@ -80,6 +82,7 @@ var result_label: Label
var reward_label: Label var reward_label: Label
var notice_label: Label var notice_label: Label
var confirm: Control var confirm: Control
var b_save_leave: Button
var handover: Control var handover: Control
var handover_label: Label var handover_label: Label
var mulligan: BattleMulligan var mulligan: BattleMulligan
@@ -135,6 +138,7 @@ static func hotseat(decks: Array, n_ai: int) -> BattleScreen:
static func online(seat: int) -> BattleScreen: static func online(seat: int) -> BattleScreen:
var b := BattleScreen.new(0, -1) var b := BattleScreen.new(0, -1)
b.net_play = true
b.online_seat = seat b.online_seat = seat
return b return b
@@ -152,6 +156,15 @@ static func against(opps: Array) -> BattleScreen:
return b return b
## Unterbrochenen Kampf aus der Datei fortsetzen (siehe save_battle).
static func resume(data: Dictionary) -> BattleScreen:
var s := BattleScreen.new(int(data.get("n_opp", 1)), int(data.get("stage", -1)))
s.challenge = data.get("challenge", {})
s.played_ids = Array(data.get("played_ids", []))
s.saved_game = Game.from_snapshot(data.game)
return s
static func weekly(ch: Dictionary) -> BattleScreen: static func weekly(ch: Dictionary) -> BattleScreen:
var b := BattleScreen.new(int(ch.n_opp), -1) var b := BattleScreen.new(int(ch.n_opp), -1)
b.challenge = ch b.challenge = ch
@@ -159,11 +172,16 @@ static func weekly(ch: Dictionary) -> BattleScreen:
func is_hotseat() -> bool: func is_hotseat() -> bool:
return game != null and game.humans.size() > 1 and online_seat < 0 return game != null and game.humans.size() > 1 and not net_play
func is_online() -> bool: func is_online() -> bool:
return online_seat >= 0 return net_play
## Zuschauer im Netzwerk: kein eigener Platz, keine Hand.
func spectating() -> bool:
return is_online() and online_seat < 0
func is_client() -> bool: func is_client() -> bool:
@@ -184,6 +202,8 @@ func _ready() -> void:
if is_online(): if is_online():
net = app.net net = app.net
net.ended.connect(_on_net_ended) net.ended.connect(_on_net_ended)
net.migrated.connect(_on_migrated)
net.status_changed.connect(_on_net_status)
if net.is_host: if net.is_host:
game = net.game game = net.game
net.action_applied.connect(after_action) net.action_applied.connect(after_action)
@@ -191,6 +211,8 @@ func _ready() -> void:
net.snapshot_received.connect(_on_snapshot) net.snapshot_received.connect(_on_snapshot)
wait_label.visible = true wait_label.visible = true
return # der erste Zustand kommt vom Host return # der erste Zustand kommt vom Host
elif saved_game != null:
game = saved_game
elif not hotseat_decks.is_empty(): elif not hotseat_decks.is_empty():
game = Game.new_hotseat(hotseat_decks, hotseat_ai) game = Game.new_hotseat(hotseat_decks, hotseat_ai)
elif tutorial: elif tutorial:
@@ -227,7 +249,7 @@ func own_rules() -> Dictionary:
func _begin() -> void: func _begin() -> void:
n_opp = game.players.size() - 1 n_opp = game.players.size() - 1
if is_online(): if is_online():
perspective = game.players[online_seat] perspective = game.players[maxi(online_seat, 0)]
else: else:
game.events.clear() game.events.clear()
perspective = game.current() if game.current().is_human else game.humans[0] perspective = game.current() if game.current().is_human else game.humans[0]
@@ -235,6 +257,8 @@ func _begin() -> void:
if is_online() and net.is_host: if is_online() and net.is_host:
net.broadcast() net.broadcast()
game.events.clear() game.events.clear()
b_save_leave.visible = resumable()
save_battle()
refresh() refresh()
@@ -304,7 +328,14 @@ func _build_static() -> void:
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center") notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center")
Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c")) Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
# Aufgeben bestätigen # Aufgeben bestätigen
confirm = Style.confirm(self, "Wirklich aufgeben?", "Der Kampf endet ohne Münzen.", "Ja, aufgeben", leave, "Weiterspielen") confirm = Style.overlay(self)
Style.panel(confirm, Rect2(340, 290, 600, 200), Style.PANEL, 10, Style.MUTED, 2)
Style.label(confirm, "Wirklich aufgeben?", 26, Style.TEXT, Rect2(340, 312, 600, 40), "center", true)
Style.label(confirm, tr("Aufgeben beendet den Kampf ohne Münzen.") + "\n" + tr("Speichern hebt ihn für später auf."), 15, Style.MUTED, Rect2(340, 356, 600, 48), "center")
Style.button(confirm, "Ja, aufgeben", Rect2(355, 420, 180, 46), leave, Color("5a2828"))
b_save_leave = Style.button(confirm, "Speichern und zurück", Rect2(550, 420, 180, 46), save_and_leave, Color("285a3c"))
b_save_leave.add_theme_font_size_override("font_size", 14)
Style.button(confirm, "Weiterspielen", Rect2(745, 420, 180, 46), func(): confirm.visible = false)
# Übergabe zwischen Menschen (Hotseat) # Übergabe zwischen Menschen (Hotseat)
handover = _overlay() handover = _overlay()
Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2) Style.panel(handover, Rect2(340, 280, 600, 220), Style.PANEL, 10, Style.GOLD, 2)
@@ -324,9 +355,31 @@ func _overlay() -> Control:
return Style.overlay(self) return Style.overlay(self)
## Einzelspieler-Kämpfe (auch Kampagne und Herausforderung) lassen sich unterbrechen und fortsetzen.
func resumable() -> bool:
return counts_for_profile() and not tutorial
func save_battle() -> void:
if not resumable() or game == null or game.winner != "":
return
var d := {"version": 1, "n_opp": n_opp, "stage": stage, "challenge": challenge, "played_ids": played_ids, "game": game.snapshot(-1)}
var f := FileAccess.open(app.battle_save_path(), FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify(d))
f.close()
func save_and_leave() -> void:
save_battle()
app.show_screen(MenuScreen.new())
func leave() -> void: func leave() -> void:
if is_online(): if is_online():
net.leave() net.leave()
if resumable() and game != null and game.winner == "":
app.clear_battle_save() # aufgegeben
if tutorial: if tutorial:
if tutorial_part == 1: if tutorial_part == 1:
app.profile.tutorial_done = true app.profile.tutorial_done = true
@@ -398,7 +451,9 @@ func build_hero_boxes() -> void:
if not o.perks.is_empty(): if not o.perks.is_empty():
title += " ★" title += " ★"
labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT, labels["name"] = Style.label(hero_layer, title, 14 if c else 18, Style.TEXT,
Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (44 if c else 20), 26), "left", true) Rect2(box.position.x + 10, box.position.y + 6, box.size.x - (58 if c else 20), 26), "left", true)
labels.name.clip_text = true
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED, labels["life"] = Style.label(hero_layer, "", 16 if c else 20, Style.RED,
Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true) Rect2(box.position.x + 10, box.position.y + (35 if c else 32), 120, 28), "left", true)
labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED, labels["stats"] = Style.label(hero_layer, "", 12 if c else 14, Style.MUTED,
@@ -414,6 +469,8 @@ func build_hero_boxes() -> void:
# --- Ablauf und Eingabe ------------------------------------------------------------------ # --- Ablauf und Eingabe ------------------------------------------------------------------
func my_turn() -> bool: func my_turn() -> bool:
if spectating():
return false
return game != null and game.winner == "" and game.current() == perspective return game != null and game.winner == "" and game.current() == perspective
@@ -500,7 +557,7 @@ func _process(delta: float) -> void:
handover.visible = true handover.visible = true
hand_layer.visible = false hand_layer.visible = false
trap_layer.visible = false trap_layer.visible = false
elif cur == perspective and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible and not handover.visible: elif cur == perspective and not spectating() and cur.turns == 1 and not cur.mulligan_done and not mulligan.visible and not handover.visible:
show_mulligan() show_mulligan()
return return
if is_client(): if is_client():
@@ -558,6 +615,7 @@ func _finish() -> void:
app.profile.tutorial2_done = true app.profile.tutorial2_done = true
app.save() app.save()
app.sfx.play("win" if won else "lose") app.sfx.play("win" if won else "lose")
app.clear_battle_save()
else: else:
var wp := game.winner_player var wp := game.winner_player
var mine: bool = wp != null and wp == perspective var mine: bool = wp != null and wp == perspective
@@ -704,7 +762,7 @@ func on_click(kind: String, obj) -> void:
selected = obj selected = obj
say(tr("Wähle ein Angriffsziel")) say(tr("Wähle ein Angriffsziel"))
else: else:
say(tr("%s kann nicht angreifen") % obj.name) say(tr("%s kann nicht angreifen") % obj.card.title())
after_action() after_action()
@@ -761,13 +819,13 @@ func combo_hints(card: Cards.Card) -> Array[String]:
var combos := {} var combos := {}
for other in perspective.board: for other in perspective.board:
if other.card.elements.any(func(e): return e in card.elements): if other.card.elements.any(func(e): return e in card.elements):
partners.append(other.name) partners.append(other.card.title())
for e1 in card.elements: for e1 in card.elements:
for e2 in other.card.elements: for e2 in other.card.elements:
if e1 != e2: if e1 != e2:
var name := Cards.combo_name([e1, e2]) var name := Cards.combo_name([e1, e2])
if not combos.has(name): if not combos.has(name):
combos[name] = other.name combos[name] = other.card.title()
if not partners.is_empty(): if not partners.is_empty():
out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)]) out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)])
for name in combos: for name in combos:
@@ -784,11 +842,28 @@ func _on_snapshot(snap: Dictionary) -> void:
if first: if first:
_begin() _begin()
return return
perspective = game.players[online_seat] perspective = game.players[maxi(online_seat, 0)]
build_hero_boxes() build_hero_boxes()
after_action() after_action()
## Host-Wechsel: das Spiel läuft jetzt auf diesem Rechner weiter.
func _on_migrated() -> void:
game = net.game
if not net.action_applied.is_connected(after_action):
net.action_applied.connect(after_action)
wait_label.visible = false
perspective = game.players[online_seat]
build_hero_boxes()
say("Du bist jetzt Host.")
after_action()
func _on_net_status() -> void:
wait_label.text = net.status
wait_label.visible = true
func _on_net_ended(reason: String) -> void: func _on_net_ended(reason: String) -> void:
if game == null or game.winner == "": if game == null or game.winner == "":
say(reason) say(reason)
@@ -814,6 +889,7 @@ func after_action() -> void:
centers[ref_key(ev[key])] = target_center(ev[key]) centers[ref_key(ev[key])] = target_center(ev[key])
refresh() refresh()
fx.play_events(evs, centers) fx.play_events(evs, centers)
save_battle()
func _note_events(evs: Array) -> void: func _note_events(evs: Array) -> void:
@@ -962,21 +1038,24 @@ func refresh() -> void:
trap_views.append(tv) trap_views.append(tv)
Style.clear(hand_layer) Style.clear(hand_layer)
for i in h.hand.size(): if spectating():
var card: Cards.Card = h.hand[i] Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(0, 690, 1280, 30), "center")
var playable: bool = mine and g.can_play(h, card)[0] else:
var r := BattleLayout.hand_rect(h.hand.size(), i) for i in h.hand.size():
var v := CardView.new(card, r.size) var card: Cards.Card = h.hand[i]
v.position = r.position var playable: bool = mine and g.can_play(h, card)[0]
if card == pending: var r := BattleLayout.hand_rect(h.hand.size(), i)
v.border = Style.YELLOW var v := CardView.new(card, r.size)
elif playable: v.position = r.position
v.border = Style.GREEN if card == pending:
v.dim = not playable v.border = Style.YELLOW
v.hover.connect(show_hand_preview) elif playable:
v.clicked.connect(func(_view): on_click("hand", card)) v.border = Style.GREEN
hand_layer.add_child(v) v.dim = not playable
hand_views.append(v) v.hover.connect(show_hand_preview)
v.clicked.connect(func(_view): on_click("hand", card))
hand_layer.add_child(v)
hand_views.append(v)
var bar_border = Style.BLUE if mine else null var bar_border = Style.BLUE if mine else null
if int(flash_until.get(g.players.find(h), 0)) > now: if int(flash_until.get(g.players.find(h), 0)) > now:
@@ -987,10 +1066,12 @@ func refresh() -> void:
energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy] energy_label.text = "◆ %d/%d" % [h.energy, h.max_energy]
deck_label.text = "Deck %d" % h.deck.size() deck_label.text = "Deck %d" % h.deck.size()
status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name]) status_label.text = (tr("Zug %d · Du bist dran") % g.turn) if mine else (tr("Zug %d · %s ist dran …") % [g.turn, g.current().name])
if spectating():
status_label.text = tr("Zuschauer") + " · " + status_label.text
b_end.disabled = not mine b_end.disabled = not mine
var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client() var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client()
b_skip.visible = ai_running b_skip.visible = ai_running
b_end.visible = not ai_running b_end.visible = not ai_running and not spectating()
b_undo.visible = undo_state != null and mine b_undo.visible = undo_state != null and mine
var lines: Array[String] = [] var lines: Array[String] = []
var start := maxi(0, g.log.size() - 60) var start := maxi(0, g.log.size() - 60)

View File

@@ -38,8 +38,11 @@ func stage_index() -> int:
return 0 if part == 1 else 1 return 0 if part == 1 else 1
## Teil 1: ein Wesen für 1 Energie auf der Hand, damit der erste Schritt immer möglich ist.
## Teil 2: Lehrkarten auf die eigene Hand; Mara bekommt Wesen, damit sie sicher in die Falle läuft. ## Teil 2: Lehrkarten auf die eigene Hand; Mara bekommt Wesen, damit sie sicher in die Falle läuft.
func inject_cards(g: Game) -> void: func inject_cards(g: Game) -> void:
if part == 1 and not g.human.hand.any(func(c): return c.is_creature() and c.cost == 1):
g.human.hand.append(Cards.by_id("f_kobold"))
if part == 2: if part == 2:
g.human.hand.append(Cards.by_id("w_falle")) g.human.hand.append(Cards.by_id("w_falle"))
g.human.hand.append(Cards.by_id("w_reiter")) g.human.hand.append(Cards.by_id("w_reiter"))

View File

@@ -15,6 +15,7 @@ var b_all: Button
var b_improve: Button var b_improve: Button
var suggestion := "" var suggestion := ""
var slots: Control var slots: Control
var element_buttons: Array = []
class CardBack extends Control: class CardBack extends Control:
@@ -24,15 +25,21 @@ class CardBack extends Control:
size = sz size = sz
custom_minimum_size = sz custom_minimum_size = sz
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
focus_entered.connect(queue_redraw)
focus_exited.connect(queue_redraw)
func _gui_input(event: InputEvent) -> void: func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event() accept_event()
clicked.emit() clicked.emit()
elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event()
clicked.emit()
func _draw() -> void: func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size) var rect := Rect2(Vector2.ZERO, size)
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 3), rect) draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect)
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6) Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true) Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
@@ -48,15 +55,20 @@ func _ready() -> void:
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center") coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.", Style.label(self, "5 Karten pro Booster, mindestens eine seltene. Klicke auf eine Karte zum Aufdecken.",
16, Style.MUTED, Rect2(0, 115, 1280, 24), "center") 16, Style.MUTED, Rect2(0, 115, 1280, 24), "center")
Style.label(self, "Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos" % Profile.ELEMENT_BOOSTER_COST,
14, Style.MUTED, Rect2(0, 586, 1280, 22), "center")
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
element_buttons.append(Style.button(self, e, Rect2(322 + i * 162, 612, 150, 38), buy.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)))
slots = Control.new() slots = Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(slots) add_child(slots)
msg_label = Style.label(self, "", 18, Style.RED, Rect2(0, 610, 1280, 30), "center") msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 700, 1280, 22), "center")
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(360, 720, 260, 48), buy, Color("5a4628")) b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(290, 720, 330, 48), buy, Color("5a4628"))
b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 140, 48), reveal_all) b_all = Style.button(self, "Alle aufdecken", Rect2(640, 720, 200, 48), reveal_all)
b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c")) b_improve = Style.button(self, "", Rect2(360, 650, 560, 44), improve, Color("285a3c"))
b_improve.visible = false b_improve.visible = false
Style.button(self, "Zurück", Rect2(800, 720, 120, 48), func(): app.show_screen(MenuScreen.new())) Style.button(self, "Zurück", Rect2(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new()))
refresh() refresh()
@@ -65,10 +77,10 @@ func _input(event: InputEvent) -> void:
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())
func buy() -> void: func buy(element: String = "") -> void:
var p: Profile = app.profile var p: Profile = app.profile
var before: Dictionary = p.collection.duplicate() var before: Dictionary = p.collection.duplicate()
var got: Array = p.open_booster() var got: Array = p.open_booster(null, element)
if got.is_empty(): if got.is_empty():
msg_label.text = "Nicht genug Münzen" msg_label.text = "Nicht genug Münzen"
msg_label.add_theme_color_override("font_color", Style.RED) msg_label.add_theme_color_override("font_color", Style.RED)
@@ -106,7 +118,7 @@ func improve() -> void:
var err: String = app.profile.improve_deck_with(suggestion) var err: String = app.profile.improve_deck_with(suggestion)
if err == "": if err == "":
app.save() app.save()
msg_label.text = "%s ist jetzt im Deck %s" % [Cards.by_id(suggestion).name, app.profile.deck_name()] msg_label.text = tr("%s ist jetzt im Deck %s") % [Cards.by_id(suggestion).title(), app.profile.deck_name()]
msg_label.add_theme_color_override("font_color", Style.GREEN) msg_label.add_theme_color_override("font_color", Style.GREEN)
suggestion = "" suggestion = ""
else: else:
@@ -119,6 +131,8 @@ func refresh() -> void:
var p: Profile = app.profile var p: Profile = app.profile
coins_label.text = "Münzen: %d" % p.coins coins_label.text = "Münzen: %d" % p.coins
b_buy.disabled = p.coins < Profile.BOOSTER_COST b_buy.disabled = p.coins < Profile.BOOSTER_COST
for b in element_buttons:
b.disabled = p.coins < Profile.ELEMENT_BOOSTER_COST
b_all.disabled = cards.is_empty() or revealed.size() >= 5 b_all.disabled = cards.is_empty() or revealed.size() >= 5
# Deck-Vorschlag, sobald alle Karten offen liegen # Deck-Vorschlag, sobald alle Karten offen liegen
suggestion = "" suggestion = ""
@@ -129,7 +143,7 @@ func refresh() -> void:
suggestion = better[0] suggestion = better[0]
b_improve.visible = suggestion != "" b_improve.visible = suggestion != ""
if suggestion != "": if suggestion != "":
b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).name, p.deck_name()] b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).title(), p.deck_name()]
Style.clear(slots) Style.clear(slots)
for i in 5: for i in 5:
var r := slot_rect(i) var r := slot_rect(i)
@@ -140,7 +154,7 @@ func refresh() -> void:
var v := CardView.new(c, r.size) var v := CardView.new(c, r.size)
v.position = r.position v.position = r.position
slots.add_child(v) slots.add_child(v)
Style.label(slots, c.rarity, 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true) Style.label(slots, tr(c.rarity), 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if new_ids.has(c.id): if new_ids.has(c.id):
Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true) Style.label(slots, "Neu!", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
else: else:

View File

@@ -8,7 +8,7 @@ var perk_box: OptionButton
func _ready() -> void: func _ready() -> void:
var p: Profile = app.profile var p: Profile = app.profile
chapter = 2 if p.campaign_stage >= Campaign.STAGES.size() else 1 chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size())
build() build()
@@ -19,9 +19,9 @@ func build() -> void:
Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true) Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true)
var tabs := Style.row(col, 10) var tabs := Style.row(col, 10)
tabs.alignment = BoxContainer.ALIGNMENT_CENTER tabs.alignment = BoxContainer.ALIGNMENT_CENTER
var b1 := Style.cbutton(tabs, "Kapitel 1", Vector2(160, 36), func(): set_chapter(1), Color("285a3c") if chapter == 1 else null) for c in Campaign.CHAPTERS.size():
var b2 := Style.cbutton(tabs, "Kapitel 2", Vector2(160, 36), func(): set_chapter(2), Color("285a3c") if chapter == 2 else null) var b := Style.cbutton(tabs, "Kapitel %d" % (c + 1), Vector2(140, 36), set_chapter.bind(c + 1), Color("285a3c") if chapter == c + 1 else null)
b2.disabled = p.campaign_stage < Campaign.STAGES.size() b.disabled = p.campaign_stage < c * Campaign.STAGES.size()
if p.perk_unlocked(): if p.perk_unlocked():
var pr := Style.row(tabs, 8) var pr := Style.row(tabs, 8)
Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110) Style.clabel(pr, "Dein Merkmal:", 15, Style.MUTED, "right", false, 110)
@@ -36,9 +36,11 @@ func build() -> void:
p.perk = "" if i == 0 else keys[i - 1] p.perk = "" if i == 0 else keys[i - 1]
app.save()) app.save())
pr.add_child(perk_box) pr.add_child(perk_box)
Style.clabel(col, "Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★)." if chapter == 1 var intro := ["Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★).",
else "Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.", 14, Style.MUTED) "Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.",
var offset := 0 if chapter == 1 else Campaign.STAGES.size() "Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks."]
Style.clabel(col, intro[chapter - 1], 14, Style.MUTED)
var offset := (chapter - 1) * Campaign.STAGES.size()
for k in Campaign.STAGES.size(): for k in Campaign.STAGES.size():
var i := offset + k var i := offset + k
var st := Campaign.stage(i) var st := Campaign.stage(i)
@@ -76,7 +78,7 @@ func build() -> void:
var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass) var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
b.disabled = true b.disabled = true
if p.campaign_stage >= Campaign.total(): if p.campaign_stage >= Campaign.total():
Style.clabel(col, "Beide Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true) Style.clabel(col, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true)
var br := Style.row(col) var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new())) Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))

View File

@@ -17,6 +17,7 @@ func _init(rect: Rect2) -> void:
position = rect.position position = rect.position
size = rect.size size = rect.size
horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
follow_focus = true # Gamepad: der fokussierten Karte hinterherscrollen
grid = GridContainer.new() grid = GridContainer.new()
grid.columns = COLS grid.columns = COLS
grid.add_theme_constant_override("h_separation", GAP) grid.add_theme_constant_override("h_separation", GAP)
@@ -31,7 +32,7 @@ func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
var cell := Control.new() var cell := Control.new()
cell.custom_minimum_size = Vector2(CW, CH + 16) cell.custom_minimum_size = Vector2(CW, CH + 16)
cell.mouse_filter = Control.MOUSE_FILTER_IGNORE cell.mouse_filter = Control.MOUSE_FILTER_IGNORE
var v := CardView.new(c, Vector2(CW, CH)) var v := CardView.new(c, Vector2(CW, CH), true)
v.dim = dim_fn.call(c) v.dim = dim_fn.call(c)
v.clicked.connect(func(_view): card_clicked.emit(c)) v.clicked.connect(func(_view): card_clicked.emit(c))
v.hover.connect(func(card, entered): card_hover.emit(card, entered)) v.hover.connect(func(card, entered): card_hover.emit(card, entered))

View File

@@ -10,19 +10,31 @@ var border = null # Color oder null (dann Seltenheitsfarbe)
var dim := false var dim := false
func _init(c: Cards.Card, sz: Vector2) -> void: ## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
func _init(c: Cards.Card, sz: Vector2, focusable: bool = false) -> void:
card = c card = c
custom_minimum_size = sz custom_minimum_size = sz
size = sz size = sz
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(card, true)) mouse_entered.connect(func(): hover.emit(card, true))
mouse_exited.connect(func(): hover.emit(card, false)) mouse_exited.connect(func(): hover.emit(card, false))
if focusable:
focus_mode = Control.FOCUS_ALL
focus_entered.connect(func():
hover.emit(card, true)
queue_redraw())
focus_exited.connect(func():
hover.emit(card, false)
queue_redraw())
func _gui_input(event: InputEvent) -> void: func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event() accept_event()
clicked.emit(self) clicked.emit(self)
elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event()
clicked.emit(self)
func _draw() -> void: func _draw() -> void:
@@ -41,10 +53,11 @@ func _draw() -> void:
draw_circle(cc, r, Color("14141c")) draw_circle(cc, r, Color("14141c"))
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true) Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true)
# Name und Kombo # Name und Kombo
var nm := Style.fit(card.name, true, f_name, size.x - 2 * r - 12) var name_left := 2 * r + 10
Style.text(self, nm, f_name, Style.TEXT, Vector2(size.x / 2 + r / 2.0, 4), "midtop", true) var nm := Style.fit(card.title(), true, f_name, size.x - name_left - 6)
Style.text(self, nm, f_name, Style.TEXT, Vector2((name_left + size.x - 6) / 2.0, 4), "midtop", true)
if mid: if mid:
var sub: String = (card.combo() + " · " + card.kind) if card.is_combo() else card.kind var sub: String = (tr(card.combo()) + " · " + tr(card.kind)) if card.is_combo() else tr(card.kind)
Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop") Style.text(self, sub, f_text, Color("e6e6e6"), Vector2(size.x / 2, 2 * r + 4), "midtop")
# Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest # Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest
var tsize := Style.fs(f_text) var tsize := Style.fs(f_text)
@@ -60,11 +73,14 @@ func _draw() -> void:
var gsize: float = minf(band_bottom - band_top, size.x * 0.42) var gsize: float = minf(band_bottom - band_top, size.x * 0.42)
Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize) Style.glyph(self, card, Vector2(size.x / 2, (band_top + band_bottom) / 2), gsize)
draw_rect(box, Color(0.06, 0.06, 0.09, 0.67)) draw_rect(box, Color(0.06, 0.06, 0.09, 0.67))
var max_lines := maxi(1, int((box.size.y - 6) / Style.FONT.get_height(tsize))) # große Schrift: lieber kürzen als überlaufen
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)), draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)),
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, -1, Style.TEXT) card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
# Werte # Werte
if card.is_creature(): if card.is_creature():
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true) Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true) Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
if dim: if dim:
draw_rect(rect, Color(0, 0, 0, 0.47)) draw_rect(rect, Color(0, 0, 0, 0.47))
if has_focus():
Style.border(self, rect, Style.GOLD, 4, 8)

View File

@@ -44,7 +44,7 @@ func _draw() -> void:
_draw_mini() _draw_mini()
return return
var fs := 11 if small else 13 var fs := 11 if small else 13
var nm := Style.fit(creature.name, true, fs, size.x - 6) var nm := Style.fit(creature.card.title(), true, fs, size.x - 6)
Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true) Style.text(self, nm, fs, Style.TEXT, Vector2(size.x / 2, 4), "midtop", true)
var badges: Array[String] = [] var badges: Array[String] = []
if creature.taunt(): if creature.taunt():

View File

@@ -28,6 +28,8 @@ var b_activate: Button
var b_autofill: Button var b_autofill: Button
var name_box: LineEdit var name_box: LineEdit
var confirm: Control var confirm: Control
var code_dialog: Control
var code_box: LineEdit
class DeckRow extends Control: class DeckRow extends Control:
@@ -43,26 +45,30 @@ class DeckRow extends Control:
size = sz size = sz
custom_minimum_size = sz custom_minimum_size = sz
mouse_filter = Control.MOUSE_FILTER_STOP mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): focus_mode = Control.FOCUS_ALL
lit = true var light := func(on: bool):
lit = on
queue_redraw() queue_redraw()
hover.emit(card, true)) hover.emit(card, on)
mouse_exited.connect(func(): mouse_entered.connect(light.bind(true))
lit = false mouse_exited.connect(light.bind(false))
queue_redraw() focus_entered.connect(light.bind(true))
hover.emit(card, false)) focus_exited.connect(light.bind(false))
func _gui_input(event: InputEvent) -> void: func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event() accept_event()
clicked.emit(card) clicked.emit(card)
elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event()
clicked.emit(card)
func _draw() -> void: func _draw() -> void:
if lit: if lit:
draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size)) draw_style_box(Style.box(Style.PANEL_LIGHT, 4), Rect2(Vector2.ZERO, size))
for k in card.elements.size(): for k in card.elements.size():
draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]]) draw_circle(Vector2(10 + k * 12, size.y / 2), 7, Cards.ELEMENT_COLORS[card.elements[k]])
Style.text(self, "%dx %s" % [count, card.name], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft") Style.text(self, "%dx %s" % [count, card.title()], 15, Style.TEXT, Vector2(40, size.y / 2), "midleft")
Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true) Style.text(self, str(card.cost), 15, Style.GOLD, Vector2(size.x - 10, size.y / 2), "midright", true)
@@ -93,10 +99,11 @@ func _ready() -> void:
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size(): for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i] var e: String = Cards.ELEMENTS[i]
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14)
search_box = LineEdit.new() search_box = LineEdit.new()
search_box.position = Vector2(400, 55) search_box.position = Vector2(424, 55)
search_box.size = Vector2(160, 34) search_box.size = Vector2(136, 34)
search_box.placeholder_text = "Suche" search_box.placeholder_text = "Suche"
search_box.text_changed.connect(func(t: String): search_box.text_changed.connect(func(t: String):
search = t search = t
@@ -150,9 +157,23 @@ func _ready() -> void:
curve.mouse_filter = Control.MOUSE_FILTER_IGNORE curve.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(curve) add_child(curve)
msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24)) msg_label = Style.label(self, "", 16, Style.GREEN, Rect2(860, 736, 400, 24))
Style.button(self, "Speichern", Rect2(860, 740, 180, 44), save_deck, Color("285a3c")) Style.button(self, "Speichern", Rect2(860, 740, 140, 44), save_deck, Color("285a3c"))
Style.button(self, "Leeren", Rect2(1050, 740, 90, 44), clear_deck) Style.button(self, "Leeren", Rect2(1008, 740, 70, 44), clear_deck)
Style.button(self, "Zurück", Rect2(1150, 740, 110, 44), leave) Style.button(self, "Code", Rect2(1086, 740, 84, 44), open_code)
Style.button(self, "Zurück", Rect2(1178, 740, 82, 44), leave)
# Deck-Code zum Teilen: kopieren, einfügen, übernehmen
code_dialog = Style.overlay(self)
Style.panel(code_dialog, Rect2(240, 270, 800, 240), Style.PANEL, 10, Style.GOLD, 2)
Style.label(code_dialog, "Deck-Code", 26, Style.TEXT, Rect2(240, 290, 800, 40), "center", true)
Style.label(code_dialog, "Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.", 15, Style.MUTED, Rect2(240, 334, 800, 24), "center")
code_box = LineEdit.new()
code_box.position = Vector2(270, 368)
code_box.size = Vector2(740, 36)
code_dialog.add_child(code_box)
Style.button(code_dialog, "In Zwischenablage", Rect2(270, 430, 180, 44), func(): DisplayServer.clipboard_set(code_box.text))
Style.button(code_dialog, "Aus Zwischenablage", Rect2(460, 430, 180, 44), func(): code_box.text = DisplayServer.clipboard_get())
Style.button(code_dialog, "Übernehmen", Rect2(650, 430, 170, 44), func(): apply_code(code_box.text), Color("285a3c"))
Style.button(code_dialog, "Schließen", Rect2(830, 430, 180, 44), func(): code_dialog.visible = false)
confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.", confirm = Style.confirm(self, "Ungespeicherte Änderungen", "Das Deck wurde geändert, aber nicht gespeichert.",
"Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD) "Verwerfen", func(): app.show_screen(MenuScreen.new()), "Weiter bearbeiten", Style.GOLD)
preview = CardView.new(Cards.all()[0], Vector2(200, 280)) preview = CardView.new(Cards.all()[0], Vector2(200, 280))
@@ -174,9 +195,11 @@ func leave() -> void:
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus() and not name_box.has_focus(): if event.is_action_pressed("ui_cancel") and not search_box.has_focus() and not name_box.has_focus() and not code_box.has_focus():
if confirm.visible: if confirm.visible:
confirm.visible = false confirm.visible = false
elif code_dialog.visible:
code_dialog.visible = false
else: else:
leave() leave()
@@ -244,7 +267,7 @@ func cards() -> Array:
out = out.filter(func(c): return filter in c.elements) out = out.filter(func(c): return filter in c.elements)
if search != "": if search != "":
var q := search.to_lower() var q := search.to_lower()
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower()) out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
match sort_mode: match sort_mode:
0: 0:
out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name)) out.sort_custom(func(a, b): return a.cost < b.cost or (a.cost == b.cost and a.name < b.name))
@@ -308,6 +331,34 @@ func save_deck() -> void:
refresh() refresh()
func open_code() -> void:
code_box.text = Profile.deck_code(deck)
code_dialog.visible = true
## Übernimmt einen Deck-Code; Karten, die in der Sammlung fehlen, werden gemeldet und weggelassen.
func apply_code(code: String) -> void:
var ids: Array = Profile.parse_deck_code(code)
if ids.is_empty():
say("Ungültiger Code", false)
return
var p: Profile = app.profile
var missing := {}
deck = []
for cid in ids:
var owned: int = p.collection.get(cid, 0)
if deck.count(cid) < owned and deck.size() < Game.DECK_SIZE:
deck.append(cid)
else:
missing[cid] = true
code_dialog.visible = false
if missing.is_empty():
say("Code übernommen", true)
else:
say(tr("Code übernommen, es fehlen: ") + ", ".join(missing.keys().map(func(cid): return Cards.by_id(cid).title())), false)
refresh()
func deck_cards() -> Array: func deck_cards() -> Array:
var out := [] var out := []
for cid in deck: for cid in deck:
@@ -361,7 +412,7 @@ func refresh() -> void:
deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT) deck_label.add_theme_color_override("font_color", Style.GREEN if deck.size() == Game.DECK_SIZE else Style.TEXT)
var elems := Game.deck_elements(dc) var elems := Game.deck_elements(dc)
elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b)) elems.sort_custom(func(a, b): return Cards.ELEMENTS.find(a) < Cards.ELEMENTS.find(b))
elems_label.text = "Elemente: " + (", ".join(elems) if not elems.is_empty() else "–") elems_label.text = tr("Elemente: ") + (", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else "–")
if not dc.is_empty(): if not dc.is_empty():
elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc) elems_label.text += " · " + tr("Stärke %.1f") % Game.deck_rating(dc)
var ok: Array = Game.validate_deck(dc, p.collection) var ok: Array = Game.validate_deck(dc, p.collection)

View File

@@ -9,6 +9,7 @@ var list_label: Label
var ip_box: LineEdit var ip_box: LineEdit
var b_host: Button var b_host: Button
var b_join: Button var b_join: Button
var b_watch: Button
var b_start: Button var b_start: Button
var ai_buttons: Array = [] var ai_buttons: Array = []
var n_ai := 0 var n_ai := 0
@@ -32,6 +33,7 @@ func _ready() -> void:
ip_box.text = "127.0.0.1" ip_box.text = "127.0.0.1"
hr.add_child(ip_box) hr.add_child(ip_box)
b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join) b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join)
b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch)
addr_label = Style.clabel(col, "", 14, Style.MUTED) addr_label = Style.clabel(col, "", 14, Style.MUTED)
status_label = Style.clabel(col, "", 16, Style.BLUE) status_label = Style.clabel(col, "", 16, Style.BLUE)
Style.spacer(col, 6) Style.spacer(col, 6)
@@ -71,6 +73,13 @@ func _join() -> void:
refresh() refresh()
## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit.
func _watch() -> void:
net.spectator = true
net.join(ip_box.text.strip_edges())
refresh()
func _set_ai(n: int) -> void: func _set_ai(n: int) -> void:
n_ai = n n_ai = n
refresh() refresh()
@@ -96,9 +105,12 @@ func refresh() -> void:
ids.sort() ids.sort()
for id in ids: for id in ids:
lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""]) lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""])
for id in net.spectators:
lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")])
list_label.text = "\n".join(lines) if not lines.is_empty() else "–" list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
b_host.disabled = net.connected b_host.disabled = net.connected
b_join.disabled = net.connected b_join.disabled = net.connected
b_watch.disabled = net.connected
ip_box.editable = not net.connected ip_box.editable = not net.connected
var humans := ids.size() var humans := ids.size()
b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1) b_start.disabled = not (net.is_host and humans >= 2 and humans + n_ai <= Game.MAX_OPPONENTS + 1)

View File

@@ -40,6 +40,7 @@ func apply_settings() -> void:
func reset_profile() -> void: func reset_profile() -> void:
profile = Profile.starter() profile = Profile.starter()
profile.refresh_daily() profile.refresh_daily()
clear_battle_save()
save() save()
apply_settings() apply_settings()
@@ -108,6 +109,16 @@ func save() -> void:
profile.save(save_path) profile.save(save_path)
## Datei für einen unterbrochenen Kampf (neben dem Spielstand).
func battle_save_path() -> String:
return save_path.get_basename() + "_kampf.json"
func clear_battle_save() -> void:
if FileAccess.file_exists(battle_save_path()):
DirAccess.remove_absolute(ProjectSettings.globalize_path(battle_save_path()))
func _exit_tree() -> void: func _exit_tree() -> void:
if net != null and net.is_inside_tree(): if net != null and net.is_inside_tree():
net.leave() net.leave()

View File

@@ -8,6 +8,7 @@ var b_hotseat: Button
var b_online: Button var b_online: Button
var b_tutorial: Button var b_tutorial: Button
var b_challenge: Button var b_challenge: Button
var b_resume: Button
var coins_label: Label var coins_label: Label
var coll_label: Label var coll_label: Label
var deck_label: Label var deck_label: Label
@@ -36,6 +37,8 @@ func _ready() -> void:
var sz := Vector2(270, 44) var sz := Vector2(270, 44)
var p0: Profile = app.profile var p0: Profile = app.profile
var part2: bool = p0.tutorial_done and not p0.tutorial2_done var part2: bool = p0.tutorial_done and not p0.tutorial2_done
if FileAccess.file_exists(app.battle_save_path()):
b_resume = Style.cbutton(grid, "Kampf fortsetzen", sz, _resume, Color("5a4628"))
b_tutorial = Style.cbutton(grid, "Anleitung 2: Fallen" if part2 else "Anleitung", sz, b_tutorial = Style.cbutton(grid, "Anleitung 2: Fallen" if part2 else "Anleitung", sz,
func(): app.show_screen(BattleScreen.guided(2 if part2 else 1)), func(): app.show_screen(BattleScreen.guided(2 if part2 else 1)),
Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null) Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null)
@@ -58,6 +61,17 @@ func _ready() -> void:
refresh() refresh()
## Unterbrochenen Kampf laden; eine unlesbare Datei wird verworfen.
func _resume() -> void:
var json := JSON.new()
var text := FileAccess.get_file_as_string(app.battle_save_path())
if json.parse(text) != OK or not (json.data is Dictionary) or not json.data.has("game"):
app.clear_battle_save()
app.show_screen(MenuScreen.new())
return
app.show_screen(BattleScreen.resume(json.data))
func refresh() -> void: func refresh() -> void:
var p: Profile = app.profile var p: Profile = app.profile
var ok: Array = p.deck_valid() var ok: Array = p.deck_valid()
@@ -74,7 +88,7 @@ func refresh() -> void:
coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [ coll_label.text = tr("Sammlung: %d von %d Karten (%d insgesamt) · Kampagne: %d/%d") % [
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()] p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()]
var elems := Game.deck_elements(p.deck_cards()) var elems := Game.deck_elements(p.deck_cards())
deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems) if not elems.is_empty() else tr("leer")] deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems.map(func(e): return tr(e))) if not elems.is_empty() else tr("leer")]
var task := p.daily_task() var task := p.daily_task()
if p.daily.done: if p.daily.done:
daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text daily_label.text = tr("Tagesaufgabe erledigt: %s ✓") % task.text

View File

@@ -37,6 +37,9 @@ func _ready() -> void:
var info := (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n] var info := (tr("Duell") if n == 1 else tr("%d am Tisch") % (n + 1)) + " · " + tr("%d Münzen") % Profile.REWARD_WIN[n]
Style.clabel(r, info, 13, Style.MUTED, "left", false, 150) Style.clabel(r, info, 13, Style.MUTED, "left", false, 150)
preview_labels[n] = Style.clabel(r, "", 13, Style.TEXT, "left", false, 600) preview_labels[n] = Style.clabel(r, "", 13, Style.TEXT, "left", false, 600)
preview_labels[n].autowrap_mode = TextServer.AUTOWRAP_WORD
preview_labels[n].custom_minimum_size = Vector2(600, 0)
preview_labels[n].size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.spacer(col, 4) Style.spacer(col, 4)
var br := Style.row(col, 16) var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER br.alignment = BoxContainer.ALIGNMENT_CENTER

View File

@@ -70,7 +70,7 @@ func _ready() -> void:
language = OptionButton.new() language = OptionButton.new()
language.custom_minimum_size = Vector2(280, 36) language.custom_minimum_size = Vector2(280, 36)
language.add_item("Deutsch", 0) language.add_item("Deutsch", 0)
language.add_item("English (Oberfläche)", 1) language.add_item("English", 1)
language.select(1 if s.language == "en" else 0) language.select(1 if s.language == "en" else 0)
language.item_selected.connect(_on_language) language.item_selected.connect(_on_language)
grid.add_child(language) grid.add_child(language)

View File

@@ -285,7 +285,7 @@ static func rules_text() -> String:
Cards.RESONANCE_TEXT, Cards.RESONANCE_TEXT,
"Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):", "Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):",
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an das stärkste gegnerische Wesen, Blitz 2 Schaden", " Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an das stärkste gegnerische Wesen, Blitz 2 Schaden",
" an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen Schild.", " an den schwächsten Gegner, Schlamm heilt dich um 3, Sturm zieht 1 Karte, Sand gibt dem neuen Wesen +1/+1.",
"", "",
"Schlüsselwörter:", "Schlüsselwörter:",
] ]
@@ -295,9 +295,13 @@ static func rules_text() -> String:
lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES]) lines.append("Deck: genau %d Karten, höchstens %dx dieselbe Karte." % [Game.DECK_SIZE, Game.MAX_COPIES])
lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.") lines.append("Leeres Deck: jede weitere gezogene Karte verursacht steigenden Schaden.")
lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.") lines.append("Booster: 5 Karten für 100 Münzen, mindestens eine seltene. Siege bringen Münzen.")
lines.append("Elementbooster: 5 Karten eines Elements (und seiner Kombos) für %d Münzen." % Profile.ELEMENT_BOOSTER_COST)
lines.append("Deck-Code: im Deckbau unter „Code“ kopieren und mit anderen teilen.")
lines.append("Ein unterbrochener Einzelspieler-Kampf lässt sich im Menü fortsetzen.")
lines.append("") lines.append("")
lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.") lines.append("Werkstatt: Karten zerlegen bringt Staub, aus Staub entstehen neue Karten. Fünf Deck-Slots.")
lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.") lines.append("Lokal zu mehreren: bis zu vier Menschen am selben Rechner, dazu KI, ohne Münzen.")
lines.append("Netzwerkspiel: Fällt der Host aus, übernimmt ein Mitspieler; Zuschauer sehen die Partie ohne Hände.")
lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.") lines.append("Mulligan: Im ersten eigenen Zug darfst du einmal Handkarten zurücklegen und nachziehen.")
lines.append("Deckstärke: Seltenheit, Schlüsselwörter und Effekte ergeben je Karte einen Wert. Gegnerdecks werden auf deine") lines.append("Deckstärke: Seltenheit, Schlüsselwörter und Effekte ergeben je Karte einen Wert. Gegnerdecks werden auf deine")
lines.append(" Stärke gebaut (Leicht 0,9-fach, Normal 1-fach, Schwer 1,2-fach mit mehr Münzen). Die Kampagne liegt nie darunter.") lines.append(" Stärke gebaut (Leicht 0,9-fach, Normal 1-fach, Schwer 1,2-fach mit mehr Münzen). Die Kampagne liegt nie darunter.")

View File

@@ -24,9 +24,10 @@ func _ready() -> void:
Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind("")) Style.button(self, "Alle", Rect2(20, 55, 60, 34), set_filter.bind(""))
for i in Cards.ELEMENTS.size(): for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i] var e: String = Cards.ELEMENTS[i]
Style.button(self, e, Rect2(88 + i * 76, 55, 70, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8)) var fb := Style.button(self, e, Rect2(88 + i * 82, 55, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14)
search_box = LineEdit.new() search_box = LineEdit.new()
search_box.position = Vector2(400, 55) search_box.position = Vector2(424, 55)
search_box.size = Vector2(200, 34) search_box.size = Vector2(200, 34)
search_box.placeholder_text = "Suche" search_box.placeholder_text = "Suche"
search_box.text_changed.connect(func(t: String): search_box.text_changed.connect(func(t: String):
@@ -51,7 +52,7 @@ func _ready() -> void:
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not search_box.has_focus(): if event.is_action_pressed("ui_cancel") and not search_box.has_focus():
app.show_screen(MenuScreen.new()) app.show_screen(MenuScreen.new())
@@ -66,7 +67,7 @@ func cards() -> Array:
out = out.filter(func(c): return filter in c.elements) out = out.filter(func(c): return filter in c.elements)
if search != "": if search != "":
var q := search.to_lower() var q := search.to_lower()
out = out.filter(func(c): return q in c.name.to_lower() or q in c.text().to_lower() or q in c.combo().to_lower()) out = out.filter(func(c): return q in c.name.to_lower() or q in c.title().to_lower() or q in c.text().to_lower() or q in c.combo().to_lower())
return out return out
@@ -88,7 +89,7 @@ func dismantle() -> void:
if err == "": if err == "":
app.sfx.play("click") app.sfx.play("click")
app.save() app.save()
say("%s zerlegt (+%d Staub)" % [selected.name, Profile.DUST_DISMANTLE[selected.rarity]], true) say(tr("%s zerlegt (+%d Staub)") % [selected.title(), Profile.DUST_DISMANTLE[selected.rarity]], true)
else: else:
say(err, false) say(err, false)
refresh() refresh()
@@ -102,7 +103,7 @@ func craft() -> void:
app.sfx.play("play") app.sfx.play("play")
app.save() app.save()
var notes: Array[String] = app.profile.take_notices() var notes: Array[String] = app.profile.take_notices()
say("%s hergestellt" % selected.name + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true) say(tr("%s hergestellt") % selected.title() + (" · " + " · ".join(notes) if not notes.is_empty() else ""), true)
else: else:
say(err, false) say(err, false)
refresh() refresh()

View File

@@ -1,11 +1,14 @@
extends Node extends Node
## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge. ## Test-Mitspieler: tritt einem Host bei und spielt einfache Züge; mit --host eröffnet er
## selbst und startet, sobald ein zweiter Mensch am Tisch sitzt (ohne KI).
## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot ## godot --path . tests/net_bot.tscn -- --ip=127.0.0.1 --name=Bot
## godot --path . tests/net_bot.tscn -- --host --name=BotHost
var net: Net var net: Net
var seat := -1 var seat := -1
var game: Game var game: Game
var t := 0.0 var t := 0.0
var hosting := false
func _ready() -> void: func _ready() -> void:
@@ -16,20 +19,37 @@ func _ready() -> void:
ip = a.substr(5) ip = a.substr(5)
if a.begins_with("--name="): if a.begins_with("--name="):
bot_name = a.substr(7) bot_name = a.substr(7)
if a == "--host":
hosting = true
net = Net.new() net = Net.new()
net.name = "Net" net.name = "Net"
get_tree().root.add_child.call_deferred(net) get_tree().root.add_child.call_deferred(net)
net.my_name = bot_name net.my_name = bot_name
net.my_deck = Profile.starter().deck.duplicate() net.my_deck = Profile.starter().deck.duplicate()
net.started.connect(func(s): seat = s) net.started.connect(func(s):
seat = s
if hosting:
net.broadcast() # erster Zustand für alle, wie es sonst der Kampfbildschirm tut
_act_host.call_deferred())
net.snapshot_received.connect(_on_snapshot) net.snapshot_received.connect(_on_snapshot)
net.ended.connect(func(_r): get_tree().quit()) net.ended.connect(func(_r): get_tree().quit())
await get_tree().process_frame await get_tree().process_frame
net.join(ip) if hosting:
net.host()
net.lobby_changed.connect(func():
if net.game == null and net.names.size() >= 2:
net.start_battle(0))
net.action_applied.connect(func():
net.broadcast()
_act_host.call_deferred())
else:
net.join(ip)
func _process(delta: float) -> void: func _process(delta: float) -> void:
t += delta t += delta
if hosting and net.game != null:
net.tick()
if t > 180.0 or FileAccess.file_exists("user://bot_quit"): if t > 180.0 or FileAccess.file_exists("user://bot_quit"):
net.leave() net.leave()
get_tree().quit() get_tree().quit()
@@ -43,7 +63,23 @@ func _on_snapshot(snap: Dictionary) -> void:
return return
if game.current_idx != seat: if game.current_idx != seat:
return return
var me: Game.Player = game.players[seat] _decide(game.players[seat])
func _act_host() -> void:
if not hosting or net.game == null or net.game.winner != "" or seat < 0 or net.game.current_idx != seat:
return
game = net.game
_decide(game.players[seat])
## Einfache Züge mit kurzer Bedenkzeit: Mulligan überspringen, erstes spielbares Wesen ohne Ziel,
## dann angreifen, sonst Zug beenden.
func _decide(me: Game.Player) -> void:
var g := game
await get_tree().create_timer(0.25).timeout
if game != g or (hosting and (net.game == null or net.game.current_idx != seat)):
return # inzwischen ein neuer Zustand
if not me.mulligan_done and me.turns == 1: if not me.mulligan_done and me.turns == 1:
net.send_action({"type": "mulligan", "hand": []}) net.send_action({"type": "mulligan", "hand": []})
return return

View File

@@ -16,7 +16,7 @@ func _initialize() -> void:
var r := RandomNumberGenerator.new() var r := RandomNumberGenerator.new()
r.seed = 21 r.seed = 21
var arts := archetypes() var arts := archetypes()
var n := 60 var n := int(OS.get_environment("N")) if OS.get_environment("N") != "" else 60
var results := {} var results := {}
for a in arts: for a in arts:
var wins := 0 var wins := 0

View File

@@ -55,7 +55,11 @@ func put(p: Game.Player, cid: String, ready: bool = true) -> Game.Creature:
func _initialize() -> void: func _initialize() -> void:
TranslationServer.set_locale("de") # das Spiel setzt die Sprache aus den Einstellungen; hier fehlt der Einstieg
test_kartenpool() test_kartenpool()
test_kampf_speichern()
test_elementbooster_und_deckcode()
test_englische_karten()
test_kombos() test_kombos()
test_wesen_spielen() test_wesen_spielen()
test_flink() test_flink()
@@ -117,7 +121,7 @@ func _initialize() -> void:
func test_kartenpool() -> void: func test_kartenpool() -> void:
check(Cards.all().size() == 110, "110 Karten") check(Cards.all().size() == 118, "118 Karten")
for key in Cards.COMBOS: for key in Cards.COMBOS:
var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key) var found: bool = Cards.all().any(func(c): return c.is_combo() and Cards.combo_key(c.elements) == key)
check(found, "Kombo vorhanden: " + key) check(found, "Kombo vorhanden: " + key)
@@ -462,7 +466,7 @@ func test_spielstand() -> void:
p.coins = 999 p.coins = 999
p.save(path) p.save(path)
var p2 := Profile.load_or_new(path) var p2 := Profile.load_or_new(path)
check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == 42, "geladen") check(p2.coins == 999 and p2.deck.size() == 20 and p2.collection.size() == Cards.of_rarity("gewöhnlich").size() + 2, "geladen")
for suffix in ["", ".bak"]: for suffix in ["", ".bak"]:
if FileAccess.file_exists(path + suffix): if FileAccess.file_exists(path + suffix):
DirAccess.remove_absolute(abs + suffix) DirAccess.remove_absolute(abs + suffix)
@@ -551,7 +555,7 @@ func test_feldkombos() -> void:
s[2][1].life = 5 s[2][1].life = 5
g.play_card(h, C("l_wind")) g.play_card(h, C("l_wind"))
check(s[2][1].life == 3 and s[2][0].life == 15, "Blitz trifft den schwächsten Gegner") check(s[2][1].life == 3 and s[2][0].life == 15, "Blitz trifft den schwächsten Gegner")
# Schlamm heilt, Sturm zieht, Sand gibt Schild # Schlamm heilt, Sturm zieht, Sand gibt +1/+1
s = setup(["e_wall", "l_wind", "w_reiter"]) s = setup(["e_wall", "l_wind", "w_reiter"])
g = s[0] g = s[0]
h = s[1] h = s[1]
@@ -562,13 +566,13 @@ func test_feldkombos() -> void:
var hand_before := h.hand.size() var hand_before := h.hand.size()
g.play_card(h, C("l_wind")) g.play_card(h, C("l_wind"))
check(h.hand.size() == hand_before, "Sturm zieht 1 (eine gespielt, eine gezogen)") check(h.hand.size() == hand_before, "Sturm zieht 1 (eine gespielt, eine gezogen)")
check(not h.board[2].shield, "nur eine Feldkombo je Ausspielen") check(h.board[2].attack == 1, "nur eine Feldkombo je Ausspielen")
s = setup(["l_wind"]) s = setup(["l_wind"])
g = s[0] g = s[0]
h = s[1] h = s[1]
put(h, "e_wall") put(h, "e_wall")
g.play_card(h, C("l_wind")) g.play_card(h, C("l_wind"))
check(h.board[1].shield, "Sand: neues Wesen hat Schild") check(h.board[1].attack == 2 and h.board[1].health == 2, "Sand: neues Wesen erhält +1/+1")
# Gleiche Elemente lösen keine Feldkombo aus # Gleiche Elemente lösen keine Feldkombo aus
s = setup(["f_kobold"]) s = setup(["f_kobold"])
g = s[0] g = s[0]
@@ -736,7 +740,7 @@ func test_fallen() -> void:
g.end_turn() g.end_turn()
var a := put(o, "e_troll") var a := put(o, "e_troll")
check(g.attack(a, h) == "", "Angriff in die Feuerfalle") check(g.attack(a, h) == "", "Angriff in die Feuerfalle")
check(h.traps.is_empty() and a.health == 3 and h.life == 26, "Feuerfalle: 3 Schaden, Angriff geht durch") check(h.traps.is_empty() and a.health == 3 and h.life == 27, "Feuerfalle: 3 Schaden, Angriff geht durch")
# Frostfalle bricht den Angriff ab # Frostfalle bricht den Angriff ab
s = setup(["w_falle"]) s = setup(["w_falle"])
g = s[0] g = s[0]
@@ -1089,7 +1093,7 @@ func test_deck_umbenennen_und_auffuellen() -> void:
func test_neue_fallen() -> void: func test_neue_fallen() -> void:
check(Cards.all().filter(func(c): return c.is_trap()).size() == 10, "zehn Fallen") check(Cards.all().filter(func(c): return c.is_trap()).size() == 12, "zwölf Fallen")
check(C("e_sandfalle").text() == "Falle. Wenn ein Gegner ein Wesen ausspielt: Es wird eingefroren.", "Fallentext je Auslöser") check(C("e_sandfalle").text() == "Falle. Wenn ein Gegner ein Wesen ausspielt: Es wird eingefroren.", "Fallentext je Auslöser")
check(C("f_aschefalle").text().ends_with("3 Schaden an dieses Wesen."), "Aschefalle-Text") check(C("f_aschefalle").text().ends_with("3 Schaden an dieses Wesen."), "Aschefalle-Text")
# Böenfalle: Angreifer zurück auf die Hand, Angriff verpufft # Böenfalle: Angreifer zurück auf die Hand, Angriff verpufft
@@ -1144,7 +1148,11 @@ func test_neue_fallen() -> void:
func test_kapitel_zwei_und_merkmalwahl() -> void: func test_kapitel_zwei_und_merkmalwahl() -> void:
check(Campaign.total() == 20 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2, "zwanzig Stufen in zwei Kapiteln") check(Campaign.total() == 30 and Campaign.chapter(9) == 1 and Campaign.chapter(10) == 2 and Campaign.chapter(29) == 3, "dreißig Stufen in drei Kapiteln")
check(Campaign.stage(25).perks.size() == 3 and Campaign.stage(25).name == "Nyx" and Campaign.target_rating(29, 1.0) == 4.4, "Kapitel 3 mit drei Merkmalen und höherer Zielstärke")
for st in Campaign.STAGES3:
for pk in st.perks:
check(Game.PERKS.has(pk), "Merkmal %s bekannt" % pk)
for i in Campaign.total(): for i in Campaign.total():
var st := Campaign.stage(i) var st := Campaign.stage(i)
check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1)) check(st.has("perks") and st.perks.all(func(k): return Game.PERKS.has(k)), "Stufe %d hat gültige Merkmale" % (i + 1))
@@ -1362,6 +1370,73 @@ func test_ki_zeitbudget() -> void:
check(v.settings.has("big_font") and not v.tutorial2_done and v.records.margins is Array, "Version 5 mit neuen Feldern") check(v.settings.has("big_font") and not v.tutorial2_done and v.records.margins is Array, "Version 5 mit neuen Feldern")
## Elementbooster enthalten nur Karten des Elements; Deck-Codes sind umkehrbar und robust.
## Englisch: jede Karte hat einen englischen Namen, Kartentexte und Schlüsselwörter werden übersetzt.
func test_englische_karten() -> void:
check(Cards.all().all(func(c): return c.name_en != ""), "alle Karten mit englischem Namen")
var de: String = Cards.by_id("f_funke").text()
TranslationServer.set_locale("en")
var funke := Cards.by_id("f_funke")
var falle := Cards.by_id("w_falle")
var wall := Cards.by_id("e_wall")
check(funke.title() == "Spark" and funke.text() == "2 damage to a target.", "Funke auf Englisch: %s / %s" % [funke.title(), funke.text()])
check(falle.text().begins_with("Trap. When an enemy creature attacks") and "The attack fizzles" in falle.text(), "Fallentext auf Englisch: %s" % falle.text())
check(wall.text() == "Guardian." and Cards.by_id("l_spiegel").text().ends_with("Summon a Wind Spirit."), "Schlüsselwort und Beschwörung auf Englisch")
TranslationServer.set_locale("de")
check(funke.title() == "Funke" and funke.text() == de, "zurück auf Deutsch")
func test_elementbooster_und_deckcode() -> void:
var p := Profile.starter()
p.coins = 500
var r := RandomNumberGenerator.new()
r.seed = 5
var got: Array = p.open_booster(r, "Wasser")
check(got.size() == 5 and got.all(func(c): return "Wasser" in c.elements) and p.coins == 500 - Profile.ELEMENT_BOOSTER_COST, "Wasser-Booster: fünf Wasserkarten, %d Münzen" % Profile.ELEMENT_BOOSTER_COST)
p.coins = 100
check(p.open_booster(r, "Feuer").is_empty() and p.coins == 100, "Elementbooster braucht %d Münzen" % Profile.ELEMENT_BOOSTER_COST)
for e in Cards.ELEMENTS:
for rar in Profile.RARITY_WEIGHTS:
check(not Profile.booster_pool(rar, e).is_empty(), "%s hat %s Karten" % [e, rar])
var deck: Array = Profile.starter().deck
var code := Profile.deck_code(deck)
check(code.begins_with(Profile.CODE_PREFIX) and Profile.parse_deck_code(code).size() == 20, "Deck-Code hin und zurück")
var back: Array = Profile.parse_deck_code(code)
back.sort()
var orig: Array = deck.duplicate()
orig.sort()
check(back == orig, "Deck-Code enthält genau die Karten")
check(Profile.parse_deck_code("kaputt").is_empty() and Profile.parse_deck_code("DINGER1:x_nix*2").is_empty()
and Profile.parse_deck_code("DINGER1:f_funke*9").size() == Game.MAX_COPIES, "ungültige Codes leer, Kopien gedeckelt")
check(Cards.by_id("w_flutfalle").is_trap() and Cards.by_id("fl_sturmfalke").combo() == "Blitz" and Cards.by_id("el_sandsturm").is_combo(), "neue Karten im Pool")
## Voller Spielstand (viewer -1) stellt Hände, Decks und Bretter wieder her; Zuschauer (-2) sehen nichts Verborgenes.
func test_kampf_speichern() -> void:
var r := RandomNumberGenerator.new()
r.seed = 7
var g := Game.new_battle(Profile.starter().deck_cards(), 2, r)
g.start()
AI.take_turn(g, g.current())
var d: Dictionary = JSON.parse_string(JSON.stringify(g.snapshot(-1)))
var g2 := Game.from_snapshot(d)
var same := g2.players.size() == g.players.size()
for i in g.players.size():
var a: Game.Player = g.players[i]
var b: Game.Player = g2.players[i]
var ids := func(cs: Array): return cs.map(func(c): return c.id)
same = same and ids.call(a.hand) == ids.call(b.hand) and ids.call(a.deck) == ids.call(b.deck) \
and a.life == b.life and a.board.size() == b.board.size() and a.is_human == b.is_human
check(same and g2.current_idx == g.current_idx and g2.turn == g.turn and g2.human.is_human, "voller Spielstand: Hände, Decks und Bretter erhalten")
var guard := 0
while g2.winner == "" and guard < 300:
AI.take_turn(g2, g2.current())
guard += 1
check(g2.winner != "", "fortgesetzte Partie ist bis zum Ende spielbar")
var s2: Dictionary = g.snapshot(-2)
check(s2.players.all(func(p): return not (p.hand is Array) and not (p.traps is Array) and not (p.deck is Array)), "Zuschauer-Sicht ohne Hände, Decks und Fallen")
## Jeder tr("...")-Text in der Oberfläche und jeder Anleitungsschritt hat einen englischen Eintrag. ## Jeder tr("...")-Text in der Oberfläche und jeder Anleitungsschritt hat einen englischen Eintrag.
func test_uebersetzung_vollstaendig() -> void: func test_uebersetzung_vollstaendig() -> void:
var keys := {} var keys := {}
@@ -1376,6 +1451,9 @@ func test_uebersetzung_vollstaendig() -> void:
key = line.substr(0, line.find(",")) key = line.substr(0, line.find(","))
keys[key] = true keys[key] = true
var missing := [] var missing := []
for k in Cards.COMBOS.values() + Cards.ELEMENTS + Cards.KEYWORDS + Cards.RARITIES + ["Wesen", "Zauber", "Falle"] + Cards.TRIGGER_TEXT.values():
if not keys.has(k):
missing.append(k)
var d := DirAccess.open("res://scripts/ui") var d := DirAccess.open("res://scripts/ui")
d.list_dir_begin() d.list_dir_begin()
var name := d.get_next() var name := d.get_next()
@@ -1389,6 +1467,11 @@ func test_uebersetzung_vollstaendig() -> void:
if not keys.has(k) and not (k in missing): if not keys.has(k) and not (k in missing):
missing.append(k) missing.append(k)
name = d.get_next() name = d.get_next()
var rx_cards := RegEx.new()
rx_cards.compile("tr\\(\"([^\"]+)\"\\)")
for m in rx_cards.search_all(FileAccess.get_file_as_string("res://scripts/cards.gd")):
if not keys.has(m.get_string(1)) and not (m.get_string(1) in missing):
missing.append(m.get_string(1))
for stepset in [BattleTutorial.STEPS1, BattleTutorial.STEPS2]: for stepset in [BattleTutorial.STEPS1, BattleTutorial.STEPS2]:
for st in stepset: for st in stepset:
if not keys.has(st[0]): if not keys.has(st[0]):

View File

@@ -157,16 +157,22 @@ func _ready() -> void:
for suffix in ["", ".bak", ".corrupt"]: for suffix in ["", ".bak", ".corrupt"]:
if FileAccess.file_exists(SAVE + suffix): if FileAccess.file_exists(SAVE + suffix):
DirAccess.remove_absolute(abs + suffix) DirAccess.remove_absolute(abs + suffix)
if FileAccess.file_exists(SAVE.get_basename() + "_kampf.json"):
DirAccess.remove_absolute(abs.get_basename() + "_kampf.json")
main = load("res://scenes/main.tscn").instantiate() main = load("res://scenes/main.tscn").instantiate()
main.save_path = SAVE main.save_path = SAVE
add_child(main) add_child(main)
await frames(3) await frames(3)
var fast := OS.get_environment("DINGER_FAST") != "" var fast := OS.get_environment("DINGER_FAST") != ""
var suite := [test_menu_und_regeln, test_gamepad_menue, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen, var suite := [test_menu_und_regeln, test_gamepad_menue, test_booster, test_deckbau, test_gegnerauswahl, test_einstellungen,
test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_anleitung2, test_herausforderung] test_erfolge, test_kampagne, test_werkstatt, test_sprache, test_anleitung, test_anleitung2, test_herausforderung,
test_kampf_fortsetzen, test_gamepad_deckbau, test_deckcode, test_elementbooster]
if not fast: if not fast:
suite += [test_hotseat, test_netzwerk, test_kampf.bind(2, 3), test_kampf_abbrechen] suite += [test_hotseat, test_netzwerk, test_host_wechsel, test_zuschauer, test_kampf.bind(2, 3), test_kampf_abbrechen]
suite.append(test_profil_zuruecksetzen) suite.append(test_profil_zuruecksetzen)
var only := OS.get_environment("DINGER_ONLY") # z. B. DINGER_ONLY=test_host_wechsel,test_zuschauer
if only != "":
suite = suite.filter(func(t: Callable): return t.get_method() in only.split(","))
for t in suite: for t in suite:
await ensure_menu() await ensure_menu()
await t.call() await t.call()
@@ -443,6 +449,97 @@ func give_up() -> void:
check(screen() is MenuScreen, "Aufgeben führt ins Menü") check(screen() is MenuScreen, "Aufgeben führt ins Menü")
## Gamepad im Deckbau: Karten und Deckzeilen sind fokussierbar, A wirkt wie ein Klick, B geht zurück.
func test_gamepad_deckbau() -> void:
await click(find_button("Deck bauen"))
var scr = screen()
var before: int = scr.deck.size()
await pad(JOY_BUTTON_DPAD_DOWN)
check(get_viewport().gui_get_focus_owner() != null and main.pad_mode, "Gamepad setzt Fokus im Deckbau")
var row: Control = scr.deck_list.get_child(0)
row.grab_focus()
await frames(1)
check(row.has_focus() and scr.preview.visible, "Deckzeile fokussierbar, Vorschau folgt")
await pad(JOY_BUTTON_A)
check(scr.deck.size() == before - 1, "A entfernt die fokussierte Karte aus dem Deck")
var elems: Array = Game.deck_elements(scr.deck_cards())
var target: CardView = null
for v in scr.grid.views():
if not v.dim and v.card.elements.all(func(e): return e in elems):
target = v
break
target.grab_focus()
await frames(1)
check(target.has_focus() and scr.preview.visible and scr.preview.card == target.card, "Karte im Raster fokussierbar mit Vorschau")
var target_id: String = target.card.id
await pad(JOY_BUTTON_A) # das Raster wird danach neu aufgebaut, target ist dann freigegeben
check(scr.deck.size() == before and target_id in scr.deck, "A nimmt die fokussierte Karte ins Deck (%d Karten, Meldung '%s')" % [scr.deck.size(), scr.msg_label.text])
await shot("31_gamepad_deckbau")
await pad(JOY_BUTTON_B)
check(scr.confirm.visible, "B fragt bei ungespeicherten Änderungen nach")
await click(find_button("Verwerfen"))
check(screen() is MenuScreen, "Verwerfen führt ins Menü")
await hover(main)
## Deck-Code kopieren, einfügen und übernehmen.
func test_deckcode() -> void:
await click(find_button("Deck bauen"))
var scr = screen()
await click(find_button("Code"))
check(scr.code_dialog.visible and scr.code_box.text.begins_with(Profile.CODE_PREFIX), "Code-Dialog zeigt den Deck-Code")
await click(find_button("In Zwischenablage"))
check(DisplayServer.clipboard_get() == scr.code_box.text, "Code in der Zwischenablage")
await shot("32_deckcode")
scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,x_nix*1"
await click(find_button("Übernehmen"))
check(scr.deck.size() == Game.DECK_SIZE and "Ungültiger Code" in scr.msg_label.text, "ungültiger Code wird abgelehnt")
scr.code_box.text = "DINGER1:f_funke*2,e_wall*1,fff_sonne*1"
await click(find_button("Übernehmen"))
check(scr.deck == ["f_funke", "f_funke", "e_wall"] and "Kern der Sonne" in scr.msg_label.text and not scr.code_dialog.visible, "Code übernommen, fehlende Karte gemeldet")
await click(find_button("Zurück"))
await click(find_button("Verwerfen"))
check(screen() is MenuScreen, "Deckbau verlassen")
## Elementbooster liefert nur Karten des Elements.
func test_elementbooster() -> void:
main.profile.coins = 300
await click(find_button("Booster öffnen"))
var scr = screen()
await click(find_button("Feuer"))
check(scr.cards.size() == 5 and scr.cards.all(func(c): return "Feuer" in c.elements) and main.profile.coins == 300 - Profile.ELEMENT_BOOSTER_COST, "Feuer-Booster: fünf Feuerkarten")
await click(find_button("Alle aufdecken"))
await shot("33_elementbooster")
await click(find_button("Zurück"))
## Kampf speichern, im Menü fortsetzen, Aufgeben löscht die Datei.
func test_kampf_fortsetzen() -> void:
await start_battle(1)
var scr = screen()
scr.ai_delay = 0.0
await wait_for_human(scr)
var kampf: String = main.battle_save_path()
check(FileAccess.file_exists(kampf), "Kampf wird laufend gespeichert")
await click(scr.b_end)
await wait_for_human(scr)
var turn: int = scr.game.turn
var life: int = scr.game.human.life
var hand: int = scr.game.human.hand.size()
await click(find_button("Aufgeben"))
await click(find_button("Speichern und zurück"))
check(screen() is MenuScreen and find_button("Kampf fortsetzen") != null, "Speichern führt ins Menü mit Fortsetzen-Knopf")
await click(find_button("Kampf fortsetzen"))
var scr2 = screen()
check(scr2 is BattleScreen and scr2.game.turn == turn and scr2.game.human.life == life and scr2.game.human.hand.size() == hand
and scr2.game.current() == scr2.game.human, "Kampf fortgesetzt mit gleichem Zug, Leben und Hand")
scr2.ai_delay = 0.0
await shot("30_kampf_fortgesetzt")
await give_up()
check(not FileAccess.file_exists(kampf) and find_button("Kampf fortsetzen") == null, "Aufgeben löscht den gespeicherten Kampf")
func test_einstellungen() -> void: func test_einstellungen() -> void:
await click(find_button("Einstellungen")) await click(find_button("Einstellungen"))
var scr = screen() var scr = screen()
@@ -684,7 +781,7 @@ func test_anleitung() -> void:
await shot("22_anleitung_mulligan") await shot("22_anleitung_mulligan")
await click(scr.b_swap) await click(scr.b_swap)
check(not scr.mulligan_box.visible and g.human.mulligan_done, "getauscht") check(not scr.mulligan_box.visible and g.human.mulligan_done, "getauscht")
check(g.log.any(func(l): return "tauscht 1 Karten" in l), "Tausch im Verlauf") check(g.log.any(func(l): return "tauscht 1 Karte" in l), "Tausch im Verlauf")
check(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar") check(scr.tutorial_step == 0 and "Energie" in scr.tutorial_text.text, "Schritt 1 sichtbar")
var idx := -1 var idx := -1
for i in g.human.hand.size(): for i in g.human.hand.size():
@@ -738,7 +835,7 @@ func test_anleitung() -> void:
check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug") check(scr.tutorial_step >= 2 or idx < 0, "Schritt 3 im zweiten Zug")
var step_before: int = scr.tutorial_step var step_before: int = scr.tutorial_step
await click(scr.b_tut_next) await click(scr.b_tut_next)
check(scr.tutorial_step == step_before + 1, "Weiter-Knopf") check(scr.tutorial_step >= step_before + 1, "Weiter-Knopf (Schritt %d)" % step_before)
await key(KEY_ESCAPE) await key(KEY_ESCAPE)
check(scr.confirm.visible, "Esc öffnet Aufgeben") check(scr.confirm.visible, "Esc öffnet Aufgeben")
await key(KEY_ESCAPE) await key(KEY_ESCAPE)
@@ -834,6 +931,117 @@ func test_netzwerk() -> void:
check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü") check(screen() is MenuScreen and not main.net.connected, "Netzwerk getrennt, zurück im Menü")
func _bot_args(extra: Array) -> Array:
return ["--headless", "--path", ProjectSettings.globalize_path("res://"), "tests/net_bot.tscn", "--"] + extra
func _bot_quit(on: bool) -> void:
var path := "user://bot_quit"
if on:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("x")
f.close()
elif FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## Verbindet sich mit dem Bot-Host (mehrere Versuche, bis er lauscht).
func _join_bot_host(watch: bool) -> void:
await click(find_button("Netzwerkspiel"))
var lobby = screen()
lobby.ip_box.text = "127.0.0.1"
for attempt in 8:
await click(find_button("Zuschauen" if watch else "Beitreten"))
var w := 0
while not main.net.connected and w < 180:
await frames(1)
w += 1
if main.net.connected:
return
main.net.leave()
await frames(30)
## Der Host (ein Bot) verschwindet: der einzige Mitspieler übernimmt und spielt zu Ende.
func test_host_wechsel() -> void:
_bot_quit(false)
var pid := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
check(pid > 0, "Bot-Host gestartet")
await frames(30)
await _join_bot_host(false)
var w := 0
while not (screen() is BattleScreen) and w < 900:
await frames(1)
w += 1
var b = screen()
check(b is BattleScreen and b.is_client(), "als Mitspieler beim Bot-Host im Kampf")
w = 0
while (b.game == null or main.net.backup.is_empty()) and w < 600:
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")
main.net.rejoin_seconds = 3.0
_bot_quit(true)
w = 0
while not main.net.is_host and w < 900:
await frames(1)
w += 1
check(main.net.is_host and b.game == main.net.game and screen() == b and b.game.log.any(func(l): return "übernimmt als Host" in l),
"Host-Wechsel: Mitspieler führt das Spiel weiter")
check(main.net.waiting.has("BotHost"), "Platz des alten Hosts bleibt zunächst frei")
await shot("34_host_wechsel")
# Der alte Host kehrt nicht zurück: ein Zug wird übersprungen, nach der Frist scheidet er aus
b.ai_delay = 0.0
await clear_mulligan(b)
if b.game.current() == b.perspective:
await click(b.b_end)
w = 0
while b.game.winner == "" and w < 900:
await frames(1)
w += 1
check(b.game.log.any(func(l): return "übersprungen" in l), "Zug des fehlenden Hosts übersprungen")
check(b.game.winner != "" and b.game.winner_player == b.perspective and b.game.log.any(func(l): return "kehrt nicht zurück" in l),
"nach der Frist scheidet der alte Host aus, Partie gewonnen (Sieger '%s', Zug %d)" % [b.game.winner, b.game.turn])
await leave_battle()
_bot_quit(false)
check(screen() is MenuScreen and not main.net.connected, "nach dem Host-Wechsel zurück im Menü")
## Zuschauer: sieht die Partie zweier Bots ohne Hände und ohne eigene Züge.
func test_zuschauer() -> void:
_bot_quit(false)
var pid_h := OS.create_process(OS.get_executable_path(), _bot_args(["--host", "--name=BotHost"]))
await frames(30)
var pid_c := OS.create_process(OS.get_executable_path(), _bot_args(["--ip=127.0.0.1", "--name=Bot"]))
check(pid_h > 0 and pid_c > 0, "zwei Bots gestartet")
await _join_bot_host(true)
var w := 0
while not (screen() is BattleScreen) and w < 1200:
await frames(1)
w += 1
var b = screen()
check(b is BattleScreen and b.spectating(), "als Zuschauer im Kampf")
w = 0
while (b.game == null or b.game.turn < 2) and w < 900:
await frames(1)
w += 1
check(b.game != null and b.game.turn >= 2 and b.hand_views.is_empty() and not b.mulligan_box.visible and not b.b_end.visible,
"Zuschauer sieht die Partie ohne Hände und ohne Züge (Zug %d)" % (b.game.turn if b.game != null else 0))
await shot("35_zuschauer")
check(not b.handover.visible and not b.is_hotseat(), "Zuschauer bekommt keine Hotseat-Übergabe")
if screen() is BattleScreen: # die Bots spielen schnell, die Partie kann schon vorbei sein
await leave_battle()
else:
var w2 := 0
while not (screen() is MenuScreen) and w2 < 300:
await frames(1)
w2 += 1
check(screen() is MenuScreen and not main.net.connected, "Zuschauer zurück im Menü")
_bot_quit(true)
await frames(60)
_bot_quit(false)
func test_kampagne() -> void: func test_kampagne() -> void:
await click(find_button("Kampagne")) await click(find_button("Kampagne"))
var scr = screen() var scr = screen()

View File

@@ -135,3 +135,85 @@ Duell,Duel
Stärke %.1f · %d Münzen,Strength %.1f · %d coins Stärke %.1f · %d Münzen,Strength %.1f · %d coins
Abbrechen,Cancel Abbrechen,Cancel
"Ja, zurücksetzen","Yes, reset" "Ja, zurücksetzen","Yes, reset"
"Speichern und zurück","Save and go back"
"Kampf fortsetzen","Resume battle"
Aufgeben beendet den Kampf ohne Münzen.,Surrendering ends the battle without coins.
Speichern hebt ihn für später auf.,Saving keeps it for later.
%s Schaden an den Angreifer,%s damage to the attacker
%s Schaden an dieses Wesen,%s damage to that creature
Der Angreifer wird eingefroren,The attacker is frozen
Es wird eingefroren,It is frozen
Es geht zurück auf die Hand,It returns to its owner's hand
Der Angriff verpufft,The attack fizzles
Beschwöre einen %s,Summon a %s
%s Schaden an ein gegnerisches Wesen,%s damage to an enemy creature
%s Schaden an ein Ziel,%s damage to a target
%s Schaden an alle gegnerischen Wesen,%s damage to all enemy creatures
%s Schaden an alle Gegner,%s damage to all opponents
%s Schaden an alle Gegner und ihre Wesen,%s damage to all opponents and their creatures
Heile dich um %s,Heal yourself for %s
Ziehe 1 Karte,Draw 1 card
Ziehe %s Karten,Draw %s cards
Friere ein gegnerisches Wesen ein,Freeze an enemy creature
Friere das stärkste gegnerische Wesen ein,Freeze the strongest enemy creature
Friere alle gegnerischen Wesen ein,Freeze all enemy creatures
Ein gegnerisches Wesen zurück auf die Hand,Return an enemy creature to its owner's hand
Alle gegnerischen Wesen zurück auf die Hand,Return all enemy creatures to their owners' hands
Ein befreundetes Wesen erhält %s,A friendly creature gets %s
Alle befreundeten Wesen erhalten %s,All friendly creatures get %s
Ein befreundetes Wesen erhält Schild,A friendly creature gets Shield
Erhält Schild,Gets Shield
Alle befreundeten Wesen erhalten Schild,All friendly creatures get Shield
Falle. %s: %s,Trap. %s: %s
Beim Ausspielen: %s,On play: %s
Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift,When an enemy creature attacks you or one of your creatures
Wenn ein Gegner ein Wesen ausspielt,When an opponent plays a creature
Wächter,Guardian
Flink,Swift
Schild,Shield
Doppelschlag,Double Strike
Wesen,Creature
Zauber,Spell
Falle,Trap
gewöhnlich,common
selten,rare
episch,epic
legendär,legendary
mythisch,mythic
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
Sonne,Sun
Ozean,Ocean
Gebirge,Mountains
Himmel,Sky
"Code übernommen, es fehlen: ","Code applied, missing: "
Elemente: ,Elements:
%s zerlegt (+%d Staub),%s dismantled (+%d dust)
%s hergestellt,%s crafted
%s ist jetzt im Deck %s,%s is now in deck %s
Deck-Code,Deck code
In Zwischenablage,To clipboard
Aus Zwischenablage,From clipboard
Code,Code
Ungültiger Code,Invalid code
Code übernommen,Code applied
Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen.,Copy to share or paste a code. Cards you do not own are skipped.
Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos,Element booster (%d coins): only cards of that element and its combos
"Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks.","Chapter 3: three perks, full tables and the strongest decks."
Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!,All three chapters completed. The Archmage is defeated for good!
Kapitel %d,Chapter %d
Zuschauen,Watch
Zuschauer,Spectator
"Zuschauer: Hände sind verdeckt","Spectator: hands are hidden"
1 keys en
135 Stärke %.1f · %d Münzen Strength %.1f · %d coins
136 Abbrechen Cancel
137 Ja, zurücksetzen Yes, reset
138 Speichern und zurück Save and go back
139 Kampf fortsetzen Resume battle
140 Aufgeben beendet den Kampf ohne Münzen. Surrendering ends the battle without coins.
141 Speichern hebt ihn für später auf. Saving keeps it for later.
142 %s Schaden an den Angreifer %s damage to the attacker
143 %s Schaden an dieses Wesen %s damage to that creature
144 Der Angreifer wird eingefroren The attacker is frozen
145 Es wird eingefroren It is frozen
146 Es geht zurück auf die Hand It returns to its owner's hand
147 Der Angriff verpufft The attack fizzles
148 Beschwöre einen %s Summon a %s
149 %s Schaden an ein gegnerisches Wesen %s damage to an enemy creature
150 %s Schaden an ein Ziel %s damage to a target
151 %s Schaden an alle gegnerischen Wesen %s damage to all enemy creatures
152 %s Schaden an alle Gegner %s damage to all opponents
153 %s Schaden an alle Gegner und ihre Wesen %s damage to all opponents and their creatures
154 Heile dich um %s Heal yourself for %s
155 Ziehe 1 Karte Draw 1 card
156 Ziehe %s Karten Draw %s cards
157 Friere ein gegnerisches Wesen ein Freeze an enemy creature
158 Friere das stärkste gegnerische Wesen ein Freeze the strongest enemy creature
159 Friere alle gegnerischen Wesen ein Freeze all enemy creatures
160 Ein gegnerisches Wesen zurück auf die Hand Return an enemy creature to its owner's hand
161 Alle gegnerischen Wesen zurück auf die Hand Return all enemy creatures to their owners' hands
162 Ein befreundetes Wesen erhält %s A friendly creature gets %s
163 Alle befreundeten Wesen erhalten %s All friendly creatures get %s
164 Ein befreundetes Wesen erhält Schild A friendly creature gets Shield
165 Erhält Schild Gets Shield
166 Alle befreundeten Wesen erhalten Schild All friendly creatures get Shield
167 Falle. %s: %s Trap. %s: %s
168 Beim Ausspielen: %s On play: %s
169 Wenn ein gegnerisches Wesen dich oder eines deiner Wesen angreift When an enemy creature attacks you or one of your creatures
170 Wenn ein Gegner ein Wesen ausspielt When an opponent plays a creature
171 Wächter Guardian
172 Flink Swift
173 Schild Shield
174 Doppelschlag Double Strike
175 Wesen Creature
176 Zauber Spell
177 Falle Trap
178 gewöhnlich common
179 selten rare
180 episch epic
181 legendär legendary
182 mythisch mythic
183 Feuer Fire
184 Wasser Water
185 Erde Earth
186 Luft Air
187 Inferno Inferno
188 Tiefsee Deep Sea
189 Berg Mountain
190 Orkan Hurricane
191 Dampf Steam
192 Lava Lava
193 Blitz Lightning
194 Schlamm Mud
195 Sturm Storm
196 Sand Sand
197 Sonne Sun
198 Ozean Ocean
199 Gebirge Mountains
200 Himmel Sky
201 Code übernommen, es fehlen: Code applied, missing:
202 Elemente: Elements:
203 %s zerlegt (+%d Staub) %s dismantled (+%d dust)
204 %s hergestellt %s crafted
205 %s ist jetzt im Deck %s %s is now in deck %s
206 Deck-Code Deck code
207 In Zwischenablage To clipboard
208 Aus Zwischenablage From clipboard
209 Code Code
210 Ungültiger Code Invalid code
211 Code übernommen Code applied
212 Zum Teilen kopieren oder einen Code einfügen. Nicht vorhandene Karten werden weggelassen. Copy to share or paste a code. Cards you do not own are skipped.
213 Elementbooster (%d Münzen): nur Karten des Elements und seiner Kombos Element booster (%d coins): only cards of that element and its combos
214 Kapitel 3: drei Merkmale, volle Tische und die stärksten Decks. Chapter 3: three perks, full tables and the strongest decks.
215 Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt! All three chapters completed. The Archmage is defeated for good!
216 Kapitel %d Chapter %d
217 Zuschauen Watch
218 Zuschauer Spectator
219 Zuschauer: Hände sind verdeckt Spectator: hands are hidden

Binary file not shown.