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

@@ -30,13 +30,16 @@ static func board_value(p: Game.Player) -> float:
return v
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv,
## die der Gegner negativ. Ein besiegter Gegner ist viel wert.
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, die der Gegner
## negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden bremsen).
## Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders.
static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive():
return -10000.0
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
var alive_enemies := 0
var threat := 0.0
var leader := 0.0
for e in g.players:
if e == p:
continue
@@ -44,11 +47,28 @@ static func evaluate(g: Game, p: Game.Player) -> float:
score += 40.0
continue
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:
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:
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

View File

@@ -1,5 +1,5 @@
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 := [
{"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.
const MIN_RATING := [1.2, 1.3, 1.5, 1.6, 1.8, 1.9, 2.1, 2.2, 2.4, 2.6]
const MIN_RATING2 := [2.2, 2.3, 2.5, 2.6, 2.8, 2.9, 3.0, 3.1, 3.2, 3.4]
const MIN_RATING3 := [3.2, 3.3, 3.5, 3.6, 3.8, 3.9, 4.0, 4.1, 4.2, 4.4]
const MIN_RATINGS := [MIN_RATING, MIN_RATING2, MIN_RATING3]
static func target_rating(stage_index: int, player_rating: float) -> float:
var base: float = MIN_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)
static func total() -> int:
return STAGES.size() + STAGES2.size()
return STAGES.size() * CHAPTERS.size()
static func chapter(stage: int) -> int:
return 1 if stage < STAGES.size() else 2
return stage / STAGES.size() + 1
## Stufe mit einheitlichem Feld "perks".
@@ -54,7 +72,7 @@ static func stage(i: int) -> Dictionary:
var st: Dictionary = STAGES[i].duplicate()
st["perks"] = [st.perk]
return st
return STAGES2[i - STAGES.size()]
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
## 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",
"Schlamm": "Heile dich um 3",
"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 RARITIES: Array[String] = ["gewöhnlich", "selten", "episch", "legendär", "mythisch"]
@@ -82,7 +82,7 @@ static func field_combo_effect(name: String) -> Effect:
"Sturm":
return Effect.new("draw", "none", 1)
"Sand":
return Effect.new("shield", "self_creature")
return Effect.new("buff", "self_creature", 1, 1)
return null
@@ -119,62 +119,73 @@ class Effect extends RefCounted:
static func from_dict(d: Dictionary) -> Effect:
return Effect.new(d.kind, d.get("target", "none"), int(d.get("amount", 0)), int(d.get("amount2", 0)), str(d.get("card_id", "")))
func text() -> String:
## Kartentext in der aktuellen Sprache. trigger: bei Fallen der Auslöser (Wortwahl).
func text(trigger: String = "") -> String:
var n := str(amount)
if target == "trigger_source":
var played := trigger == "creature_played"
match kind:
"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":
return "Der Angreifer wird eingefroren"
if played:
return tr("Es wird eingefroren")
return tr("Der Angreifer wird eingefroren")
"bounce":
return "Es geht zurück auf die Hand"
return tr("Es geht zurück auf die Hand")
match kind:
"cancel":
return "Der Angriff verpufft"
return tr("Der Angriff verpufft")
"summon":
return "Beschwöre einen " + Cards.by_id(card_id).name
return tr("Beschwöre einen %s") % Cards.by_id(card_id).title()
"damage":
match target:
"enemy_creature":
return n + " Schaden an ein gegnerisches Wesen"
return tr("%s Schaden an ein gegnerisches Wesen") % n
"enemy_any":
return n + " Schaden an ein Ziel"
return tr("%s Schaden an ein Ziel") % n
"all_enemy_creatures":
return n + " Schaden an alle gegnerischen Wesen"
return tr("%s Schaden an alle gegnerischen Wesen") % n
"all_enemy_heroes":
return n + " Schaden an alle Gegner"
return tr("%s Schaden an alle Gegner") % n
"all_enemies":
return n + " Schaden an alle Gegner und ihre Wesen"
return tr("%s Schaden an alle Gegner und ihre Wesen") % n
"heal":
return "Heile dich um " + n
return tr("Heile dich um %s") % n
"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":
if target == "enemy_creature":
return "Friere ein gegnerisches Wesen ein"
return "Friere alle gegnerischen Wesen ein"
return tr("Friere ein gegnerisches Wesen ein")
if target == "strongest_enemy_creature":
return tr("Friere das stärkste gegnerische Wesen ein")
return tr("Friere alle gegnerischen Wesen ein")
"bounce":
if target == "enemy_creature":
return "Ein gegnerisches Wesen zurück auf die Hand"
return "Alle gegnerischen Wesen zurück auf die Hand"
return tr("Ein gegnerisches Wesen zurück auf die Hand")
return tr("Alle gegnerischen Wesen zurück auf die Hand")
"buff":
var b := "+%d/+%d" % [amount, amount2]
if target == "friendly_creature":
return "Ein befreundetes Wesen erhält " + b
return "Alle befreundeten Wesen erhalten " + b
return tr("Ein befreundetes Wesen erhält %s") % b
return tr("Alle befreundeten Wesen erhalten %s") % b
"shield":
if target == "friendly_creature":
return "Ein befreundetes Wesen erhält Schild"
return tr("Ein befreundetes Wesen erhält Schild")
if target == "self_creature":
return "Erhält Schild"
return "Alle befreundeten Wesen erhalten Schild"
return tr("Erhält Schild")
return tr("Alle befreundeten Wesen erhalten Schild")
return ""
class Card extends RefCounted:
var id: String
var name: String
var name: String # deutscher Name (auch im Verlauf und in Tests)
var name_en := "" # englischer Name
var elements: Array
var cost: int
var rarity: String
@@ -192,7 +203,7 @@ class Card extends RefCounted:
return kind == "Falle"
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():
d["attack"] = attack
d["health"] = health
@@ -208,6 +219,7 @@ class Card extends RefCounted:
var c := Card.new()
c.id = d.id
c.name = d.name
c.name_en = str(d.get("name_en", ""))
c.elements = Array(d.elements)
c.cost = int(d.cost)
c.rarity = d.rarity
@@ -220,6 +232,12 @@ class Card extends RefCounted:
c.trigger = str(d.get("trigger", ""))
return c
## Anzeigename in der aktuellen Sprache.
func title() -> String:
if name_en != "" and TranslationServer.get_locale().begins_with("en"):
return name_en
return name
func is_combo() -> bool:
return elements.size() >= 2
@@ -238,19 +256,19 @@ class Card extends RefCounted:
var kws: Array[String] = []
for k in Cards.KEYWORDS:
if k in keywords:
kws.append(k)
kws.append(tr(k))
parts.append(", ".join(kws) + ".")
if not effects.is_empty():
var fx: Array[String] = []
for f in effects:
fx.append(f.text())
fx.append(f.text(trigger))
var s := ", ".join(fx) + "."
if is_trap():
if trigger == "creature_played":
s = s.replace("an den Angreifer", "an dieses Wesen").replace("Der Angreifer wird", "Es wird")
parts.append("Falle. %s: %s" % [Cards.TRIGGER_TEXT[trigger], s])
parts.append(tr("Falle. %s: %s") % [tr(Cards.TRIGGER_TEXT[trigger]), s])
elif is_creature():
parts.append(tr("Beim Ausspielen: %s") % s)
else:
parts.append(("Beim Ausspielen: " + s) if is_creature() else s)
parts.append(s)
return " ".join(parts)

View File

@@ -315,7 +315,7 @@ func mulligan(p: Player, cards: Array) -> String:
if n > 0:
shuffle(p.deck, rng)
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})
return ""

View File

@@ -8,6 +8,8 @@ signal started(seat: int)
signal snapshot_received(snap: Dictionary)
signal action_applied
signal ended(reason: String)
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
signal status_changed
const PORT := 24455
@@ -23,15 +25,25 @@ var my_seat := -1
var game: Game # Host
var status := ""
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
const REJOIN_SECONDS := 60.0
var rejoin_seconds := 60.0
var spectator := false # eigene Rolle: nur zuschauen
var spectators := {} # Host: peer_id -> Name
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
var i_am_successor := false
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
var reconnecting := false
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(func(): _fail("Verbindung fehlgeschlagen"))
multiplayer.server_disconnected.connect(func(): _fail("Host hat das Spiel beendet"))
multiplayer.connection_failed.connect(func():
if not reconnecting:
_fail("Verbindung fehlgeschlagen"))
multiplayer.server_disconnected.connect(_on_server_lost)
func host(port: int = PORT) -> Error:
@@ -45,6 +57,7 @@ func host(port: int = PORT) -> Error:
connected = true
names = {1: my_name}
decks = {1: my_deck}
spectators = {}
status = "Warte auf Mitspieler …"
lobby_changed.emit()
return OK
@@ -76,6 +89,13 @@ func leave() -> void:
my_seat = -1
game = null
status = ""
spectator = false
spectators = {}
successor_ip = ""
i_am_successor = false
backup = {}
backup_names = {}
last_winner = ""
func local_addresses() -> Array:
@@ -91,7 +111,114 @@ func _fail(reason: String) -> void:
func _on_connected() -> void:
connected = true
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:
@@ -104,14 +231,16 @@ func _on_peer_disconnected(id: int) -> void:
var pname: String = names.get(id, "")
names.erase(id)
decks.erase(id)
spectators.erase(id)
if game != null and seats.has(id):
var p: Game.Player = game.players[seats[id]]
if game.winner == "":
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(REJOIN_SECONDS)])
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)])
seats.erase(id)
_update_successor()
action_applied.emit()
lobby_update.rpc(names)
lobby_update.rpc(names, spectators)
lobby_changed.emit()
@@ -123,7 +252,7 @@ func tick() -> void:
for pname in waiting.keys():
var w: Dictionary = waiting[pname]
var p: Game.Player = game.players[w.seat]
if now - float(w.since) > REJOIN_SECONDS:
if now - float(w.since) > rejoin_seconds:
waiting.erase(pname)
game.say("%s kehrt nicht zurück und scheidet aus." % p.name)
game.damage(p, 999)
@@ -137,10 +266,18 @@ func tick() -> void:
@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:
return
var id := multiplayer.get_remote_sender_id()
if as_spectator:
spectators[id] = name
lobby_update.rpc(names, spectators)
lobby_changed.emit()
if game != null:
start_game.rpc_id(id, -1)
receive_snapshot.rpc_id(id, game.snapshot(-2))
return
if game != null:
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
if waiting.has(name):
@@ -150,19 +287,21 @@ func register(name: String, deck_ids: Array) -> void:
game.say("%s ist zurück." % name)
start_game.rpc_id(id, seats[id])
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
_update_successor()
action_applied.emit()
else:
status = "%s wollte beitreten, das Spiel läuft schon" % name
return
names[id] = name
decks[id] = deck_ids
lobby_update.rpc(names)
lobby_update.rpc(names, spectators)
lobby_changed.emit()
@rpc("authority", "reliable")
func lobby_update(n: Dictionary) -> void:
func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
names = n
spectators = sp
lobby_changed.emit()
@@ -194,6 +333,9 @@ func start_battle(n_ai: int) -> void:
for id in ids:
if id != 1:
start_game.rpc_id(id, seats[id])
for id in spectators:
start_game.rpc_id(id, -1)
_update_successor()
started.emit(my_seat)
@@ -203,17 +345,24 @@ func start_game(seat: int) -> void:
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:
if not is_host or game == null:
return
for id in seats:
if id != 1:
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
for id in spectators:
receive_snapshot.rpc_id(id, game.snapshot(-2))
var succ := _successor_id()
if succ > 0:
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
@rpc("authority", "reliable")
func receive_snapshot(snap: Dictionary) -> void:
last_winner = str(snap.get("winner", ""))
snapshot_received.emit(snap)

View File

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

View File

@@ -6,6 +6,8 @@ const SAVE_VERSION := 5
const PITY_BOOSTERS := 20 # danach ist eine mythische Karte garantiert
const SAVE_PATH := "user://spielstand.json"
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 RARITY_WEIGHTS := {"gewöhnlich": 70.0, "selten": 20.0, "episch": 7.5, "legendär": 2.0, "mythisch": 0.5}
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": "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": "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": "wochenheld", "name": "Wochenheld", "text": "Bestehe eine wöchentliche Herausforderung.", "reward": 100},
]
@@ -336,7 +339,15 @@ func dismantle_extras() -> int:
# --- 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 weights := PackedFloat32Array(RARITY_WEIGHTS.values())
var cards := []
@@ -345,23 +356,49 @@ static func roll_booster(r: RandomNumberGenerator) -> Array:
if i == BOOSTER_SIZE - 1 and rarity == "gewöhnlich" \
and cards.all(func(c): return c.rarity == "gewöhnlich"):
rarity = "selten" # mindestens eine seltene Karte pro Booster
var pool := Cards.of_rarity(rarity)
var pool := booster_pool(rarity, element)
cards.append(pool[r.randi_range(0, pool.size() - 1)])
return cards
## Öffnet einen Booster. Gibt die Karten zurück, leer wenn die Münzen nicht reichen.
func open_booster(r: RandomNumberGenerator = null) -> Array:
if coins < BOOSTER_COST:
## Deck-Code zum Teilen: "DINGER1:id*anzahl,…" (sortiert, daher vergleichbar).
static func deck_code(card_ids: Array) -> String:
var counts := {}
for cid in card_ids:
counts[cid] = counts.get(cid, 0) + 1
var keys := counts.keys()
keys.sort()
return CODE_PREFIX + ",".join(keys.map(func(k): return "%s*%d" % [k, counts[k]]))
## Karten-IDs aus einem Deck-Code; leer bei ungültigem Code.
static func parse_deck_code(code: String) -> Array:
var s := code.strip_edges()
if not s.begins_with(CODE_PREFIX):
return []
var out := []
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 []
if r == null:
r = Game.make_rng()
coins -= BOOSTER_COST
var cards := roll_booster(r)
coins -= cost
var cards := roll_booster(r, element)
booster_since_mythic += 1
var has_mythic: bool = cards.any(func(c): return c.rarity == "mythisch")
if booster_since_mythic >= PITY_BOOSTERS and not has_mythic:
var pool := Cards.of_rarity("mythisch")
var pool := booster_pool("mythisch", element)
cards[BOOSTER_SIZE - 1] = pool[r.randi_range(0, pool.size() - 1)]
has_mythic = true
if has_mythic:
@@ -478,6 +515,8 @@ func _achieved(id: String, ctx: Dictionary) -> bool:
"kampagnenheld":
return campaign_stage >= Campaign.STAGES.size()
"elementmeister":
return campaign_stage >= 2 * Campaign.STAGES.size()
"archimagier":
return campaign_stage >= Campaign.total()
"serie":
return records.best_streak >= 5

View File

@@ -63,7 +63,7 @@ func play_events(evs: Array, centers: Dictionary) -> void:
screen.preview_timer = 1.8
"mulligan":
if ev.count > 0:
popup("tauscht %d Karten" % ev.count, Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
popup(("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:

View File

@@ -9,7 +9,8 @@ var stage := -1 # Kampagnenstufe oder -1
var tutorial := false
var hotseat_decks: Array = []
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 game: Game
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 smart_ai := true # Vorausschau-KI (Leicht: einfache KI)
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_t := 0.0
@@ -80,6 +82,7 @@ var result_label: Label
var reward_label: Label
var notice_label: Label
var confirm: Control
var b_save_leave: Button
var handover: Control
var handover_label: Label
var mulligan: BattleMulligan
@@ -135,6 +138,7 @@ static func hotseat(decks: Array, n_ai: int) -> BattleScreen:
static func online(seat: int) -> BattleScreen:
var b := BattleScreen.new(0, -1)
b.net_play = true
b.online_seat = seat
return b
@@ -152,6 +156,15 @@ static func against(opps: Array) -> BattleScreen:
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:
var b := BattleScreen.new(int(ch.n_opp), -1)
b.challenge = ch
@@ -159,11 +172,16 @@ static func weekly(ch: Dictionary) -> BattleScreen:
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:
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:
@@ -184,6 +202,8 @@ func _ready() -> void:
if is_online():
net = app.net
net.ended.connect(_on_net_ended)
net.migrated.connect(_on_migrated)
net.status_changed.connect(_on_net_status)
if net.is_host:
game = net.game
net.action_applied.connect(after_action)
@@ -191,6 +211,8 @@ func _ready() -> void:
net.snapshot_received.connect(_on_snapshot)
wait_label.visible = true
return # der erste Zustand kommt vom Host
elif saved_game != null:
game = saved_game
elif not hotseat_decks.is_empty():
game = Game.new_hotseat(hotseat_decks, hotseat_ai)
elif tutorial:
@@ -227,7 +249,7 @@ func own_rules() -> Dictionary:
func _begin() -> void:
n_opp = game.players.size() - 1
if is_online():
perspective = game.players[online_seat]
perspective = game.players[maxi(online_seat, 0)]
else:
game.events.clear()
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:
net.broadcast()
game.events.clear()
b_save_leave.visible = resumable()
save_battle()
refresh()
@@ -304,7 +328,14 @@ func _build_static() -> void:
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"))
# 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)
handover = _overlay()
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)
## 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:
if is_online():
net.leave()
if resumable() and game != null and game.winner == "":
app.clear_battle_save() # aufgegeben
if tutorial:
if tutorial_part == 1:
app.profile.tutorial_done = true
@@ -398,7 +451,9 @@ func build_hero_boxes() -> void:
if not o.perks.is_empty():
title += " ★"
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,
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,
@@ -414,6 +469,8 @@ func build_hero_boxes() -> void:
# --- Ablauf und Eingabe ------------------------------------------------------------------
func my_turn() -> bool:
if spectating():
return false
return game != null and game.winner == "" and game.current() == perspective
@@ -500,7 +557,7 @@ func _process(delta: float) -> void:
handover.visible = true
hand_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()
return
if is_client():
@@ -558,6 +615,7 @@ func _finish() -> void:
app.profile.tutorial2_done = true
app.save()
app.sfx.play("win" if won else "lose")
app.clear_battle_save()
else:
var wp := game.winner_player
var mine: bool = wp != null and wp == perspective
@@ -704,7 +762,7 @@ func on_click(kind: String, obj) -> void:
selected = obj
say(tr("Wähle ein Angriffsziel"))
else:
say(tr("%s kann nicht angreifen") % obj.name)
say(tr("%s kann nicht angreifen") % obj.card.title())
after_action()
@@ -761,13 +819,13 @@ func combo_hints(card: Cards.Card) -> Array[String]:
var combos := {}
for other in perspective.board:
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 e2 in other.card.elements:
if e1 != e2:
var name := Cards.combo_name([e1, e2])
if not combos.has(name):
combos[name] = other.name
combos[name] = other.card.title()
if not partners.is_empty():
out.append("Resonanz +%d mit %s" % [mini(partners.size(), Game.RESONANCE_MAX), ", ".join(partners)])
for name in combos:
@@ -784,11 +842,28 @@ func _on_snapshot(snap: Dictionary) -> void:
if first:
_begin()
return
perspective = game.players[online_seat]
perspective = game.players[maxi(online_seat, 0)]
build_hero_boxes()
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:
if game == null or game.winner == "":
say(reason)
@@ -814,6 +889,7 @@ func after_action() -> void:
centers[ref_key(ev[key])] = target_center(ev[key])
refresh()
fx.play_events(evs, centers)
save_battle()
func _note_events(evs: Array) -> void:
@@ -962,21 +1038,24 @@ func refresh() -> void:
trap_views.append(tv)
Style.clear(hand_layer)
for i in h.hand.size():
var card: Cards.Card = h.hand[i]
var playable: bool = mine and g.can_play(h, card)[0]
var r := BattleLayout.hand_rect(h.hand.size(), i)
var v := CardView.new(card, r.size)
v.position = r.position
if card == pending:
v.border = Style.YELLOW
elif playable:
v.border = Style.GREEN
v.dim = not playable
v.hover.connect(show_hand_preview)
v.clicked.connect(func(_view): on_click("hand", card))
hand_layer.add_child(v)
hand_views.append(v)
if spectating():
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(0, 690, 1280, 30), "center")
else:
for i in h.hand.size():
var card: Cards.Card = h.hand[i]
var playable: bool = mine and g.can_play(h, card)[0]
var r := BattleLayout.hand_rect(h.hand.size(), i)
var v := CardView.new(card, r.size)
v.position = r.position
if card == pending:
v.border = Style.YELLOW
elif playable:
v.border = Style.GREEN
v.dim = not playable
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
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]
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])
if spectating():
status_label.text = tr("Zuschauer") + " · " + status_label.text
b_end.disabled = not mine
var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client()
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
var lines: Array[String] = []
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
## 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.
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:
g.human.hand.append(Cards.by_id("w_falle"))
g.human.hand.append(Cards.by_id("w_reiter"))

View File

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

View File

@@ -8,7 +8,7 @@ var perk_box: OptionButton
func _ready() -> void:
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()
@@ -19,9 +19,9 @@ func build() -> void:
Style.clabel(col, "Kampagne", 34, Style.GOLD, "center", true)
var tabs := Style.row(col, 10)
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)
var b2 := Style.cbutton(tabs, "Kapitel 2", Vector2(160, 36), func(): set_chapter(2), Color("285a3c") if chapter == 2 else null)
b2.disabled = p.campaign_stage < Campaign.STAGES.size()
for c in Campaign.CHAPTERS.size():
var b := Style.cbutton(tabs, "Kapitel %d" % (c + 1), Vector2(140, 36), set_chapter.bind(c + 1), Color("285a3c") if chapter == c + 1 else null)
b.disabled = p.campaign_stage < c * Campaign.STAGES.size()
if p.perk_unlocked():
var pr := Style.row(tabs, 8)
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]
app.save())
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
else "Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.", 14, Style.MUTED)
var offset := 0 if chapter == 1 else Campaign.STAGES.size()
var intro := ["Zehn Magier je Kapitel. Jede Stufe bringt mehr Gegner an den Tisch. Bosse haben Merkmale (★).",
"Die Magier kehren mit zwei Merkmalen und größerem Gefolge zurück.",
"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():
var i := offset + k
var st := Campaign.stage(i)
@@ -76,7 +78,7 @@ func build() -> void:
var b := Style.cbutton(r, "Gesperrt", Vector2(130, 36), func(): pass)
b.disabled = true
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)
br.alignment = BoxContainer.ALIGNMENT_CENTER
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
size = rect.size
horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
follow_focus = true # Gamepad: der fokussierten Karte hinterherscrollen
grid = GridContainer.new()
grid.columns = COLS
grid.add_theme_constant_override("h_separation", GAP)
@@ -31,7 +32,7 @@ func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
var cell := Control.new()
cell.custom_minimum_size = Vector2(CW, CH + 16)
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.clicked.connect(func(_view): card_clicked.emit(c))
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
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
custom_minimum_size = sz
size = sz
mouse_filter = Control.MOUSE_FILTER_STOP
mouse_entered.connect(func(): hover.emit(card, true))
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:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit(self)
elif has_focus() and event.is_action_pressed("ui_accept"):
accept_event()
clicked.emit(self)
func _draw() -> void:
@@ -41,10 +53,11 @@ func _draw() -> void:
draw_circle(cc, r, Color("14141c"))
Style.text(self, str(card.cost), f_stat, Style.GOLD, cc, "center", true)
# Name und Kombo
var nm := Style.fit(card.name, true, f_name, size.x - 2 * r - 12)
Style.text(self, nm, f_name, Style.TEXT, Vector2(size.x / 2 + r / 2.0, 4), "midtop", true)
var name_left := 2 * r + 10
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:
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")
# Textfeld: Höhe folgt dem umbrochenen Text und der Schriftgröße, das Bild bekommt den Rest
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)
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))
var max_lines := maxi(1, int((box.size.y - 6) / Style.FONT.get_height(tsize))) # große Schrift: lieber kürzen als überlaufen
draw_multiline_string(Style.FONT, Vector2(box.position.x + 4, box.position.y + 4 + Style.FONT.get_ascent(tsize)),
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, -1, Style.TEXT)
card.text(), HORIZONTAL_ALIGNMENT_LEFT, box.size.x - 8, tsize, max_lines, Style.TEXT)
# Werte
if card.is_creature():
Style.text(self, str(card.attack), f_stat, Style.YELLOW, Vector2(10, size.y - 6), "bottomleft", true)
Style.text(self, str(card.health), f_stat, Style.RED, Vector2(size.x - 10, size.y - 6), "bottomright", true)
if dim:
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()
return
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)
var badges: Array[String] = []
if creature.taunt():

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ var b_hotseat: Button
var b_online: Button
var b_tutorial: Button
var b_challenge: Button
var b_resume: Button
var coins_label: Label
var coll_label: Label
var deck_label: Label
@@ -36,6 +37,8 @@ func _ready() -> void:
var sz := Vector2(270, 44)
var p0: Profile = app.profile
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,
func(): app.show_screen(BattleScreen.guided(2 if part2 else 1)),
Color("5a4628") if not (p0.tutorial_done and p0.tutorial2_done) else null)
@@ -58,6 +61,17 @@ func _ready() -> void:
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:
var p: Profile = app.profile
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") % [
p.distinct_owned(), Cards.all().size(), total, p.campaign_stage, Campaign.total()]
var elems := Game.deck_elements(p.deck_cards())
deck_label.text = tr("Aktives Deck: %s (%s)") % [p.deck_name(), ", ".join(elems) 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()
if p.daily.done:
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]
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].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)
var br := Style.row(col, 16)
br.alignment = BoxContainer.ALIGNMENT_CENTER

View File

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

View File

@@ -285,7 +285,7 @@ static func rules_text() -> String:
Cards.RESONANCE_TEXT,
"Feldkombo: Spielst du ein Wesen neben ein eigenes Wesen mit anderem Element, löst eine Kombo aus (eine je Ausspielen):",
" Dampf friert das stärkste gegnerische Wesen, Lava macht 1 Schaden an 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:",
]
@@ -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("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("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("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("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("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.")

View File

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