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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,24 +46,16 @@ func play_events(evs: Array, centers: Dictionary) -> void:
popup("Feldkombo %s!" % ev.name, Style.GOLD, where + Vector2(0, -30), 0.1)
"play":
sfx("play")
"spell":
sfx("play")
var caster: Game.Player = g.players[ev.player]
popup(ev.card.name, Style.GOLD, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
if caster != screen.perspective:
screen.show_preview(ev.card, true)
screen.preview_timer = 1.6
"trap_set":
sfx("click")
popup("Falle gelegt", Style.MUTED, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
"trap":
"fuse":
sfx("combo")
popup("Falle: %s!" % ev.card.name, Style.ORANGE, screen.target_center({"kind": "player", "index": ev.player}) + Vector2(0, -20), 0.0)
screen.show_preview(ev.card, true)
screen.preview_timer = 1.8
"mulligan":
if ev.count > 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)
popup("Verschmolzen!", Style.PURPLE, screen.target_center({"kind": "player", "index": ev.player}), 0.0)
"field":
sfx("combo")
popup("Feld: %s" % Cards.FIELD_NAMES[ev.element], Cards.ELEMENT_COLORS[ev.element], Vector2(640, 196), 0.0)
"buy":
sfx("click") # die Karte landet sichtbar auf der Hand, das reicht
"reroll":
sfx("click")
func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
@@ -73,9 +65,24 @@ func center_of(ref: Dictionary, centers: Dictionary) -> Vector2:
return c
## Mehrere Meldungen an derselben Stelle rücken nach unten, sonst liegen sie übereinander.
func _free_spot(at: Vector2) -> Vector2:
for step in 6:
var spot := at + Vector2(0, step * 26)
var taken := false
for l in screen.fx_layer.get_children():
if l is Label and l.get_meta("spot", Vector2(-999, -999)).distance_to(spot) < 20:
taken = true
break
if not taken:
return spot
return at
func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
if not enabled or at.x < 0:
return
at = _free_spot(at)
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 22)
@@ -84,6 +91,7 @@ func popup(text: String, color: Color, at: Vector2, delay: float) -> void:
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
l.add_theme_constant_override("outline_size", 6)
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
l.set_meta("spot", at)
screen.fx_layer.add_child(l)
l.reset_size()
l.position = at - l.size / 2

View File

@@ -1,11 +1,12 @@
class_name BattleGamepad
extends RefCounted
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Hand, eigene Wesen und Ziele,
## A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge, X macht rückgängig.
## Gamepad-Steuerung des Kampfes: Steuerkreuz bewegt einen Zeiger über Laden, Hand, eigene Wesen
## und Ziele, A bestätigt, B bricht ab, Start beendet den Zug, Y überspringt Gegnerzüge,
## X macht rückgängig.
var screen # BattleScreen
var active := false
var zone := "hand"
var zone := "shop"
var index := 0
var targets: Array = []
@@ -26,7 +27,7 @@ func button(button: int) -> void:
if screen.confirm.visible:
screen.confirm.visible = false
screen.cancel_all()
zone = "hand"
zone = "shop"
JOY_BUTTON_Y:
if screen.b_skip.visible:
screen.skip_ai = true
@@ -41,16 +42,35 @@ func button(button: int) -> void:
move(1)
JOY_BUTTON_DPAD_UP, JOY_BUTTON_DPAD_DOWN:
if zone != "target":
zone = "own" if zone == "hand" else "hand"
index = 0
_next_zone(1 if button == JOY_BUTTON_DPAD_DOWN else -1)
screen.refresh()
## Wechselt den Bereich und überspringt leere (eine leere Hand ist keine Station).
## step 0: beim aktuellen Bereich bleiben, falls er etwas enthält.
func _next_zone(step: int) -> void:
var order := ["shop", "hand", "own"]
var at := order.find(zone)
index = 0
if step == 0 and not items().is_empty():
return
var dir := -1 if step < 0 else 1
for i in order.size():
at = posmod(at + dir, order.size())
zone = order[at]
if not items().is_empty():
return
func items() -> Array:
match zone:
"shop":
return screen.shop_views
"hand":
return screen.hand_views
"own":
if screen.fusing: # zum Verschmelzen zählt jedes eigene Wesen, nicht nur angriffsbereite
return screen.own_views
return screen.own_views.filter(func(v): return v.creature.can_attack())
"target":
return targets
@@ -64,9 +84,6 @@ func move(dir: int) -> void:
func confirm() -> void:
if screen.mulligan.visible:
screen.do_mulligan([])
return
if screen.overlay.visible:
screen.leave()
return
@@ -78,6 +95,8 @@ func confirm() -> void:
index = clampi(index, 0, items.size() - 1)
var item = items[index]
match zone:
"shop":
screen.on_click("shop", int(item.get_meta("slot", 0)))
"hand":
screen.on_click("hand", item.card)
if screen.pending != null:
@@ -113,8 +132,8 @@ func confirm() -> void:
for o in screen.hero_panels:
if screen.hero_panels[o] == item:
screen.on_click("hero", o)
zone = "hand"
index = 0
zone = "own"
_next_zone(0)
## Pulsierender Rahmen als Zeiger.

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,17 +5,13 @@ const CARD_W := 200
const CARD_H := 280
var app
var cards: Array = []
var cards: Array = [] # {"card", "rarity", "upgraded", "dust"} je Karte
var revealed := {}
var new_ids := {}
var coins_label: Label
var msg_label: Label
var b_buy: Button
var b_all: Button
var b_improve: Button
var suggestion := ""
var slots: Control
var element_buttons: Array = []
class CardBack extends Control:
@@ -46,28 +42,19 @@ class CardBack extends Control:
func slot_rect(i: int) -> Rect2:
var gap := 20
var x0 := (1280 - 5 * CARD_W - 4 * gap) / 2
var x0 := (1280 - Profile.BOOSTER_SIZE * CARD_W - (Profile.BOOSTER_SIZE - 1) * gap) / 2
return Rect2(x0 + i * (CARD_W + gap), 230, CARD_W, CARD_H)
func _ready() -> void:
Style.label(self, "Booster", 36, Style.GOLD, Rect2(0, 20, 1280, 50), "center", true)
coins_label = Style.label(self, "", 22, Style.GOLD, Rect2(0, 78, 1280, 30), "center")
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, "", 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(860, 720, 130, 48), func(): app.show_screen(MenuScreen.new()))
refresh()
@@ -77,10 +64,9 @@ func _input(event: InputEvent) -> void:
app.show_screen(MenuScreen.new())
func buy(element: String = "") -> void:
func buy() -> void:
var p: Profile = app.profile
var before: Dictionary = p.collection.duplicate()
var got: Array = p.open_booster(null, element)
var got: Array = p.open_booster()
if got.is_empty():
msg_label.text = "Nicht genug Münzen"
msg_label.add_theme_color_override("font_color", Style.RED)
@@ -89,13 +75,7 @@ func buy(element: String = "") -> void:
app.sfx.play("play")
cards = got
revealed = {}
new_ids = {}
for c in cards:
if before.get(c.id, 0) == 0:
new_ids[c.id] = true
var notes: Array[String] = p.take_notices()
msg_label.text = " · ".join(notes)
msg_label.add_theme_color_override("font_color", Style.GREEN)
msg_label.text = ""
refresh()
@@ -106,59 +86,32 @@ func reveal(i: int) -> void:
func reveal_all() -> void:
for i in 5:
for i in Profile.BOOSTER_SIZE:
revealed[i] = true
refresh()
## Nimmt die vorgeschlagene Karte ins aktive Deck (ersetzt die schwächste).
func improve() -> void:
if suggestion == "":
return
var err: String = app.profile.improve_deck_with(suggestion)
if err == "":
app.save()
msg_label.text = tr("%s ist jetzt im Deck %s") % [Cards.by_id(suggestion).title(), app.profile.deck_name()]
msg_label.add_theme_color_override("font_color", Style.GREEN)
suggestion = ""
else:
msg_label.text = err
msg_label.add_theme_color_override("font_color", Style.RED)
refresh()
func refresh() -> void:
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 = ""
if not cards.is_empty() and revealed.size() >= 5:
var ids: Array = cards.map(func(c): return c.id)
var better: Array = p.deck_suggestions(ids)
if not better.is_empty():
suggestion = better[0]
b_improve.visible = suggestion != ""
if suggestion != "":
b_improve.text = tr("Deck verbessern: %s ins aktive Deck (%s)") % [Cards.by_id(suggestion).title(), p.deck_name()]
b_all.disabled = cards.is_empty() or revealed.size() >= Profile.BOOSTER_SIZE
Style.clear(slots)
for i in 5:
for i in Profile.BOOSTER_SIZE:
var r := slot_rect(i)
if cards.is_empty():
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
elif revealed.has(i):
var c: Cards.Card = cards[i]
var v := CardView.new(c, r.size)
var res: Dictionary = cards[i]
var c: Cards.Card = res.card
var v := CardView.new(c, r.size, false, res.rarity)
v.position = r.position
slots.add_child(v)
Style.label(slots, tr(c.rarity), 16, Cards.RARITY_COLORS[c.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if 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, tr(res.rarity), 16, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x, r.end.y + 8, r.size.x, 24), "center", true)
if res.upgraded:
Style.label(slots, "Aufgewertet", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
else:
Style.label(slots, "jetzt %dx" % p.collection[c.id], 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
Style.label(slots, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
else:
var back := CardBack.new(r.size)
back.position = r.position

View File

@@ -70,7 +70,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, "Alle drei Kapitel abgeschlossen. Der Archimagier ist endgültig besiegt!", 16, Style.GREEN, "center", true)
Style.clabel(col, "Alle Kapitel abgeschlossen", 16, Style.GREEN, "center", true)
var br := Style.row(col)
br.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(br, "Zurück", Vector2(160, 40), func(): app.show_screen(MenuScreen.new()))

View File

@@ -3,7 +3,6 @@ extends ScrollContainer
## Scrollbares Kartenraster mit Beschriftung unter jeder Karte.
signal card_clicked(card: Cards.Card)
signal card_hover(card: Cards.Card, entered: bool)
const COLS := 7
const CW := 100
@@ -25,17 +24,16 @@ func _init(rect: Rect2) -> void:
add_child(grid)
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool
func fill(cards: Array, label_fn: Callable, dim_fn: Callable) -> void:
## cards: Karten; label_fn(card) -> String; dim_fn(card) -> bool; rarity_fn(card) -> String (Optik)
func fill(cards: Array, label_fn: Callable, dim_fn: Callable, rarity_fn: Callable = Callable()) -> void:
Style.clear(grid)
for c in cards:
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), true)
var v := CardView.new(c, Vector2(CW, CH), true, rarity_fn.call(c) if rarity_fn.is_valid() else "gewöhnlich")
v.dim = dim_fn.call(c)
v.clicked.connect(func(_view): card_clicked.emit(c))
v.hover.connect(func(card, entered): card_hover.emit(card, entered))
cell.add_child(v)
var text: String = label_fn.call(c)
Style.label(cell, text, 12, Style.MUTED if v.dim else Style.TEXT, Rect2(0, CH + 1, CW, 16), "center")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,13 +76,12 @@ func _ready() -> void:
grid.add_child(language)
Style.clabel(grid, "", 18, Style.MUTED, "left", false, 100)
Style.spacer(col, 20)
Style.clabel(col, "Der Spielstand liegt unter %s" % ProjectSettings.globalize_path(Profile.SAVE_PATH), 13, Style.MUTED)
Style.spacer(col, 30)
var r := Style.row(col, 16)
r.alignment = BoxContainer.ALIGNMENT_CENTER
Style.cbutton(r, "Profil zurücksetzen", Vector2(200, 44), func(): confirm.visible = true, Color("5a2828"))
Style.cbutton(r, "Zurück", Vector2(160, 44), func(): app.show_screen(MenuScreen.new()))
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Decks, Münzen und Fortschritt gehen verloren.",
confirm = Style.confirm(self, "Profil wirklich zurücksetzen?", "Sammlung, Münzen und Fortschritt gehen verloren.",
"Ja, zurücksetzen", _reset, "Abbrechen", Style.RED)
_labels()

View File

@@ -2,8 +2,6 @@ class_name Sfx
extends Node
## Kurze, zur Laufzeit erzeugte Töne (keine Audiodateien nötig).
static var inst: Sfx
var streams := {}
var players: Array[AudioStreamPlayer] = []
var volume := 0.8
@@ -14,7 +12,6 @@ var music_box := {}
func _ready() -> void:
inst = self
streams["click"] = _tone(880, 0.05, "sine")
streams["play"] = _tone(440, 0.14, "sine", 660)
streams["attack"] = _tone(220, 0.16, "square", 90)

View File

@@ -2,12 +2,8 @@ class_name Style
## Farben, Schriften, Theme und Zeichenhilfen.
const FONT: FontFile = preload("res://assets/fonts/DejaVuSans.ttf")
static var font_scale := 1.0 # Einstellung „Große Schrift“
static func fs(size: int) -> int:
return int(round(size * font_scale))
const FONT_BOLD: FontFile = preload("res://assets/fonts/DejaVuSans-Bold.ttf")
static var font_scale := 1.0 # Einstellung „Große Schrift“
const BG := Color("1a1c24")
const PANEL := Color("282b36")
@@ -20,6 +16,12 @@ const RED := Color("e65555")
const BLUE := Color("6eaaf0")
const YELLOW := Color("ffe65a")
const ORANGE := Color("ff963c")
const PURPLE := Color("8c78dc")
## Schriftgröße mit der Einstellung „Große Schrift“.
static func fs(size: int) -> int:
return int(round(size * font_scale))
static func darker(c: Color, f: float = 0.65) -> Color:
@@ -244,65 +246,42 @@ static func border(ci: CanvasItem, rect: Rect2, color: Color, width: int, radius
ci.draw_style_box(sb, rect)
## Hintergrund in Elementfarbe; ein verschmolzenes Wesen zeigt seine Elemente als Streifen.
static func fill_elements(ci: CanvasItem, rect: Rect2, elements: Array, radius: int = 8) -> void:
var a: Color = Cards.ELEMENT_COLORS[elements[0]]
if elements.size() == 1:
ci.draw_style_box(box(a, radius), rect)
elif elements[0] == elements[1]:
# Doppeltes Element: gleiche Farbe mit zweitem, hellem Innenrahmen
ci.draw_style_box(box(a, radius), rect)
border(ci, rect.grow(-5), lighter(a, 0.27), 2, maxi(2, radius - 3))
else:
var b: Color = Cards.ELEMENT_COLORS[elements[1]]
var half := floorf(rect.size.x / 2)
var left := StyleBoxFlat.new()
left.bg_color = a
left.corner_radius_top_left = radius
left.corner_radius_bottom_left = radius
var right := StyleBoxFlat.new()
right.bg_color = b
right.corner_radius_top_right = radius
right.corner_radius_bottom_right = radius
ci.draw_style_box(left, Rect2(rect.position, Vector2(half, rect.size.y)))
ci.draw_style_box(right, Rect2(rect.position + Vector2(half, 0), Vector2(rect.size.x - half, rect.size.y)))
ci.draw_style_box(box(Cards.ELEMENT_COLORS[elements[0]], radius), rect)
return
var n := elements.size()
var w := rect.size.x / n
for i in n:
var sb := StyleBoxFlat.new()
sb.bg_color = Cards.ELEMENT_COLORS[elements[i]]
if i == 0:
sb.corner_radius_top_left = radius
sb.corner_radius_bottom_left = radius
if i == n - 1:
sb.corner_radius_top_right = radius
sb.corner_radius_bottom_right = radius
ci.draw_style_box(sb, Rect2(rect.position + Vector2(floorf(i * w), 0), Vector2(ceilf(w), rect.size.y)))
# --- Kartensymbole (prozedural, je Element und Kartenart) --------------------------
# --- Kartensymbole (prozedural, je Element) --------------------------
static func glyph(ci: CanvasItem, card, center: Vector2, size: float) -> void:
var variant := absi(hash(card.id)) % 3
var col := lighter(Cards.ELEMENT_COLORS[card.elements[0]], 0.38)
col.a = 0.95
if card.is_trap():
# Falle: Kreuz im Kreis
ci.draw_arc(center, size * 0.5, 0, TAU, 32, col, maxf(2, size * 0.08))
var d := size * 0.28
ci.draw_line(center + Vector2(-d, -d), center + Vector2(d, d), col, maxf(2, size * 0.1))
ci.draw_line(center + Vector2(-d, d), center + Vector2(d, -d), col, maxf(2, size * 0.1))
return
if card.kind == "Zauber":
_star(ci, center, size * 0.55, Color(col, 0.35))
var elems: Array = card.elements
if elems.size() >= 2 and elems[0] != elems[1]:
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
col2.a = 0.95
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
else:
if elems.size() == 1:
_element_glyph(ci, elems[0], center, size, variant, col)
if elems.size() >= 2:
ci.draw_arc(center, size * 0.6, 0, TAU, 32, Color(col, 0.7), 2)
if elems.size() >= 3:
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
static func _star(ci: CanvasItem, c: Vector2, r: float, col: Color) -> void:
var pts := PackedVector2Array()
for i in 8:
var a := TAU * i / 8 - PI / 2
var rr := r if i % 2 == 0 else r * 0.45
pts.append(c + Vector2(cos(a), sin(a)) * rr)
ci.draw_colored_polygon(pts, col)
return
# Verschmolzen: die ersten beiden Elemente nebeneinander, ab dem dritten ein Ring
_element_glyph(ci, elems[0], center + Vector2(-size * 0.28, 0), size * 0.62, variant, col)
var col2 := lighter(Cards.ELEMENT_COLORS[elems[1]], 0.38)
col2.a = 0.95
_element_glyph(ci, elems[1], center + Vector2(size * 0.28, 0), size * 0.62, variant, col2)
if elems.size() >= 3:
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void: