update
Tests / headless (push) Waiting to run
Tests / fenster-schnell (push) Waiting to run
Tests / fenster-voll (push) Skipped
Tests / balance (push) Skipped

This commit is contained in:
team3 committed 2026-10-14 17:35:09 +02:00
1 parent f2c609a08b
commit 1d563b9eec
34 files changed
+5486 -643

No files matched your search

+51 -58
View File
@@ -1,79 +1,72 @@
class_name Campaign
## Kampagne: zehn benannte Magier in drei Kapiteln. Die Bosse haben weniger Leben als du,
## dafür Merkmale; je Kapitel kommt eins dazu.
## Kampagne: fünf Kapitel mit je zehn benannten Magiern, der zehnte ist der Boss. Kapitel 1 spielt
## neutrale Karten, Kapitel 2 bis 5 je ein Element; jeder Gegner spielt 30 Karten seines Elements
## nach Manakurve (DeckGen.curve_deck) mit steigendem Kostendeckel (COST_CAP), in der Stufe seines Kapitels (Kapitel 1 und 2 Common, dann je Kapitel eine höher, Kapitel 5
## Ultra Rare). Mit jedem offenen
## Kapitel gibt es den Booster seines Elements zu kaufen (Profile.booster_unlocked).
const STAGES := [
{"name": "Fenn", "title": "Kobold-Zähmer", "life": 22, "reward": 100, "perk": "feuer_angriff"},
{"name": "Mara", "title": "Gezeitenhexe", "life": 22, "reward": 100, "perk": "leben_35"},
{"name": "Borin", "title": "Steinfaust", "life": 24, "reward": 150, "perk": "wesen_angriff"},
{"name": "Liska", "title": "Windläuferin", "life": 24, "reward": 150, "perk": "ziehen_gratis"},
{"name": "Vulkar", "title": "Herr der Lava", "life": 24, "reward": 200, "perk": "wesen_leben"},
{"name": "Nyx", "title": "Sturmruferin", "life": 26, "reward": 200, "perk": "extra_mana"},
{"name": "Ignis", "title": "Inferno-Priester", "life": 26, "reward": 250, "perk": "element_doppelt"},
{"name": "Thalassa", "title": "Tiefseekönigin", "life": 26, "reward": 250, "perk": "alle_flink"},
{"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 300, "perk": "alle_flink"},
{"name": "Archimagier", "title": "Meister der Elemente", "life": 28, "reward": 500, "perk": "start_energie"},
]
const STAGES_PER_CHAPTER := 10
const CHAPTER_ELEMENTS: Array[String] = [Cards.NEUTRAL, "Feuer", "Wasser", "Luft", "Erde"]
const NAMES: Array[String] = ["Fenn", "Mara", "Borin", "Liska", "Vulkar", "Nyx", "Ignis", "Thalassa", "Zephyra", "Archimagier"]
const TITLES := {
"Neutral": ["Kobold-Zähmer", "Wanderhexe", "Steinfaust", "Pfadfinderin", "Söldnerfürst", "Schattenruferin", "Grauer Priester", "Händlerkönigin", "Stahlkaiserin", "Meister der Lehre"],
"Feuer": ["Flammenzähmer", "Glutweberin", "Schmiedefaust", "Funkenläuferin", "Herr der Lava", "Aschenruferin", "Inferno-Priester", "Glutkönigin", "Flammenkaiserin", "Meister des Feuers"],
"Wasser": ["Gezeitenzähmer", "Gezeitenhexe", "Eisfaust", "Nebelläuferin", "Herr der Brandung", "Regenruferin", "Tiefsee-Priester", "Tiefseekönigin", "Flutkaiserin", "Meister des Wassers"],
"Luft": ["Windzähmer", "Wolkenhexe", "Sturmfaust", "Windläuferin", "Herr der Böen", "Sturmruferin", "Himmelspriester", "Wolkenkönigin", "Blitzkaiserin", "Meister der Lüfte"],
"Erde": ["Wurzelzähmer", "Moorhexe", "Felsenfaust", "Hügelläuferin", "Herr der Schluchten", "Bebenruferin", "Erzpriester", "Kristallkönigin", "Bergkaiserin", "Meister der Erde"],
}
const LIFE := [22, 22, 24, 24, 24, 26, 26, 26, 28, 28]
## Höchste Kartenkosten im Stapel je Stufe eines Kapitels.
const COST_CAP: Array[int] = [3, 4, 4, 5, 5, 6, 7, 8, 9, 10]
## Merkmale je Stufe: das erste nach dieser Liste, weitere je drei Plätze dahinter.
const PERK_ORDER: Array[String] = ["feuer_angriff", "leben_35", "wesen_angriff", "ziehen_gratis", "wesen_leben", "extra_mana", "kein_nachteil", "alle_flink", "alle_flink", "start_energie"]
## Kapitel 2: die Magier kehren mit zwei Merkmalen und mehr Leben zurück.
const STAGES2 := [
{"name": "Fenn", "title": "Flammenzähmer", "life": 22, "reward": 300, "perks": ["feuer_angriff", "start_energie"]},
{"name": "Mara", "title": "Herrin der Gezeiten", "life": 22, "reward": 300, "perks": ["leben_35", "extra_mana"]},
{"name": "Borin", "title": "Fels der Ewigkeit", "life": 24, "reward": 350, "perks": ["wesen_angriff", "alle_flink"]},
{"name": "Liska", "title": "Sturmläuferin", "life": 24, "reward": 350, "perks": ["ziehen_gratis", "wesen_angriff"]},
{"name": "Vulkar", "title": "Aschekönig", "life": 24, "reward": 400, "perks": ["wesen_leben", "feuer_angriff"]},
{"name": "Nyx", "title": "Gewitterruferin", "life": 26, "reward": 400, "perks": ["extra_mana", "ziehen_gratis"]},
{"name": "Ignis", "title": "Inferno-Erzpriester", "life": 26, "reward": 450, "perks": ["element_doppelt", "wesen_leben"]},
{"name": "Thalassa", "title": "Kaiserin der Tiefe", "life": 26, "reward": 450, "perks": ["alle_flink", "leben_35"]},
{"name": "Zephyra", "title": "Blitzkaiserin", "life": 28, "reward": 500, "perks": ["alle_flink", "start_energie"]},
{"name": "Archimagier", "title": "Herr aller Elemente", "life": 28, "reward": 800, "perks": ["start_energie", "element_doppelt"]},
]
## Kapitel 3: drei Merkmale und volle Tische.
const STAGES3 := [
{"name": "Fenn", "title": "Aschefürst", "life": 20, "reward": 600, "perks": ["feuer_angriff", "start_energie", "wesen_leben"]},
{"name": "Mara", "title": "Sturmflut", "life": 20, "reward": 600, "perks": ["leben_35", "extra_mana", "alle_flink"]},
{"name": "Borin", "title": "Berg selbst", "life": 22, "reward": 700, "perks": ["wesen_angriff", "alle_flink", "leben_35"]},
{"name": "Liska", "title": "Windbraut", "life": 22, "reward": 700, "perks": ["ziehen_gratis", "wesen_angriff", "extra_mana"]},
{"name": "Vulkar", "title": "Magmakaiser", "life": 22, "reward": 800, "perks": ["wesen_leben", "feuer_angriff", "wesen_angriff"]},
{"name": "Nyx", "title": "Orkanmutter", "life": 24, "reward": 800, "perks": ["extra_mana", "ziehen_gratis", "element_doppelt"]},
{"name": "Ignis", "title": "Sonnenpriester", "life": 24, "reward": 900, "perks": ["element_doppelt", "wesen_leben", "start_energie"]},
{"name": "Thalassa", "title": "Herz der Tiefe", "life": 24, "reward": 900, "perks": ["alle_flink", "leben_35", "element_doppelt"]},
{"name": "Zephyra", "title": "Gewitterkaiserin", "life": 26, "reward": 1000, "perks": ["alle_flink", "start_energie", "wesen_leben"]},
{"name": "Archimagier", "title": "Ewiger Meister", "life": 26, "reward": 1200, "perks": ["start_energie", "element_doppelt", "leben_35"]},
]
const CHAPTERS := [STAGES, STAGES2, STAGES3]
static func total() -> int:
return STAGES.size() * CHAPTERS.size()
return STAGES_PER_CHAPTER * CHAPTER_ELEMENTS.size()
static func chapter(stage: int) -> int:
return stage / STAGES.size() + 1
return stage / STAGES_PER_CHAPTER + 1
## Stufe mit einheitlichem Feld "perks".
static func element_of(chapter_no: int) -> String:
return CHAPTER_ELEMENTS[chapter_no - 1]
## Merkmale je Gegner: eins, ab Kapitel 3 zwei, in Kapitel 5 drei.
static func perk_count(chapter_no: int) -> int:
return (chapter_no + 1) / 2
## Stufe i: Name, Titel, Leben, Belohnung (Kapitel 1: 100 bis 300, Boss 500; je Kapitel 250 mehr),
## Merkmale und Element.
static func stage(i: int) -> Dictionary:
if i < STAGES.size():
var st: Dictionary = STAGES[i].duplicate()
st["perks"] = [st.perk]
return st
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
var c := chapter(i)
var k := i % STAGES_PER_CHAPTER
var perks: Array = []
for n in perk_count(c):
perks.append(PERK_ORDER[(k + 3 * n) % STAGES_PER_CHAPTER])
var base := 100 + 250 * (c - 1)
return {"name": NAMES[k], "title": TITLES[element_of(c)][k], "life": LIFE[k],
"reward": base + (400 if k == STAGES_PER_CHAPTER - 1 else 50 * (k / 2)),
"perks": perks, "element": element_of(c), "max_cost": COST_CAP[k]}
## Boss einer Stufe mit dem Leben, den Merkmalen und einem Stapel der Stufe; ab Kapitel 2 sind
## seine Karten eine Seltenheitsstufe höher (+1/+1), ab Kapitel 3 zwei.
## Gegner einer Stufe: Leben und Merkmale der Stufe, 30 Karten seines Elements nach Manakurve bis
## max_cost, in der Stufe des Kapitels (rarity_of).
static func opponent(stage_index: int, r: RandomNumberGenerator = null) -> Game.Player:
var st := stage(stage_index)
var boss := Game.Player.new(st.name, false, int(st.life), st.title)
boss.perks = Array(st.perks)
if r == null:
r = Game.make_rng()
var ch := chapter(stage_index)
boss.deck = DeckGen.opponent_deck(r, ch)
boss.deck = DeckGen.curve_deck(st.element, r, int(st.max_cost))
for id in boss.deck:
boss.looks[id] = ch - 1
boss.looks[id] = rarity_of(chapter(stage_index))
return boss
## Seltenheitsstufe der Gegnerkarten: Kapitel 1 und 2 Common, dann je Kapitel eine höher.
static func rarity_of(chapter_no: int) -> int:
return maxi(0, chapter_no - 2)
+61 -24
View File
@@ -1,7 +1,10 @@
class_name DeckGen
## Stapel: die vier Startdecks, Auffüllen aus der Sammlung, zufällige Stapel für Gegner.
## Stapel: Elementstapel nach Manakurve (Startstapel und Gegner), Auffüllen aus der Sammlung,
## zufällige Stapel für Tests und Simulation.
const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens
## Manakurve eines Stapels mit DECK_MIN Karten: so viele Karten je Kosten 0 bis 10.
const CURVE: Array[int] = [2, 5, 6, 6, 5, 3, 2, 1, 0, 0, 0]
## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang.
@@ -18,41 +21,75 @@ static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array:
return out
## Stapel eines Kampagnengegners: Kapitel 1 bis Kosten 3, Kapitel 2 bis 5, Kapitel 3 alles.
static func opponent_deck(r: RandomNumberGenerator, chapter: int) -> Array:
return random_deck(r, [3, 5, 99][clampi(chapter - 1, 0, 2)])
## Alle Karten eines Elements (oder die neutralen) in Datenreihenfolge: die Startsammlung.
static func element_deck(element: String) -> Array:
return Cards.all().filter(func(c): return c.elements[0] == element).map(func(c): return c.id)
## Startdeck für ein Element: die 25 Karten des Elements bis Angriff 4, dazu je fünf billige der
## anderen Elemente (DECK_MIN Karten).
static func start_deck(element: String) -> Array:
var out := []
## Stapel eines Elements nach der Manakurve: DECK_MIN verschiedene Karten des Elements bis max_cost,
## je Kosten so viele wie CURVE vorgibt, zufällig gewählt. Was über dem Deckel fehlt, füllen die
## erlaubten Kosten reihum von billig nach teuer auf.
static func curve_deck(element: String, r: RandomNumberGenerator, max_cost: int = 10) -> Array:
var by_cost := {}
for c in Cards.all():
if c.elements[0] == element and c.attack <= 4:
out.append(c.id)
for e in Cards.ELEMENTS:
if e == element:
continue
var cheap := Cards.all().filter(func(c): return c.elements[0] == e and c.cost <= Game.START_MANA)
cheap.sort_custom(func(a, b): return a.cost < b.cost if a.cost != b.cost else a.attack > b.attack)
for c in cheap.slice(0, 5):
out.append(c.id)
if c.elements[0] == element and c.cost <= max_cost:
if not by_cost.has(c.cost):
by_cost[c.cost] = []
by_cost[c.cost].append(c.id)
for cost in by_cost:
Game.shuffle(by_cost[cost], r)
var out := []
for cost in CURVE.size():
for k in CURVE[cost]:
if by_cost.has(cost) and not by_cost[cost].is_empty():
out.append(by_cost[cost].pop_back())
while out.size() < Game.DECK_MIN:
var added := false
for cost in range(0, max_cost + 1):
if out.size() >= Game.DECK_MIN:
break
if by_cost.has(cost) and not by_cost[cost].is_empty():
out.append(by_cost[cost].pop_back())
added = true
if not added:
break
return out
## Füllt deck aus pool (Karten-IDs) bis limit auf, die wertvollsten zuerst (Card.value in der
## Stufe aus levels: ID -> Stufe), bei gleichem Wert die teuersten; nichts doppelt.
## Füllt deck aus pool (Karten-IDs) bis limit auf, die wertvollsten zuerst (Card.value in der Stufe
## aus levels: ID -> Stufe); bei gleichem Wert zuerst, was der Manakurve (CURVE, auf limit
## skaliert) noch fehlt, dann die billigeren, dann nach ID; nichts doppelt.
static func fill(deck: Array, pool: Array, limit: int, levels: Dictionary = {}) -> Array:
var out := deck.duplicate()
var cards := pool.filter(func(id): return not (id in out)).map(func(id): return Cards.by_id(id).leveled(int(levels.get(id, 0))))
cards.sort_custom(func(a, b):
if a.value() != b.value():
return a.value() > b.value()
return a.cost > b.cost if a.cost != b.cost else a.id < b.id)
for c in cards:
if out.size() >= limit:
break
out.append(c.id)
return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
var have := {} # Kosten -> Karten im Stapel
for id in out:
var slot := clampi(Cards.by_id(id).cost, 0, CURVE.size() - 1)
have[slot] = int(have.get(slot, 0)) + 1
var i := 0
while i < cards.size() and out.size() < limit:
var j := i
while j < cards.size() and cards[j].value() == cards[i].value():
j += 1
var rest := []
for c in cards.slice(i, j):
if out.size() >= limit:
break
var slot := clampi(c.cost, 0, CURVE.size() - 1)
if int(have.get(slot, 0)) < CURVE[slot] * limit / Game.DECK_MIN:
out.append(c.id)
have[slot] = int(have.get(slot, 0)) + 1
else:
rest.append(c)
for c in rest:
if out.size() >= limit:
break
out.append(c.id)
i = j
return out
+28 -85
View File
@@ -6,11 +6,10 @@ extends RefCounted
const MAX_BOARD := 5
const START_HAND := 5 # Startkarten: die obersten des eigenen Stapels
const START_MANA := 1 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt
const DECK_MIN := 40 # Karten je Stapel: mindestens so viele (Gegner und Startdecks genau so viele)
const DECK_MIN := 30 # Karten je Stapel: mindestens so viele (Gegner genau so viele: alle Karten eines Elements)
const DECK_MAX := 60 # und höchstens so viele
const START_LIFE := 30
const ELEMENT_PAIR := 2 # so viele eigene offene Wesen eines Elements: Sondereffekt des Elements
const ELEMENT_COUNT := 3 # je so viele ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements
const DISCARD_GAIN := 1 # Abwerfen einer Handkarte bringt so viel Mana
# Merkmale der Kampagnen-Bosse (Player.perks).
@@ -23,7 +22,7 @@ const PERKS := {
"leben_35": "Beginnt mit 5 Leben mehr",
"alle_flink": "Wesen greifen sofort an",
"start_energie": "Hat im ersten Zug 2 Mana mehr",
"element_doppelt": "Elementbonus zählt doppelt",
"kein_nachteil": "Wesen haben keinen Elementnachteil",
}
## Für wiederholbare Tests: 0 = echter Zufall, sonst Startwert (zählt je Aufruf hoch).
@@ -50,8 +49,7 @@ class Creature extends RefCounted:
var max_health: int
var attacks_done := 0 # Angriffe in diesem Zug
var ready := false # lag schon zu Beginn des eigenen Zugs: darf angreifen und aufgedeckt werden
var hidden := false # verdeckt gelegt: greift nicht an, zählt nicht für die Elementboni
var aura := 0 # Elementbonus (+1 je Element mit vollem Stapel); das Leben ist schon angepasst
var hidden := false # verdeckt gelegt: greift nicht an, zählt nicht fürs Elementpaar
func _init(c: Cards.Card, o, id: int) -> void:
card = c
@@ -90,9 +88,9 @@ class Creature extends RefCounted:
out.append(k)
return out
## Angriffswert inklusive Elementbonus und Boss-Merkmalen.
## Angriffswert inklusive Boss-Merkmalen.
func power() -> int:
var v := attack + aura
var v := attack
var o = owner()
if o != null:
if "feuer_angriff" in o.perks and "Feuer" in card.elements:
@@ -118,7 +116,6 @@ class Creature extends RefCounted:
c.attacks_done = attacks_done
c.ready = ready
c.hidden = hidden
c.aura = aura
return c
@@ -137,7 +134,7 @@ class Player extends RefCounted:
var perks: Array = [] # Boss-Merkmale (siehe PERKS)
var looks := {} # Karten-ID -> Seltenheitsstufe, reine Optik (siehe Profile)
var turns := 0 # begonnene eigene Züge
var played := {} # Element -> bisher ausgespielte Karten (je ELEMENT_COUNT: +1/+1)
var cards_played := 0 # ausgespielte Karten (Münzen nach dem Kampf)
var draws := 0 # in diesem Zug gezogene Karten; die n-te kostet n-1 Mana
var fatigue := 0 # Züge ohne Karten im Stapel: so viel Leben kostet der nächste
@@ -147,8 +144,6 @@ class Player extends RefCounted:
is_human = h
life = l
max_life = l
for e in Cards.ELEMENTS:
played[e] = 0
func alive() -> bool:
return life > 0
@@ -178,7 +173,7 @@ class Player extends RefCounted:
p.perks = perks.duplicate()
p.looks = looks
p.turns = turns
p.played = played.duplicate()
p.cards_played = cards_played
p.deck = deck.duplicate()
p.draws = draws
p.fatigue = fatigue
@@ -406,14 +401,12 @@ func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String:
cr.health += 1
cr.max_health += 1
p.board.append(cr)
for e in card.elements:
p.played[e] = int(p.played.get(e, 0)) + 1
p.cards_played += 1
if face_down:
say("%s legt ein Wesen verdeckt." % p.name)
else:
say("%s spielt %s." % [p.name, card.name])
emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card, "hidden": face_down})
apply_auras()
cleanup()
check_end()
return ""
@@ -463,12 +456,11 @@ func reveal(p: Player, cr: Creature) -> String:
return ""
## Deckt ein Wesen auf; die Elementboni gelten sofort.
## Deckt ein Wesen auf; das Elementpaar gilt sofort.
func _flip(cr: Creature) -> void:
cr.hidden = false
say("%s wird aufgedeckt." % cr.name)
emit({"type": "reveal", "uid": cr.uid, "card": cr.card})
apply_auras()
# --- Verschmelzen ------------------------------------------------------------
@@ -523,49 +515,6 @@ func fuse(p: Player, a, b) -> String:
return ""
# --- Elementboni ---------------------------------------------------------------
## +1/+1 je volle ELEMENT_COUNT ausgespielte Karten eines Elements des Spielers; mehrere Elemente
## zählen alle, auch Neutral (eigener Zähler, aber kein Paar).
static func aura_of(elements: Array, played: Dictionary) -> int:
var a := 0
for e in elements:
a += int(played.get(e, 0)) / ELEMENT_COUNT
return a
## Bonus eines offenen Wesens aus den ausgespielten Karten seines Besitzers; verdeckte bekommen nichts.
func aura_for(c: Creature) -> int:
var o = c.owner()
if c.hidden or o == null:
return 0
var a := aura_of(c.card.elements, o.played)
if "element_doppelt" in o.perks:
a *= 2
return a
## Rechnet die Elementboni neu. Kommt ein Bonus, steigen Leben und Höchstleben; geht er
## (beim Verdecken), sinkt nur das Höchstleben, das Leben wird gekappt.
func apply_auras() -> void:
for p in players:
for c in p.board:
var a := aura_for(c)
if a != c.aura:
var delta: int = a - c.aura
c.max_health += delta
c.health = mini(c.health + maxi(delta, 0), c.max_health)
c.aura = a
## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme (sie zählt selbst mit).
func forecast(p: Player, card: Cards.Card) -> int:
var played := p.played.duplicate()
for e in card.elements:
played[e] = int(played.get(e, 0)) + 1
return aura_of(card.elements, played) * (2 if "element_doppelt" in p.perks else 1)
# --- Kampf -------------------------------------------------------------------
## Leben lassen sich erst angreifen, wenn der Gegner kein Wesen mehr hat; Wesen mit Spott
@@ -582,7 +531,7 @@ func can_attack(cr: Creature, target) -> Array:
var tgt_owner = target.owner() if target is Creature else target
if not (tgt_owner in enemies_of(cr.owner())):
return [false, "Kein gültiges Ziel"]
if cr.power() + matchup_bonus(cr, target) <= 0: # 0 Angriff reicht nur mit Elementvorteil
if cr.power() + matchup_bonus(cr, target) <= 0: # 0 Angriff macht auch im Vorteil nichts
return [false, "%s würde keinen Schaden machen" % cr.name]
if not (target is Creature) and not tgt_owner.board.is_empty():
return [false, "Erst die Wesen"]
@@ -609,17 +558,18 @@ func attack(cr: Creature, target) -> String:
return ""
## Elementvorteil beim Zuschlagen und beim Konter: im Vorteil zählt der Stapelbonus des Ziels
## nicht (so viel Schaden mehr), im Nachteil der eigene nicht (so viel weniger). Gegen Helden
## gibt es keinen.
## Elementvorteil beim Zuschlagen und beim Konter, als Verschiebung des Schadens: im Vorteil
## doppelt (+ Angriff), im Nachteil halb, aufgerundet (− Angriff / 2). Gegen Helden gibt es keinen;
## mit dem Merkmal kein_nachteil entfällt der Nachteil.
static func matchup_bonus(source: Creature, target) -> int:
if not (target is Creature):
return 0
var m := Cards.matchup(source.card.elements, target.card.elements)
if m > 0:
return target.aura
return source.power()
if m < 0:
return -source.aura
var o = source.owner()
return 0 if o != null and "kein_nachteil" in o.perks else -(source.power() / 2)
return 0
@@ -634,24 +584,17 @@ func damage(target, amount: int) -> void:
emit({"type": "damage", "target": ref_of(target), "amount": amount})
## Räumt Tote ab; danach gelten die Elementboni neu, was weitere Tote nach sich ziehen kann.
## Räumt Tote ab; wer besiegt ist, nimmt seine Wesen mit vom Tisch.
func cleanup() -> void:
for round in 8:
var died := false
for p in players:
for c in p.board.duplicate():
# Wer besiegt ist, nimmt seine Wesen mit vom Tisch.
if c.health <= 0 or not p.alive():
p.board.erase(c)
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 not died:
return
apply_auras()
for p in players:
for c in p.board.duplicate():
if c.health <= 0 or not p.alive():
p.board.erase(c)
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)})
## Ende: einer von beiden hat kein Leben mehr.
@@ -676,11 +619,11 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
arr[j] = tmp
## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}, "deck": [ids]}
## Duell gegen einen zufälligen Gegner (Tests, Simulation). rules: {"human_perks": [...], "looks": {...}, "deck": [ids]}
static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
if r == null:
r = make_rng()
return new_battle_with(DeckGen.random_opponent(int(rules.get("opponent_life", START_LIFE))), r, false, rules)
return new_battle_with(DeckGen.random_opponent(), r, false, rules)
static func new_battle_with(opp: Player, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game:
+185 -46
View File
@@ -1,24 +1,30 @@
class_name Profile
extends RefCounted
## Spielstand: Münzen, Staub, Sammlung, Stapel, Kampagne, Einstellungen.
## Man startet mit einem von vier Startdecks (je ein Element im Mittelpunkt); Booster und die
## Werkstatt bringen den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe
## Man startet mit allen neutralen Karten (Stapel: 30 nach Manakurve); die Elemente schaltet die Kampagne frei (je Kapitel einen
## Booster), die Werkstatt bringt den Rest. Jede eigene Karte hat eine Seltenheitsstufe, die +1/+1 je Stufe
## gibt (Cards.Card.leveled); kommt eine seltenere Version, ersetzt sie die alte, die zu Staub wird.
const SAVE_VERSION := 7
const SAVE_VERSION := 8
const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100
const BOOSTER_COST := 100 # neutraler Booster
const ELEMENT_BOOSTER_COST := 200 # Booster eines Elements (ab seinem Kapitel)
const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"Common": 45.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 10.0, "Ultimate Rare": 5.0}
const START_COINS := 150
const REWARD_WIN := 100
const REWARD_LOSS := 50
# Schwierigkeit: Leben der Gegner, KI und Münzen.
# Münzen nach dem Kampf: je eigener Zug, je ausgespielte Karte und je Zug unter FAST_TURN_SEC
# Sekunden; ein Sieg verdoppelt alles, die Schwierigkeit multipliziert (siehe battle_coins).
const COINS_TURN := 5
const COINS_CARD := 5
const COINS_FAST := 5
const FAST_TURN_SEC := 15.0
# Schwierigkeit im Einzelspieler: Stufe der Gegnerkarten relativ zu deiner Deckstärke (level),
# KI (smart: Vorausschau, sonst gierig), ein zufälliges Merkmal (perk) und der Münzfaktor.
const DIFFICULTIES := {
"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},
"leicht": {"name": "Leicht", "level": -1, "smart": false, "perk": false, "reward": 1.0},
"normal": {"name": "Normal", "level": 0, "smart": true, "perk": false, "reward": 1.5},
"schwer": {"name": "Schwer", "level": 1, "smart": true, "perk": true, "reward": 2.0},
}
# Sammlung: eine Stufe abgeben bringt Staub, eine Stufe aufwerten kostet Staub.
const DUST_DISMANTLE := {"Common": 50, "Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultimate Rare": 1000}
@@ -26,6 +32,8 @@ const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultim
var coins := 0
var dust := 0
var xp := 0 # Erfahrung: alle je verdienten Münzen (Skills.level_for)
var skills := {} # Skill-ID -> gelernte Stufe (Skills.SKILLS)
var collection := {} # eigene Karten: id -> Seltenheitsstufe (Index in Cards.RARITIES)
var deck: Array = [] # Karten-IDs des eigenen Stapels (Game.DECK_MIN bis DECK_MAX)
var campaign_stage := 0 # Anzahl abgeschlossener Kampagnenstufen
@@ -34,24 +42,20 @@ var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfort
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
## Neues Profil: noch ohne Karten, das Startdeck wählt der Spieler (choose_start).
## Neues Profil: alle neutralen Karten in der Sammlung, 30 davon nach Manakurve im Stapel.
static func starter() -> Profile:
var p := Profile.new()
p.coins = START_COINS
p.give_start()
return p
## Hat der Spieler schon ein Startdeck gewählt?
func started() -> bool:
return not collection.is_empty()
## Das Startdeck eines Elements wird Sammlung und Stapel.
func choose_start(element: String) -> void:
## Alle neutralen Karten in Common werden die Sammlung, ein Kurvenstapel daraus der Stapel.
func give_start() -> void:
collection = {}
deck = DeckGen.start_deck(element)
for id in deck:
for id in DeckGen.element_deck(Cards.NEUTRAL):
collection[id] = 0
deck = DeckGen.curve_deck(Cards.NEUTRAL, Game.make_rng())
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
@@ -74,6 +78,11 @@ static func from_dict(d: Dictionary) -> Profile:
var p := Profile.new()
p.coins = int(d.get("coins", 0))
p.dust = int(d.get("dust", 0))
p.xp = int(d.get("xp", 0))
var sk = d.get("skills", {})
for id in sk:
if Skills.SKILLS.has(id):
p.skills[id] = clampi(int(sk[id]), 0, int(Skills.SKILLS[id].max))
p.campaign_stage = int(d.get("campaign_stage", 0))
if int(d.get("version", 1)) >= SAVE_VERSION:
var coll = d.get("collection", {})
@@ -85,6 +94,8 @@ static func from_dict(d: Dictionary) -> Profile:
if p.collection.has(cid) and not (cid in ids):
ids.append(cid)
p.deck = ids.slice(0, Game.DECK_MAX)
if p.collection.is_empty():
p.give_start()
var se = d.get("settings", {})
p.settings = {"volume": float(se.get("volume", 0.0)), "music": float(se.get("music", 0.0)),
"ai_speed": float(se.get("ai_speed", 1.0)), "animations": bool(se.get("animations", true)),
@@ -99,8 +110,8 @@ static func from_dict(d: Dictionary) -> Profile:
func to_dict() -> Dictionary:
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, "deck": deck,
"campaign_stage": campaign_stage, "settings": settings, "perk": perk}
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "xp": xp, "skills": skills, "collection": collection,
"deck": deck, "campaign_stage": campaign_stage, "settings": settings, "perk": perk}
## Speichert und sichert den vorherigen Stand als .bak.
@@ -182,7 +193,7 @@ func dismantle(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid)
dust += DUST_DISMANTLE[Cards.RARITIES[lvl]]
dust += dust_gain(DUST_DISMANTLE[Cards.RARITIES[lvl]])
if lvl == 0:
collection.erase(cid)
deck.erase(cid)
@@ -204,11 +215,21 @@ func craft(cid: String) -> String:
# --- Booster ----------------------------------------------------------------------
## Fünf Karten mit gewürfelter Seltenheit.
static func roll_booster(r: RandomNumberGenerator) -> Array:
## Preis eines Boosters: neutral BOOSTER_COST, je Element ELEMENT_BOOSTER_COST.
static func booster_cost(element: String) -> int:
return BOOSTER_COST if element == Cards.NEUTRAL else ELEMENT_BOOSTER_COST
## Der Booster eines Elements ist zu haben, sobald sein Kampagnenkapitel offen ist (neutral immer).
func booster_unlocked(element: String) -> bool:
return campaign_stage >= Campaign.STAGES_PER_CHAPTER * Campaign.CHAPTER_ELEMENTS.find(element)
## Fünf Karten des Elements mit gewürfelter Seltenheit; luck (Packglück) verschiebt die Chancen.
static func roll_booster(r: RandomNumberGenerator, element: String, luck: int = 0) -> Array:
var rarities := RARITY_WEIGHTS.keys()
var weights := PackedFloat32Array(RARITY_WEIGHTS.values())
var pool := Cards.all()
var weights := Skills.pack_weights(luck)
var pool := Cards.all().filter(func(c): return c.elements[0] == element)
var out := []
for i in BOOSTER_SIZE:
var rar: String = rarities[r.rand_weighted(weights)]
@@ -218,16 +239,29 @@ static func roll_booster(r: RandomNumberGenerator) -> Array:
return out
## Öffnet einen Booster. Neue Karten kommen in Sammlung und Stapel (solange Platz ist). Von jeder
## Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine gleiche
## oder niedrigere wird selbst 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:
## Öffnet einen Booster des Elements. Neue Karten kommen in Sammlung und Stapel (solange Platz ist).
## Von jeder Karte zählt die seltenste Version: eine höhere ersetzt die alte, die zu Staub wird; eine
## gleiche oder niedrigere wird selbst zu Staub. Gibt die Ergebnisse zurück, leer wenn der Booster
## noch gesperrt ist oder die Münzen nicht reichen.
func open_booster(element: String, r: RandomNumberGenerator = null) -> Array:
return open_boosters(element, 1, r)
## Öffnet n Booster des Elements auf einmal; leer, wenn gesperrt oder die Münzen für alle nicht reichen.
func open_boosters(element: String, n: int, r: RandomNumberGenerator = null) -> Array:
if not booster_unlocked(element) or coins < pack_price(element) * n:
return []
if r == null:
r = Game.make_rng()
coins -= BOOSTER_COST
var out := roll_booster(r)
var out := []
for i in n:
out += _open_one(element, r)
return out
func _open_one(element: String, r: RandomNumberGenerator) -> Array:
coins -= pack_price(element)
var out := roll_booster(r, element, skill("packglueck"))
for res in out:
var lvl: int = Cards.RARITIES.find(res.rarity)
res["new"] = not owns(res.card.id)
@@ -239,33 +273,138 @@ func open_booster(r: RandomNumberGenerator = null) -> Array:
deck.append(res.card.id)
elif lvl > level(res.card.id):
res["upgraded"] = true
res["dust"] = DUST_DISMANTLE[rarity(res.card.id)]
res["dust"] = dust_gain(DUST_DISMANTLE[rarity(res.card.id)])
collection[res.card.id] = lvl
else:
res["dust"] = DUST_DISMANTLE[res.rarity]
res["dust"] = dust_gain(DUST_DISMANTLE[res.rarity])
dust += res.dust
return out
# --- 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.
func record_battle(won: bool, stage: int = -1, reward_factor: float = 1.0) -> int:
var reward := REWARD_LOSS
## Münzen eines Kampfes: COINS_TURN je eigenem Zug, COINS_CARD je ausgespielter Karte, COINS_FAST
## je schnellem Zug; ein Sieg verdoppelt, factor ist der Münzfaktor der Schwierigkeit.
static func battle_coins(won: bool, turns: int, cards: int, fast: int, factor: float = 1.0) -> int:
var pts := COINS_TURN * turns + COINS_CARD * cards + COINS_FAST * fast
if won:
reward = int(round(REWARD_WIN * 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)
pts *= 2
return int(round(pts * factor))
## Verbucht ein Kampfergebnis mit den verdienten Münzen (battle_coins, mal Münzsegen) und gibt die
## Gutschrift zurück; sie zählt auch als Erfahrung, ein neues Level meldet sich. stage >= 0:
## Kampagnenstufe; ein erster Sieg schaltet die nächste Stufe frei und bringt ihre Belohnung obendrauf.
func record_battle(won: bool, earned: int, stage: int = -1) -> int:
var reward := int(round(earned * coin_factor()))
if won and stage >= 0 and stage == campaign_stage:
campaign_stage += 1
reward += Campaign.stage(stage).reward
notices.append("Kampagne: %s besiegt" % Campaign.stage(stage).name)
coins += reward
var before := player_level()
xp += reward
if player_level() > before:
var gained := player_level() - before
notices.append(("Level %d erreicht: +%d Skillpunkt" if gained == 1 else "Level %d erreicht: +%d Skillpunkte") % [player_level(), gained])
return reward
# --- Skilltree ----------------------------------------------------------------------
## Spielerlevel aus der Erfahrung (nicht die Kartenstufe level(cid)).
func player_level() -> int:
return Skills.level_for(xp)
## Gelernte Stufe eines Skills.
func skill(id: String) -> int:
return int(skills.get(id, 0))
## Freie Skillpunkte: ein Punkt je Level, abzüglich der gelernten Stufen.
func skill_points() -> int:
var spent := 0
for id in skills:
spent += int(skills[id])
return player_level() - spent
## Lernt die nächste Stufe eines Skills für einen Punkt. Gibt "" zurück oder eine Fehlermeldung.
func learn(id: String) -> String:
if skill(id) >= int(Skills.SKILLS[id].max):
return "Schon auf der höchsten Stufe"
if skill_points() <= 0:
return "Kein Skillpunkt frei"
skills[id] = skill(id) + 1
return ""
## Preis eines Boosters mit Händler-Rabatt.
func pack_price(element: String) -> int:
return int(round(booster_cost(element) * (1.0 - 0.1 * skill("packpreis"))))
func coin_factor() -> float:
return 1.0 + 0.1 * skill("muenzen")
## Staub mit Staubsammler-Bonus.
func dust_gain(base: int) -> int:
return int(round(base * (1.0 + 0.25 * skill("staub"))))
## Merkmale im Kampf: das gewählte (nach Kapitel 1) und die der Kampfskills.
func battle_perks() -> Array:
var out := []
if perk != "" and perk_unlocked():
out.append(perk)
for id in Skills.PERK_OF:
if skill(id) > 0 and not (Skills.PERK_OF[id] in out):
out.append(Skills.PERK_OF[id])
return out
func difficulty() -> Dictionary:
return DIFFICULTIES.get(settings.get("difficulty", "normal"), DIFFICULTIES.normal)
## Deine Stufe: die Deckstärke des Kampfstapels (100 Common … 500 Ultimate Rare) als Seltenheitsstufe.
func deck_level() -> int:
return clampi(roundi((DeckGen.strength(deck_cards(), collection) - 100) / 100.0), 0, Cards.RARITIES.size() - 1)
## Stufe der Gegnerkarten im Einzelspieler: deine Stufe plus den Versatz der Schwierigkeit.
func opponent_level() -> int:
return clampi(deck_level() + int(difficulty().level), 0, Cards.RARITIES.size() - 1)
## Elemente der offenen Kampagnenkapitel (Neutral immer).
func unlocked_elements() -> Array:
return Campaign.CHAPTER_ELEMENTS.slice(0, campaign_stage / Campaign.STAGES_PER_CHAPTER + 1)
## Alle Karten der offenen Elemente: was Sammlung, Deck und Booster zeigen.
func unlocked_cards() -> Array:
var open := unlocked_elements()
return Cards.all().filter(func(c): return c.elements[0] in open)
## Einzelspieler-Gegner: ein Element aus den offenen Kapiteln, 30 Karten nach Manakurve in opponent_level;
## auf Schwer ein zufälliges Merkmal. Leben wie deins.
func single_opponent(r: RandomNumberGenerator) -> Game.Player:
var elements := unlocked_elements()
var element: String = elements[r.randi_range(0, elements.size() - 1)]
var opp := Game.Player.new("Gegner", false, Game.START_LIFE, element)
opp.deck = DeckGen.curve_deck(element, r)
var level := opponent_level()
for id in opp.deck:
opp.looks[id] = level
if bool(difficulty().perk):
var keys: Array = Game.PERKS.keys()
opp.perks = [keys[r.randi_range(0, keys.size() - 1)]]
return opp
func perk_unlocked() -> bool:
return campaign_stage >= Campaign.STAGES.size()
return campaign_stage >= Campaign.STAGES_PER_CHAPTER
+62
View File
@@ -0,0 +1,62 @@
class_name Skills
## Skilltree: Erfahrung sind die verdienten Münzen, je Level ein Skillpunkt, Punkte kaufen Stufen
## der Skills in drei Ästen. Münzen kaufen keine Punkte.
const BRANCHES: Array[String] = ["Sammeln", "Wirtschaft", "Kampf"]
## id -> Name, Ast, Höchststufe, Text.
const SKILLS := {
"packglueck": {"name": "Packglück", "branch": "Sammeln", "max": 3, "text": "Seltenere Karten in Boostern"},
"staub": {"name": "Staubsammler", "branch": "Sammeln", "max": 3, "text": "Mehr Staub aus Doppelten und Zerlegen"},
"muenzen": {"name": "Münzsegen", "branch": "Wirtschaft", "max": 3, "text": "Mehr Münzen nach jedem Kampf"},
"packpreis": {"name": "Händler", "branch": "Wirtschaft", "max": 2, "text": "Booster werden billiger"},
"gratisziehen": {"name": "Freies Ziehen", "branch": "Kampf", "max": 1, "text": "Das zweite Ziehen je Zug ist auch gratis"},
"startmana": {"name": "Starker Start", "branch": "Kampf", "max": 1, "text": "Mehr Mana im ersten Zug"},
}
## Kampfskills geben ein Merkmal (Game.PERKS) im Kampf.
const PERK_OF := {"gratisziehen": "ziehen_gratis", "startmana": "start_energie"}
## Packglück je Stufe: Common gibt ab, die höheren Stufen gewinnen.
const LUCK_SHIFT := {"Common": -8.0, "Rare": 3.0, "Super Rare": 2.0, "Ultra Rare": 2.0, "Ultimate Rare": 1.0}
## Erfahrung, die Level n verlangt: 100, 300, 600, 1000, 1500 … (50 · n · (n + 1)).
static func xp_for_level(n: int) -> int:
return 50 * n * (n + 1)
## Level zu einer Erfahrung.
static func level_for(xp: int) -> int:
var n := 0
while xp >= xp_for_level(n + 1):
n += 1
return n
static func in_branch(branch: String) -> Array:
return SKILLS.keys().filter(func(id): return SKILLS[id].branch == branch)
## Boosterchancen je Stufe (Reihenfolge Cards.RARITIES) mit Packglück luck.
static func pack_weights(luck: int) -> PackedFloat32Array:
var out := PackedFloat32Array()
for rar in Cards.RARITIES:
out.append(float(Profile.RARITY_WEIGHTS[rar]) + luck * float(LUCK_SHIFT[rar]))
return out
## Konkrete Wirkung eines Skills auf einer Stufe, als Text.
static func effect(id: String, rank: int) -> String:
match id:
"packglueck":
var w := pack_weights(rank)
return TranslationServer.translate("Chancen %d/%d/%d/%d/%d %%") % [int(w[0]), int(w[1]), int(w[2]), int(w[3]), int(w[4])]
"staub":
return TranslationServer.translate("+%d %% Staub") % (25 * rank)
"muenzen":
return TranslationServer.translate("+%d %% Münzen") % (10 * rank)
"packpreis":
return TranslationServer.translate("Neutral %d, Element %d Münzen") % [roundi(Profile.BOOSTER_COST * (1.0 - 0.1 * rank)), roundi(Profile.ELEMENT_BOOSTER_COST * (1.0 - 0.1 * rank))]
"gratisziehen":
return TranslationServer.translate("zwei Karten je Zug gratis" if rank > 0 else "eine Karte je Zug gratis")
"startmana":
return TranslationServer.translate("erster Zug: %d Mana") % (Game.START_MANA + (2 if rank > 0 else 0))
return ""
+1
View File
@@ -0,0 +1 @@
uid://bbcmk1gtfjoim
+1 -1
View File
@@ -68,6 +68,6 @@ static func preview_rect(_anchor: Rect2) -> Rect2:
return Rect2(Vector2(1070, 278), PREVIEW_SIZE)
## Zusatzzeile (Elementbonus, Eigenschaften) links unter den Elementzählern.
## Zusatzzeile (Merkmale, Eigenschaften) links unter den Elementpaaren.
static func info_rect(_preview: Rect2) -> Rect2:
return INFO
+53 -27
View File
@@ -31,7 +31,10 @@ var ai_timer := 0.0
var ai_task := -1 # WorkerThreadPool-Aufgabe der laufenden KI-Berechnung
var ai_copy: Game # Spielkopie, auf der die KI rechnet
var ai_box := {} # Ergebnis der Berechnung (vom Thread befüllt)
var reward := -1
var reward := -1 # gutgeschriebene Münzen (mit Kampagnenbonus)
var earned := 0 # Münzen aus Zügen, Karten und Tempo (Profile.battle_coins)
var fast_turns := 0 # eigene Züge unter Profile.FAST_TURN_SEC
var turn_start := 0 # Beginn des laufenden eigenen Zugs (ms)
var ended := false
var pad: BattleGamepad
var gp_zone: String:
@@ -72,7 +75,7 @@ var energy_label: Label
var msg_label: Label
var element_view: ElementView # eigene Elementleiste
var opp_element_view: ElementView # die des Gegners darüber
var info_label: Label # Vorschau: Elementbonus, Merkmale
var info_label: Label # Vorschau: Merkmale, Eigenschaften
var menu_layer: Control # Kartenmenü (Rechtsklick)
var menu_target = null # Karte oder Wesen, für das das Menü offen ist
var arrivals := {} # uid -> Rect2: Wesen, das gerade aus der Hand aufs Brett fliegt
@@ -81,6 +84,7 @@ var banner_turn := -1 # Zug, für den die Zugansage schon lief
var overlay: Control
var result_label: Label
var reward_label: Label
var detail_label: Label # Aufschlüsselung der Münzen
var notice_label: Label
var confirm: Control
var cursor_layer: Control
@@ -99,23 +103,20 @@ func _ready() -> void:
pad = BattleGamepad.new(self)
ai_delay = 0.75 / float(s.ai_speed)
_build_static()
if stage >= 0:
var r := Game.make_rng()
game = Game.new_battle_with(Campaign.opponent(stage, r), r, false, own_rules())
else:
game = Game.new_battle(Game.make_rng(), own_rules())
var r := Game.make_rng()
var opp: Game.Player = Campaign.opponent(stage) if stage >= 0 else app.profile.single_opponent(r)
game = Game.new_battle_with(opp, r, false, own_rules())
smart_ai = bool(app.profile.difficulty().smart)
_begin()
## Eigener Stapel und Kartenstufen, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1).
## Eigener Stapel und Kartenstufen und das eigene Merkmal (nach Kapitel 1).
func own_rules() -> Dictionary:
var p: Profile = app.profile
var rules := {"looks": p.looks(), "deck": p.deck_cards()}
if stage < 0:
rules["opponent_life"] = int(p.difficulty().life)
if p.perk != "" and p.perk_unlocked():
rules["human_perks"] = [p.perk]
var perks := p.battle_perks()
if not perks.is_empty():
rules["human_perks"] = perks
return rules
@@ -224,7 +225,8 @@ func _build_static() -> void:
overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
reward_label = Style.label(overlay, "", 26, Style.GOLD, Rect2(0, 335, 1280, 40), "center")
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 385, 1280, 80), "center")
detail_label = Style.label(overlay, "", 17, Style.MUTED, Rect2(0, 372, 1280, 28), "center")
notice_label = Style.label(overlay, "", 18, Style.GREEN, Rect2(0, 405, 1280, 70), "center")
Style.button(overlay, "Zurück zum Menü", Rect2(530, 480, 220, 50), leave, Color("285a3c"))
confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen")
@@ -459,8 +461,11 @@ func _exit_tree() -> void:
func _finish() -> void:
var won := game.winner == "human"
if game.current() == game.human:
_close_turn() # der Zug, der das Spiel entschied
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
reward = app.profile.record_battle(won, stage, factor)
earned = Profile.battle_coins(won, game.human.turns, game.human.cards_played, fast_turns, factor)
reward = app.profile.record_battle(won, earned, stage)
app.save()
app.sfx.play("win" if won else "lose")
refresh()
@@ -777,6 +782,7 @@ func end_turn() -> void:
return
cancel()
clear_undo()
_close_turn()
game.end_turn()
ai_timer = ai_delay
after_action()
@@ -839,11 +845,36 @@ func on_tile_click(cr: Game.Creature) -> void:
on_click("enemy", cr)
## Zugansage, wenn der eigene Zug beginnt.
## Zählt den laufenden eigenen Zug als schnell, wenn er unter Profile.FAST_TURN_SEC blieb.
func _close_turn() -> void:
if Time.get_ticks_msec() - turn_start < Profile.FAST_TURN_SEC * 1000:
fast_turns += 1
## Aufschlüsselung der Münzen fürs Ergebnisbild.
func reward_text() -> String:
var h := game.human
var s := "%s · %s · %s = %d" % [tr("%d Züge") % h.turns, tr("%d Karten") % h.cards_played, tr("%d schnelle Züge") % fast_turns,
Profile.COINS_TURN * h.turns + Profile.COINS_CARD * h.cards_played + Profile.COINS_FAST * fast_turns]
if game.winner == "human":
s += " · " + tr("Sieg ×2")
var d: Dictionary = app.profile.difficulty()
if stage < 0 and float(d.reward) != 1.0:
s += " · %s %s" % [tr(d.name), Style.factor(float(d.reward))]
var boosted := int(round(earned * app.profile.coin_factor()))
if boosted != earned:
s += " · %s %s" % [tr("Münzsegen"), Style.factor(app.profile.coin_factor())]
if reward > boosted:
s += " · " + tr("Kampagne +%d") % (reward - boosted)
return s
## Zugansage, wenn der eigene Zug beginnt; ab hier läuft die Zeit für den schnellen Zug.
func announce_turn() -> void:
if game == null or not my_turn() or banner_turn == game.turn:
return
banner_turn = game.turn
turn_start = Time.get_ticks_msec()
fx.banner(tr("Dein Zug"), Style.GOLD)
fx.sfx("turn")
@@ -871,23 +902,17 @@ func show_preview(card, entered: bool, owner_looks: Dictionary = {}, info: Strin
info_label.size = ir.size
## Eigene Handkarte: zeigt den Elementbonus, den sie offen gelegt bekäme.
## Eigene Handkarte in der Vorschau.
func show_hand_preview(card: Cards.Card, entered: bool, anchor: Rect2 = Rect2()) -> void:
if drag_ghost != null:
return
var a := game.forecast(perspective, card)
var info := ""
if a > 0:
info = tr("Elementbonus +%d") % a
show_preview(card, entered, perspective.looks, info, anchor)
show_preview(card, entered, perspective.looks, "", anchor)
## Was ein Wesen gerade hebt, was es kann und ob es schon angreift.
func creature_info(cr: Game.Creature) -> String:
var parts: Array[String] = []
if cr.aura > 0:
parts.append(tr("Elementbonus +%d") % cr.aura)
var perk := cr.power() - cr.attack - cr.aura
var perk := cr.power() - cr.attack
if perk > 0:
parts.append(tr("Merkmal +%d") % perk)
if cr.card.is_neutral():
@@ -902,10 +927,11 @@ func creature_info(cr: Game.Creature) -> String:
return " · ".join(parts)
## Elementvorteil des gewählten Angreifers gegen ein mögliches Ziel; gegen Verdeckte unbekannt.
## Konter eines möglichen Ziels gegen den gewählten Angreifer (Verschiebung durch sein Element);
## gegen Verdeckte unbekannt.
func matchup_for(cr: Game.Creature) -> int:
if selected != null and not cr.hidden and game.can_attack(selected, cr)[0]:
return Game.matchup_bonus(selected, cr)
return Game.matchup_bonus(cr, selected)
return 0
@@ -1139,7 +1165,6 @@ func refresh() -> void:
fx.glide(v, hand_arrivals[card])
hand_arrivals.erase(card)
v.lift = mine
v.bonus = g.forecast(h, card)
if fuse_first != null and mine and g.can_fuse(h, fuse_first, card)[0]:
v.border = Style.PURPLE
elif card == fuse_first:
@@ -1185,6 +1210,7 @@ func refresh() -> void:
result_label.text = "Sieg!" if won else "Niederlage"
result_label.add_theme_color_override("font_color", Style.GREEN if won else Style.RED)
reward_label.text = "+%d Münzen" % maxi(reward, 0)
detail_label.text = reward_text()
var notes: Array[String] = app.profile.take_notices()
if not notes.is_empty():
notice_label.text = "\n".join(notes)
+165 -36
View File
@@ -1,19 +1,32 @@
class_name BoosterScreen
extends Control
## Booster kaufen und öffnen: unten die Pack-Kacheln der offenen Kapitel (Neutral und die Elemente in
## Kapitelreihenfolge, gesperrte sind unsichtbar) mit ×1, ×5, ×10; die Karten fliegen aus dem Pack in ein Raster
## (ein Pack: fünf große, mehrere: zehn je Reihe, beste Stufe zuerst) und liegen verdeckt. Klick
## oder „Alle aufdecken“ dreht sie gestaffelt um, seltene blitzen auf; danach steht das Fazit.
const CARD_W := 200
const CARD_H := 280
const SMALL_W := 108
const SMALL_H := 151
const AREA := Rect2(40, 100, 1200, 460)
const QUANTITIES := [1, 5, 10]
var app
var cards: Array = [] # {"card", "rarity", "upgraded", "dust"} je Karte
var cards: Array = [] # {"card", "rarity", "new", "upgraded", "dust"} je Karte
var revealed := {}
var coins_label: Label
var msg_label: Label
var b_buy: Button
var summary_label: Label
var b_buy: Button # Neutral ×1 (Tests)
var b_all: Button
var scroll: ScrollContainer
var slots: Control
var fx_layer: Control
var fresh := {} # gerade aufgedeckt: wird umgedreht
var pack_tiles := {} # Element -> PackTile (Kapitelreihenfolge)
var pack_buttons := {} # Element -> der ×1-Knopf
var fresh := {} # gerade aufgedeckt: Index -> Verzögerung fürs Umdrehen
var fly_from := Vector2.ZERO # woher die Karten einfliegen (Mitte der gekauften Kachel)
var fly := false # beim nächsten refresh einfliegen
var last_pack := "" # zuletzt gekauftes Pack (pulsiert)
class CardBack extends Control:
@@ -39,28 +52,56 @@ class CardBack extends Control:
var rect := Rect2(Vector2.ZERO, size)
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD, 5 if has_focus() else 3), rect)
Style.border(self, rect.grow(-15), Color("5a3c82"), 2, 6)
Style.text(self, "?", 48, Style.GOLD, size / 2, "center", true)
Style.text(self, "?", 48 if size.x >= 180 else 30, Style.GOLD, size / 2, "center", true)
func slot_rect(i: int) -> Rect2:
var gap := 20
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)
## Pack-Kachel eines Elements: Symbol, Name, Preis; die Kaufknöpfe hängt der Bildschirm an.
class PackTile extends Control:
var element: String
var locked := false
var hint := ""
var pulse := false
var buy_buttons: Array = []
func _init(e: String, rect: Rect2) -> void:
element = e
position = rect.position
size = rect.size
pivot_offset = rect.size / 2
func _draw() -> void:
var col: Color = Cards.ELEMENT_COLORS[element]
draw_style_box(Style.box(Style.darker(col, 0.5), 10, Style.GOLD if pulse else Color(col, 0.7), 2), Rect2(Vector2.ZERO, size))
Style.element_icon(self, element, Vector2(32, 40), 34, Style.lighter(col, 0.4))
Style.text(self, tr(element), 16, Style.TEXT, Vector2(60, 30), "midleft", true)
Style.text(self, hint, 13, Style.MUTED, Vector2(60, 52), "midleft")
func _ready() -> void:
Style.header(self, "Booster", func(): app.show_screen(MenuScreen.new()))
coins_label = Style.chip(self, "", Rect2(860, 26, 240, 38))
fx_layer = Control.new()
fx_layer.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(fx_layer)
scroll = ScrollContainer.new()
scroll.position = AREA.position
scroll.size = AREA.size
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
slots = Control.new()
slots.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(slots)
msg_label = Style.label(self, "", 17, Style.RED, Rect2(0, 640, 1280, 22), "center")
b_buy = Style.button(self, "Booster kaufen (%d Münzen)" % Profile.BOOSTER_COST, Rect2(370, 690, 330, 52), buy, Color("5a4628"))
b_buy.add_theme_font_size_override("font_size", 20)
b_all = Style.button(self, "Alle aufdecken", Rect2(720, 690, 200, 52), reveal_all)
slots.mouse_filter = Control.MOUSE_FILTER_PASS
slots.custom_minimum_size = AREA.size
scroll.add_child(slots)
summary_label = Style.label(self, "", 16, Style.GOLD, Rect2(0, 566, 1280, 24), "center", true)
b_all = Style.button(self, "Alle aufdecken", Rect2(540, 594, 200, 40), reveal_all)
for i in Campaign.CHAPTER_ELEMENTS.size():
var e: String = Campaign.CHAPTER_ELEMENTS[i]
var tile := PackTile.new(e, Rect2(70 + i * 230, 644, 220, 136))
add_child(tile)
for k in QUANTITIES.size():
var b := Style.button(tile, "×%d" % QUANTITIES[k], Rect2(10 + k * 68, 88, 64, 36), buy.bind(e, QUANTITIES[k]))
b.add_theme_font_size_override("font_size", 14)
tile.buy_buttons.append(b)
pack_tiles[e] = tile
pack_buttons[e] = tile.buy_buttons[0]
b_buy = pack_buttons[Cards.NEUTRAL]
refresh()
@@ -69,19 +110,34 @@ func _input(event: InputEvent) -> void:
app.show_screen(MenuScreen.new())
func buy() -> void:
## Kauft n Packs eines Elements; die Karten liegen verdeckt, mehrere Packs nach Stufe sortiert.
func buy(element: String, n: int) -> void:
var p: Profile = app.profile
var got: Array = p.open_booster()
var before: int = p.coins
var got: Array = p.open_boosters(element, n)
if got.is_empty():
msg_label.text = "Nicht genug Münzen"
msg_label.add_theme_color_override("font_color", Style.RED)
return
app.save()
app.sfx.play("play")
if n > 1:
got.sort_custom(func(a, b): return Cards.RARITIES.find(a.rarity) > Cards.RARITIES.find(b.rarity))
cards = got
revealed = {}
msg_label.text = ""
fresh = {}
fly = true
var tile: PackTile = pack_tiles[element]
fly_from = tile.position + tile.size / 2
last_pack = element
scroll.scroll_vertical = 0
refresh()
_count_coins(before, p.coins)
## Die Münzen zählen sichtbar herunter.
func _count_coins(from: int, to: int) -> void:
if not Style.animations or from == to:
return
create_tween().tween_method(func(v: float): coins_label.text = "Münzen: %d" % int(round(v)), float(from), float(to), 0.5)
func reveal(i: int) -> void:
@@ -91,13 +147,17 @@ func reveal(i: int) -> void:
refresh()
## Deckt alles auf, von vorn nach hinten gestaffelt; ab Ultra Rare bleibt die Karte kurz allein.
func reveal_all() -> void:
fresh = {}
var k := 0
for i in Profile.BOOSTER_SIZE:
var delay := 0.0
var step := 0.12 if cards.size() <= Profile.BOOSTER_SIZE else 0.06
for i in cards.size():
if not revealed.has(i):
fresh[i] = k * 0.12
k += 1
fresh[i] = delay
delay += step
if Cards.RARITIES.find(cards[i].rarity) >= 3:
delay += 0.35
revealed[i] = true
app.sfx.play("play")
refresh()
@@ -113,7 +173,7 @@ func flip(v: CardView, delay: float, rarity: String) -> void:
tw.tween_property(v, "scale:x", 1.0, 0.18).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
if rarity == "Common":
return
var burst := Style.panel(fx_layer, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12)
var burst := Style.panel(slots, Rect2(v.position, v.size).grow(6), Color(Cards.RARITY_COLORS[rarity], 0.6), 12)
burst.pivot_offset = burst.size / 2
burst.modulate.a = 0.0
var bt := create_tween()
@@ -124,13 +184,71 @@ func flip(v: CardView, delay: float, rarity: String) -> void:
bt.tween_callback(burst.queue_free)
## Einflug einer verdeckten Karte aus dem Pack auf ihren Platz, reihenweise gestaffelt.
func fly_in(back: Control, target: Vector2, i: int, per_row: int) -> void:
if not Style.animations:
return
back.position = fly_from - AREA.position - back.size / 2
back.scale = Vector2(0.4, 0.4)
var tw := create_tween().set_parallel()
var delay := (i / per_row) * 0.1 + (i % per_row) * 0.03
tw.tween_property(back, "position", target, 0.3).set_delay(delay).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tw.tween_property(back, "scale", Vector2.ONE, 0.3).set_delay(delay).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
## Platz der i-ten Karte: ein Pack fünf große in einer Reihe, sonst zehn kleine je Reihe.
func slot_rect(i: int) -> Rect2:
var single := cards.size() <= Profile.BOOSTER_SIZE
var w := CARD_W if single else SMALL_W
var h := CARD_H if single else SMALL_H
var per_row := 5 if single else 10
var gap := 20 if single else 12
var x0 := (AREA.size.x - per_row * w - (per_row - 1) * gap) / 2
var row_h := h + (60 if single else 44)
return Rect2(x0 + (i % per_row) * (w + gap), 10 + (i / per_row) * row_h, w, h)
## Fazit nach dem Aufdecken: neue Karten, Aufwertungen, Staub.
func summary() -> String:
if cards.is_empty() or revealed.size() < cards.size():
return ""
var fresh_n := 0
var up := 0
var dust := 0
for res in cards:
if res.get("new", false):
fresh_n += 1
elif res.upgraded:
up += 1
dust += int(res.dust)
return tr("%d neu · %d aufgewertet · +%d Staub") % [fresh_n, up, dust]
func refresh() -> void:
var p: Profile = app.profile
coins_label.text = "Münzen: %d" % p.coins
b_buy.disabled = p.coins < Profile.BOOSTER_COST
b_all.disabled = cards.is_empty() or revealed.size() >= Profile.BOOSTER_SIZE
var open: Array = p.unlocked_elements()
var x0 := (1280 - open.size() * 220 - (open.size() - 1) * 10) / 2
for e in pack_tiles:
var tile: PackTile = pack_tiles[e]
tile.locked = not p.booster_unlocked(e)
tile.visible = not tile.locked
if not tile.locked:
tile.position = Vector2(x0 + open.find(e) * 230, 644)
tile.hint = tr("%d Münzen") % p.pack_price(e)
for k in QUANTITIES.size():
tile.buy_buttons[k].disabled = tile.locked or p.coins < p.pack_price(e) * QUANTITIES[k]
tile.pulse = e == last_pack and not cards.is_empty() and revealed.size() >= cards.size()
tile.queue_redraw()
b_all.disabled = cards.is_empty() or revealed.size() >= cards.size()
summary_label.text = summary()
Style.clear(slots)
for i in Profile.BOOSTER_SIZE:
var single := cards.size() <= Profile.BOOSTER_SIZE
var per_row := 5 if single else 10
var n := maxi(cards.size(), Profile.BOOSTER_SIZE)
var rows := (n + per_row - 1) / per_row
slots.custom_minimum_size = Vector2(AREA.size.x, maxf(AREA.size.y, slot_rect(n - 1).end.y + 60))
for i in n:
var r := slot_rect(i)
if cards.is_empty():
Style.panel(slots, r, Style.PANEL, 8, Style.PANEL_LIGHT, 2)
@@ -142,16 +260,27 @@ func refresh() -> void:
slots.add_child(v)
if fresh.has(i):
flip(v, fresh[i], res.rarity)
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)
var fs := 16 if single else 12
Style.label(slots, tr(res.rarity), fs, Cards.RARITY_COLORS[res.rarity], Rect2(r.position.x - 10, r.end.y + 6, r.size.x + 20, 20), "center", true)
var y2 := r.end.y + (30 if single else 24)
if res.get("new", false):
Style.label(slots, "Neu!", 16, Style.GOLD, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
Style.label(slots, "Neu!", fs, Style.GOLD, Rect2(r.position.x, y2, r.size.x, 20), "center", true)
elif res.upgraded:
Style.label(slots, "Aufgewertet", 16, Style.GREEN, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center", true)
Style.label(slots, "Aufgewertet", fs, Style.GREEN, Rect2(r.position.x - 10, y2, r.size.x + 20, 20), "center", true)
else:
Style.label(slots, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")
Style.label(slots, "+%d Staub" % res.dust, fs - 2, Style.MUTED, Rect2(r.position.x, y2, r.size.x, 20), "center")
else:
var back := CardBack.new(r.size)
back.position = r.position
back.pivot_offset = r.size / 2
back.clicked.connect(reveal.bind(i))
slots.add_child(back)
if fly:
fly_in(back, r.position, i, per_row)
fly = false
fresh = {}
if rows > 0 and last_pack != "" and pack_tiles[last_pack].pulse and Style.animations:
var tile: PackTile = pack_tiles[last_pack]
var tw := tile.create_tween().set_loops(3)
tw.tween_property(tile, "scale", Vector2(1.04, 1.04), 0.35).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tw.tween_property(tile, "scale", Vector2.ONE, 0.35).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
+11 -8
View File
@@ -1,6 +1,7 @@
class_name CampaignScreen
extends Control
## Kampagne als Pfad: zehn Stationen je Kapitel, darunter die gewählte Stufe im Detail.
## Kampagne als Pfad: zehn Stationen je Kapitel (fünf Kapitel: Neutral, dann je ein Element),
## darunter die gewählte Stufe im Detail.
var app
var chapter := 1
@@ -11,7 +12,7 @@ var node_buttons: Array = []
func _ready() -> void:
var p: Profile = app.profile
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTERS.size())
chapter = mini(Campaign.chapter(p.campaign_stage), Campaign.CHAPTER_ELEMENTS.size())
selected = mini(p.campaign_stage, Campaign.total() - 1)
build()
@@ -21,14 +22,16 @@ func build() -> void:
node_buttons = []
var p: Profile = app.profile
Style.header(self, "Kampagne", func(): app.show_screen(MenuScreen.new()))
var n := Campaign.STAGES.size()
var n := Campaign.STAGES_PER_CHAPTER
var x := 40
for c in Campaign.CHAPTERS.size():
var b := Style.button(self, "Kapitel %d" % (c + 1), Rect2(x, 88, 150, 38), set_chapter.bind(c + 1), Style.PRIMARY if chapter == c + 1 else null)
for c in Campaign.CHAPTER_ELEMENTS.size():
var b := Style.button(self, "Kapitel %d" % (c + 1), Rect2(x, 88, 130, 38), set_chapter.bind(c + 1), Style.PRIMARY if chapter == c + 1 else null)
b.disabled = p.campaign_stage < c * n
x += 160
x += 138
var element := Campaign.element_of(chapter)
Style.label(self, tr(element), 20, Cards.ELEMENT_COLORS[element], Rect2(0, 136, 1280, 30), "center", true)
if p.perk_unlocked():
Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(760, 96, 130, 24), "right")
Style.label(self, "Dein Merkmal:", 15, Style.MUTED, Rect2(740, 96, 150, 24), "right")
perk_box = OptionButton.new()
perk_box.position = Vector2(900, 88)
perk_box.size = Vector2(340, 38)
@@ -109,7 +112,7 @@ func build() -> void:
func set_chapter(c: int) -> void:
chapter = c
var p: Profile = app.profile
var first := (c - 1) * Campaign.STAGES.size()
var first := (c - 1) * Campaign.STAGES_PER_CHAPTER
selected = p.campaign_stage if Campaign.chapter(mini(p.campaign_stage, Campaign.total() - 1)) == c else first
build()
+6 -4
View File
@@ -14,10 +14,10 @@ var card: Cards.Card
var border = null # Color oder null (dann Rahmen der Seltenheitsstufe)
var rarity := "Common" # nur Optik
var dim := false
var bonus := 0 # Vorschau im Kampf: Elementbonus, den die Karte offen gelegt bekäme
var lift := false # hebt sich beim Zeigen an (Hand)
var drag_enabled := false # Hand im Kampf: Drücken meldet pressed statt clicked
var drop := false # Ziel der gerade gezogenen Karte
var marked := false # Haken: die Karte ist im Stapel (Deck)
## focusable: per Gamepad oder Tab anwählbar (Sammlung, Booster); A bzw. Enter wirkt wie ein Klick.
@@ -92,15 +92,17 @@ func _draw() -> void:
var pic_bottom := size.y - r_stat - 4
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
Style.picture(self, card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rarity, seed)
Style.rarity_sparkle(self, rect, rarity, seed) # vor den Werten, damit nichts die Zahlen überlagert
# Unten: Angriff links, Leben rechts
Style.stat_circle(self, Vector2(r_stat - 2, size.y - r_stat + 2), r_stat, str(card.attack), Style.AMBER, f_stat, Color.WHITE, shiny)
Style.stat_circle(self, Vector2(size.x - r_stat + 2, size.y - r_stat + 2), r_stat, str(card.health), Style.LIFE, f_stat, Color.WHITE, shiny)
if bonus > 0:
Style.text_outlined(self, "+%d" % bonus, 13 if big else 11, Style.GREEN, Vector2(r_stat * 2 + 4, size.y - r_stat + 2), "midleft", 3)
Style.card_sheen(self, rect, rarity, seed)
Style.border(self, rect, frame, 3 if big else 2, 8)
Style.cost_crystal(self, Vector2(r_cost + 4, r_cost + 4), r_cost, card.cost, f_stat, shiny)
Style.rarity_sparkle(self, rect, rarity, seed)
if marked:
var mc := Vector2(size.x / 2, size.y - r_stat + 2)
draw_circle(mc, r_stat * 0.6, Style.GREEN)
Style.text(self, "✓", f_stat - 4, Color.WHITE, mc, "center", true)
if dim:
draw_rect(rect, Color(0, 0, 0, 0.47))
if has_focus():
+69
View File
@@ -0,0 +1,69 @@
class_name CheatScreen
extends Control
## Cheats zum Ausprobieren: Münzen, Staub, Level, Skills, Kampagnenkapitel und Sammlung direkt setzen.
## Jeder Knopf speichert sofort; oben steht der aktuelle Stand.
var app
var status_label: Label
var msg_label: Label
func _ready() -> void:
Style.header(self, "Cheats", func(): app.show_screen(MenuScreen.new()))
status_label = Style.label(self, "", 16, Style.GOLD, Rect2(40, 90, 1200, 48), "left", true)
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD
var groups := [
["Währung", [["+1.000 Münzen", func(): app.profile.coins += 1000], ["+10.000 Münzen", func(): app.profile.coins += 10000],
["+1.000 Staub", func(): app.profile.dust += 1000], ["+10.000 Staub", func(): app.profile.dust += 10000]]],
["Erfahrung", [["+1 Level", func(): app.profile.xp = Skills.xp_for_level(app.profile.player_level() + 1)],
["+5 Level", func(): app.profile.xp = Skills.xp_for_level(app.profile.player_level() + 5)],
["Skills zurücksetzen", func(): app.profile.skills = {}], ["Erfahrung auf 0", func():
app.profile.xp = 0
app.profile.skills = {}]]],
["Kampagne", [["Kapitel 2 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 10)],
["Kapitel 3 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 20)],
["Kapitel 4 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 30)],
["Kapitel 5 frei", func(): app.profile.campaign_stage = maxi(app.profile.campaign_stage, 40)],
["Kampagne zurücksetzen", func(): app.profile.campaign_stage = 0]]],
["Sammlung", [["Alle Karten (Common)", func(): _give_all(0)], ["Alle Karten Ultimate Rare", func(): _give_all(4)],
["Eigene Karten +1 Stufe", func():
for id in app.profile.collection:
app.profile.collection[id] = mini(int(app.profile.collection[id]) + 1, Cards.RARITIES.size() - 1)],
["Sammlung zurücksetzen", func(): app.profile.give_start()]]],
]
var y := 160
for g in groups:
Style.label(self, g[0], 18, Style.TEXT, Rect2(40, y, 200, 30), "left", true)
var x := 250
for entry in g[1]:
var b := Style.button(self, entry[0], Rect2(x, y - 4, 190, 40), _apply.bind(entry[1]))
b.add_theme_font_size_override("font_size", 14)
x += 198
y += 70
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(40, y + 10, 1200, 24), "left")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
## Alle Karten der Sammlung geben, in Stufe level; der Stapel bleibt.
func _give_all(level: int) -> void:
for c in Cards.all():
app.profile.collection[c.id] = level
func _apply(action: Callable) -> void:
action.call()
app.save()
app.sfx.play("click")
msg_label.text = tr("Gespeichert")
refresh()
func refresh() -> void:
var p: Profile = app.profile
status_label.text = tr("Münzen %d · Staub %d · EP %d (Level %d, %d Skillpunkte frei) · Kampagne %d/%d · Sammlung %d Karten, Stapel %d") % [
p.coins, p.dust, p.xp, p.player_level(), p.skill_points(), p.campaign_stage, Campaign.total(), p.collection.size(), p.deck.size()]
+1
View File
@@ -0,0 +1 @@
uid://dluwr50n0sd17
+4 -3
View File
@@ -21,7 +21,7 @@ var b_craft: Button
func _ready() -> void:
Style.header(self, "Sammlung", func(): app.show_screen(MenuScreen.new()))
Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
var filters: Array = Cards.ELEMENTS + [Cards.NEUTRAL]
var filters: Array = app.profile.unlocked_elements() # nur Elemente offener Kapitel
for i in filters.size():
var e: String = filters[i]
var fb := Style.button(self, e, Rect2(108 + i * 82, 86, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
@@ -60,9 +60,10 @@ func set_filter(e: String) -> void:
refresh()
## Eigene Karten zuerst (höhere Stufen vorn), dann die fehlenden, sonst in Kartenreihenfolge.
## Karten der offenen Elemente: eigene zuerst (höhere Stufen vorn), dann die fehlenden, sonst in
## Kartenreihenfolge.
func cards() -> Array:
var out: Array = Cards.all().duplicate()
var out: Array = app.profile.unlocked_cards()
if filter != "":
out = out.filter(func(c): return filter in c.elements)
if search != "":
+4 -10
View File
@@ -12,7 +12,7 @@ var width := 2
var flash := false # kurz rot: wurde gerade angegriffen
var slot: Vector2 # Zielposition im Raster
var busy := false # wird gerade animiert (kein Neupositionieren)
var matchup := 0 # Elementvorteil des gewählten Angreifers gegen dieses fremde Wesen (Game.matchup_bonus); die Werte zeigen dann, was ihn erwartet
var matchup := 0 # fremdes Wesen: sein Konter gegen den gewählten Angreifer (Game.matchup_bonus); die Werte zeigen, was ihn erwartet
var attack_shift := 0 # eigener Angreifer: Elementvorteil gegen das Ziel unter der Maus
var doomed := false # stirbt bei dem Angriff, der gerade unter der Maus liegt
var secret := false # verdecktes fremdes Wesen: nur der Rücken ist zu sehen
@@ -78,7 +78,7 @@ func _draw() -> void:
var pic_bottom := size.y - r_stat - 4
var r_pic := minf(size.x * 0.42, (pic_bottom - pic_top) / 2)
Style.picture(self, creature.card, Vector2(size.x / 2, (pic_top + pic_bottom) / 2), r_pic, rar, seed)
# Unter dem Namen links die Eigenschaften (in der Farbe des Elements, das sie gibt), rechts der Elementbonus
# Unter dem Namen links die Eigenschaften (in der Farbe des Elements, das sie gibt)
var icon := 6.0 if small else 7.0
var y := top_h + 6 + icon
for k in creature.keywords():
@@ -88,11 +88,7 @@ func _draw() -> void:
col = Style.lighter(Cards.ELEMENT_COLORS[e], 0.45)
Style.keyword_icon(self, k, Vector2(6 + icon, y), icon, col)
y += icon * 2 + 6
if creature.aura != 0:
var badge := "+%d" % creature.aura if creature.aura > 0 else "−%d" % -creature.aura
var bw := Style.FONT_BOLD.get_string_size(badge, HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(10)).x + 8
draw_rect(Rect2(size.x - bw - 4, top_h + 5, bw, 16), Color(0.05, 0.05, 0.08, 0.75))
Style.text(self, badge, 10, Style.GREEN if creature.aura > 0 else Style.RED, Vector2(size.x - 4 - bw / 2, top_h + 13), "center", true)
Style.rarity_sparkle(self, rect, rar, seed) # vor den Werten, damit nichts die Zahlen überlagert
_draw_stats(15 if small else 17, r_stat, shiny)
Style.card_sheen(self, rect, rar, seed)
Style.border(self, rect, bcol, 4 if flash else width, 6)
@@ -103,7 +99,6 @@ func _draw() -> void:
if doomed:
draw_rect(Rect2(0, size.y * 0.5 - 12, size.x, 24), Color(0.05, 0.02, 0.02, 0.8))
Style.text_outlined(self, tr("stirbt"), 14, Style.RED, Vector2(size.x / 2, size.y * 0.5), "center", 3)
Style.rarity_sparkle(self, rect, rar, seed)
_draw_drop()
@@ -128,8 +123,7 @@ func _draw_back(rect: Rect2) -> void:
## das Leben bleibt echt. Eigener Angreifer über einem Ziel: sein Angriff mit Elementvorteil.
func shown_stats() -> Array:
if matchup != 0:
var atk := maxi(0, creature.power() - matchup)
return [atk, creature.health]
return [maxi(0, creature.power() + matchup), creature.health]
if attack_shift != 0:
return [maxi(0, creature.power() + attack_shift), creature.health]
return [creature.power(), creature.health]
+190 -25
View File
@@ -1,38 +1,139 @@
class_name DeckScreen
extends Control
## Deck zusammenstellen: alle eigenen Karten im Raster, ein Klick nimmt eine Karte in den
## Stapel oder wieder heraus. Karten außerhalb des Stapels sind abgedunkelt. Der Stapel hält
## 40 bis 60 Karten; „Auffüllen“ nimmt die wertvollsten eigenen Karten des gewählten Filters.
## „Stärke“ ist der mittlere Kartenwert (100 Grundkarten … 500 Ultimate Rare).
## Deck zusammenstellen: alle eigenen Karten im Raster (Filter nach Element und Mana, Stapelkarten
## zuerst, dann höchste Stufen vorn), ein Klick nimmt eine Karte in den Stapel oder wieder heraus. Stapelkarten tragen einen Haken, die anderen sind
## abgedunkelt. Rechts Stapelgröße, Stärke (mittlerer Kartenwert, 100 Grundkarten … 500 Ultimate
## Rare), Manakurve, Elementanteile und die Vorschau der gezeigten Karte. Der Stapel hält DECK_MIN
## bis DECK_MAX Karten; „Auffüllen“ nimmt die wertvollsten eigenen Karten des gewählten Filters.
const PREVIEW := Rect2(940, 362, 180, 252)
const MAX_COST := 10
var app
var filter := ""
var filter := "" # Element oder ""
var cost_filter := -1 # Kosten 0 bis 10 oder -1
var grid: CardGrid
var all_button: Button
var element_buttons := {} # Element -> Knopf mit Symbol
var cost_buttons: Array = [] # je Kosten ein Knopf
var count_label: Label
var strength_label: Label
var curve: CurveView
var share: ElementShare
var preview_holder: Control
var shown_card: Cards.Card = null # Karte in der Vorschau
var msg_label: Label
## Filterknopf mit dem Symbol eines Elements.
class ElementButton extends Button:
var element: String
func _init(e: String) -> void:
element = e
tooltip_text = tr(e)
func _draw() -> void:
Style.element_icon(self, element, size / 2, size.y * 0.6, Style.lighter(Cards.ELEMENT_COLORS[element], 0.4))
## Manakurve: ein Balken je Kosten 0 bis 10 mit der Zahl der Stapelkarten.
class CurveView extends Control:
var counts: Array = []
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_counts(c: Array) -> void:
counts = c
queue_redraw()
func _draw() -> void:
var n := 11
var gap := 4.0
var bw := (size.x - gap * (n - 1)) / n
var top := 16.0
var base := size.y - 16.0
var mx := 1
for v in counts:
mx = maxi(mx, int(v))
for i in n:
var v: int = int(counts[i]) if i < counts.size() else 0
var x := i * (bw + gap)
var h := (base - top) * v / mx
draw_rect(Rect2(x, base - 2, bw, 2), Style.PANEL_LIGHT)
if v > 0:
draw_rect(Rect2(x, base - h, bw, h), Style.BLUE)
Style.text(self, str(v), 11, Style.TEXT, Vector2(x + bw / 2, base - h - 8), "center", true)
Style.text(self, str(i), 11, Style.MUTED, Vector2(x + bw / 2, base + 8), "center")
## Elementanteile des Stapels: je Element eine farbige Kachel mit der Kartenzahl.
class ElementShare extends Control:
var counts := {}
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_counts(c: Dictionary) -> void:
counts = c
queue_redraw()
func shown() -> Array:
return ElementView.all().filter(func(e): return int(counts.get(e, 0)) > 0)
func _draw() -> void:
var items := shown()
if items.is_empty():
return
var w := size.x / items.size()
for i in items.size():
var e: String = items[i]
var col: Color = Cards.ELEMENT_COLORS[e]
var r := Rect2(i * w + 2, 0, w - 4, size.y)
draw_style_box(Style.box(Color(col, 0.25), 6, Color(col, 0.7), 1), r)
Style.text(self, "%s %d" % [tr(e), int(counts[e])], 12, Style.TEXT, r.get_center(), "center", true)
func _ready() -> void:
Style.header(self, "Deck", func(): app.show_screen(MenuScreen.new()))
Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
all_button = Style.button(self, "Alle", Rect2(40, 86, 60, 34), set_filter.bind(""))
var filters: Array = Cards.ELEMENTS + [Cards.NEUTRAL]
for i in filters.size():
var e: String = filters[i]
var fb := Style.button(self, e, Rect2(108 + i * 82, 86, 78, 34), set_filter.bind(e), Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
fb.add_theme_font_size_override("font_size", 14)
var fb := ElementButton.new(e)
fb.position = Vector2(108 + i * 40, 86)
fb.size = Vector2(34, 34)
fb.pressed.connect(set_filter.bind(e))
Style.juice(fb)
add_child(fb)
element_buttons[e] = fb
for c in MAX_COST + 1:
var cb := Style.button(self, str(c), Rect2(330 + c * 40, 86, 36, 34), set_cost.bind(c))
cb.add_theme_font_size_override("font_size", 14)
cost_buttons.append(cb)
grid = CardGrid.new(Rect2(40, 132, CardGrid.COLS * (CardGrid.CW + CardGrid.GAP) + 10, 650))
grid.card_clicked.connect(toggle)
add_child(grid)
Style.panel(self, Rect2(820, 86, 420, 696))
count_label = Style.chip(self, "", Rect2(860, 100, 340, 36), Style.BLUE)
strength_label = Style.chip(self, "", Rect2(860, 144, 340, 36), Style.GOLD)
var hint := Style.label(self, "", 15, Style.MUTED, Rect2(840, 188, 380, 66), "center")
hint.autowrap_mode = TextServer.AUTOWRAP_WORD # vor dem Text, sonst wächst die Breite auf eine Zeile
hint.text = "Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die wertvollsten Karten des Filters."
Style.button(self, "Auffüllen", Rect2(840, 262, 380, 46), fill_up)
Style.button(self, "Leeren", Rect2(840, 318, 380, 46), clear_deck, Color("5a3c28"))
msg_label = Style.label(self, "", 15, Style.RED, Rect2(840, 382, 380, 48), "center")
Style.label(self, "Manakurve", 13, Style.MUTED, Rect2(850, 190, 360, 20), "center")
curve = CurveView.new(Rect2(850, 212, 360, 100))
add_child(curve)
share = ElementShare.new(Rect2(850, 322, 360, 30))
add_child(share)
preview_holder = Control.new()
preview_holder.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(preview_holder)
Style.button(self, "Auffüllen", Rect2(840, 626, 380, 44), fill_up, Style.PRIMARY)
Style.button(self, "Leeren", Rect2(840, 678, 380, 40), clear_deck)
msg_label = Style.label(self, "", 14, Style.MUTED, Rect2(840, 726, 380, 48), "center")
msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD
refresh()
@@ -46,15 +147,34 @@ func set_filter(e: String) -> void:
refresh()
## Manafilter: derselbe Knopf noch einmal hebt ihn auf.
func set_cost(c: int) -> void:
cost_filter = -1 if cost_filter == c else c
refresh()
## Hebt den aktiven Filterknopf hervor.
func _mark(b: Button, active: bool, base: Color) -> void:
b.add_theme_stylebox_override("normal", Style.box(Style.lighter(base) if active else base, 8, Style.GOLD if active else Style.MUTED, 2 if active else 1))
b.add_theme_stylebox_override("hover", Style.box(Style.lighter(base), 8, Style.GOLD if active else Style.MUTED, 2))
b.add_theme_stylebox_override("pressed", Style.box(Style.darker(base), 8, Style.GOLD if active else Style.MUTED, 2))
func toggle(card: Cards.Card) -> void:
var err: String = app.profile.toggle_deck(card.id)
msg_label.text = err
say(err)
if err == "":
app.sfx.play("click")
app.save()
refresh()
## Fehler rot, sonst der Hinweis.
func say(err: String) -> void:
msg_label.text = tr(err) if err != "" else tr("Klick auf eine Karte: rein oder raus.")
msg_label.add_theme_color_override("font_color", Style.RED if err != "" else Style.MUTED)
## Füllt mit den wertvollsten eigenen Karten des gewählten Filters bis zum Höchstwert auf.
func fill_up() -> void:
var p: Profile = app.profile
@@ -72,30 +192,75 @@ func clear_deck() -> void:
refresh()
## Eigene Karten, die im Stapel zuerst, dann höchste Stufen vorn, dann nach Kosten.
## Sortierschlüssel einer Karte: im Stapel zuerst, dann höchste Stufen vorn, dann Kosten, dann ID.
func sort_key(c: Cards.Card) -> Array:
var p: Profile = app.profile
return [0 if c.id in p.deck else 1, -p.level(c.id), c.cost, c.id]
## Eigene Karten nach Element- und Manafilter, sortiert (sort_key).
func cards() -> Array:
var p: Profile = app.profile
var out: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
if filter != "":
out = out.filter(func(c): return filter in c.elements)
out.sort_custom(func(a, b):
var ia: bool = a.id in p.deck
var ib: bool = b.id in p.deck
if ia != ib:
return ia
if p.level(a.id) != p.level(b.id):
return p.level(a.id) > p.level(b.id)
return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
if cost_filter >= 0:
out = out.filter(func(c): return c.cost == cost_filter)
out.sort_custom(func(a, b): return sort_key(a) < sort_key(b))
return out
## Vorschau der gezeigten Karte in ihrer Stufe.
func show_card(card: Cards.Card) -> void:
var p: Profile = app.profile
shown_card = card
Style.clear(preview_holder)
var v := CardView.new(card.leveled(p.level(card.id)), PREVIEW.size, false, p.rarity(card.id))
v.position = PREVIEW.position
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
func refresh() -> void:
var p: Profile = app.profile
count_label.text = tr("Stapel: %d Karten (%d bis %d)") % [p.deck.size(), Game.DECK_MIN, Game.DECK_MAX]
count_label.add_theme_color_override("font_color", Style.RED if p.deck.size() < Game.DECK_MIN else Style.BLUE)
strength_label.text = tr("Stärke %d") % DeckGen.strength(p.deck, p.collection)
_mark(all_button, filter == "", Style.PANEL_LIGHT)
var open: Array = p.unlocked_elements() # nur Elemente offener Kapitel
for e in element_buttons:
var eb: Button = element_buttons[e]
eb.visible = e in open
if eb.visible:
eb.position = Vector2(108 + open.find(e) * 40, 86)
_mark(eb, filter == e, Style.darker(Cards.ELEMENT_COLORS[e], 0.8))
for c in cost_buttons.size():
_mark(cost_buttons[c], cost_filter == c, Style.PANEL_LIGHT)
var counts := []
counts.resize(11)
counts.fill(0)
var per_element := {}
for id in p.deck:
var c := Cards.by_id(id)
counts[clampi(c.cost, 0, 10)] += 1
per_element[c.elements[0]] = int(per_element.get(c.elements[0], 0)) + 1
curve.set_counts(counts)
share.set_counts(per_element)
if msg_label.text == "":
say("")
var shown := cards()
grid.fill(shown.map(func(c): return c.leveled(p.level(c.id))), func(c): return Cards.RARITIES[c.look])
var views := grid.views()
for i in views.size():
views[i].dim = not (shown[i].id in p.deck)
var in_deck: bool = shown[i].id in p.deck
views[i].dim = not in_deck
views[i].marked = in_deck
views[i].hover.connect(func(c, entered):
if entered:
show_card(c))
if shown_card != null and p.owns(shown_card.id):
show_card(shown_card)
elif shown.is_empty():
Style.clear(preview_holder)
else:
show_card(shown[0])
+10 -22
View File
@@ -1,14 +1,12 @@
class_name ElementView
extends Control
## Elementzähler einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente da, bei denen
## etwas passiert ist: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je
## offenem Wesen, beide: Effekt aktiv; Neutral hat keinen), die Zahl der ausgespielten Karten
## und ab Game.ELEMENT_COUNT der Bonus +1/+1.
## Elementpaare einer Seite als Liste neben ihrem Brett. Es stehen nur Elemente mit offenen Wesen
## da: je Zeile Name, zweimal das Symbol seines Sondereffekts (leuchtend je offenem Wesen, beide:
## Effekt aktiv; Neutral hat keinen) und die Zahl der offenen Wesen.
const ROW := 32.0
var own := {} # offene eigene Wesen je Element
var played := {} # ausgespielte Karten je Element
var own := {} # offene eigene Wesen je Element
func _init(rect: Rect2) -> void:
@@ -17,7 +15,6 @@ func _init(rect: Rect2) -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
for e in all():
own[e] = 0
played[e] = 0
static func all() -> Array:
@@ -27,7 +24,6 @@ static func all() -> Array:
func update_from(_g: Game, me: Game.Player) -> void:
for e in all():
own[e] = me.element_count(e) if me != null else 0
played[e] = int(me.played.get(e, 0)) if me != null else 0
queue_redraw()
@@ -35,24 +31,18 @@ func paired(e: String) -> bool:
return Cards.ELEMENT_KEYWORD.has(e) and int(own.get(e, 0)) >= Game.ELEMENT_PAIR
func bonus(e: String) -> int:
return int(played.get(e, 0)) / Game.ELEMENT_COUNT
## Elemente, bei denen sich etwas getan hat (offene Wesen oder ausgespielte Karten).
## Elemente mit offenen Wesen.
func shown() -> Array:
return all().filter(func(e): return int(own.get(e, 0)) > 0 or int(played.get(e, 0)) > 0)
return all().filter(func(e): return int(own.get(e, 0)) > 0)
## Der Inhalt als Text (Tests, Protokoll): Element offen/gespielt, dazu Effekt und Bonus.
## Der Inhalt als Text (Tests, Protokoll): Element und offene Wesen, dazu der Effekt.
func text() -> String:
var parts: Array[String] = []
for e in shown():
var s := "%s %d/%d" % [tr(e), int(own.get(e, 0)), int(played.get(e, 0))]
var s := "%s %d" % [tr(e), int(own.get(e, 0))]
if paired(e):
s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e])
if bonus(e) > 0:
s += " (+%d/+%d)" % [bonus(e), bonus(e)]
parts.append(s)
return " · ".join(parts)
@@ -61,7 +51,7 @@ func _draw() -> void:
var y := ROW / 2
for e in shown():
var col: Color = Cards.ELEMENT_COLORS[e]
var active := paired(e) or bonus(e) > 0
var active := paired(e)
draw_style_box(Style.box(Color(col, 0.18 if active else 0.1), 6, Color(col, 0.6 if active else 0.0), 1 if active else 0), Rect2(0, y - ROW / 2 + 2, size.x, ROW - 4))
var px := 10.0
Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, y), "midleft", active)
@@ -71,7 +61,5 @@ func _draw() -> void:
var filled := k < int(own.get(e, 0))
Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, y), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3))
px += Game.ELEMENT_PAIR * 24 + 8
Style.text(self, str(int(played.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true)
if bonus(e) > 0:
Style.text(self, "+%d/+%d" % [bonus(e), bonus(e)], 12, Style.GREEN, Vector2(size.x - 6, y), "midright", true)
Style.text(self, str(int(own.get(e, 0))), 15, Style.lighter(col, 0.45), Vector2(px, y), "midleft", true)
y += ROW
+3 -3
View File
@@ -18,7 +18,7 @@ func _ready() -> void:
add_child(sfx)
profile = Profile.load_or_new(save_path)
apply_settings()
show_screen(MenuScreen.new() if profile.started() else StartDeckScreen.new())
show_screen(MenuScreen.new())
func apply_settings() -> void:
@@ -30,12 +30,12 @@ func apply_settings() -> void:
theme = Style.make_theme()
## Neues Profil: Sammlung und Fortschritt werden verworfen, das Startdeck wird neu gewählt.
## Neues Profil: Sammlung und Fortschritt werden verworfen, zurück bleiben die neutralen Karten.
func reset_profile() -> void:
profile = Profile.starter()
save()
apply_settings()
show_screen(StartDeckScreen.new())
show_screen(MenuScreen.new())
func set_language(code: String) -> void:
+12 -4
View File
@@ -1,6 +1,7 @@
class_name MenuScreen
extends Control
## Hauptmenü: links Titel und Spielarten, rechts drei Karten der eigenen Sammlung als Schaufenster.
## Hauptmenü: links Titel und Spielarten (darunter Booster, Sammlung, Skilltree, dann Einstellungen und
## Beenden), rechts drei Karten der eigenen Sammlung als Schaufenster, unten rechts die Cheats.
var app
var coins_label: Label
@@ -25,10 +26,17 @@ func _ready() -> void:
var x := 80
for entry in [["Booster öffnen", func(): app.show_screen(BoosterScreen.new())],
["Sammlung", func(): app.show_screen(CollectionScreen.new())],
["Einstellungen", func(): app.show_screen(SettingsScreen.new())],
["Skilltree", func(): app.show_screen(SkillScreen.new())]]:
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
x += 176
y += 56
x = 80
for entry in [["Einstellungen", func(): app.show_screen(SettingsScreen.new())],
["Beenden", func(): get_tree().quit()]]:
Style.button(self, entry[0], Rect2(x, y, 166, 44), entry[1])
x += 176
var cheats := Style.button(self, "Cheats", Rect2(1120, 744, 120, 36), func(): app.show_screen(CheatScreen.new()))
cheats.add_theme_font_size_override("font_size", 14)
notice_label = Style.label(self, "", 16, Style.GREEN, Rect2(80, y + 70, 760, 60), "left")
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD
_showcase(p)
@@ -55,8 +63,8 @@ func _showcase(p: Profile) -> void:
func refresh() -> void:
var p: Profile = app.profile
coins_label.text = tr("Münzen: %d · Staub: %d") % [p.coins, p.dust]
coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d") % [
p.collection.size(), Cards.all().size(), p.campaign_stage, Campaign.total()]
coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d · Level %d") % [
p.collection.size(), p.unlocked_cards().size(), p.campaign_stage, Campaign.total(), p.player_level()]
var notes := p.take_notices()
notice_label.text = " · ".join(notes)
if not notes.is_empty():
+7 -3
View File
@@ -1,7 +1,7 @@
class_name OpponentSelectScreen
extends Control
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Die Karten kommen im Kampf
## vom gemeinsamen Stapel (siehe Game.start).
## Einzelspieler: Schwierigkeit wählen und ein Duell starten. Der Gegner spielt ein Element aus den
## offenen Kapiteln in einer Stufe relativ zu deiner Deckstärke (Profile.single_opponent).
var app
var diff_buttons := {}
@@ -46,7 +46,11 @@ func refresh() -> void:
for key in diff_buttons:
_mark(diff_buttons[key], key == p.settings.difficulty)
var d := p.difficulty()
info_label.text = tr("Gegner mit %d Leben · %d Münzen bei Sieg") % [int(d.life), int(round(Profile.REWARD_WIN * float(d.reward)))]
var parts: Array[String] = [tr("Gegnerkarten: %s") % tr(Cards.RARITIES[p.opponent_level()]), tr("Vorausschau-KI") if bool(d.smart) else tr("einfache KI")]
if bool(d.perk):
parts.append(tr("ein Merkmal"))
parts.append(tr("Münzen %s") % Style.factor(float(d.reward)))
info_label.text = " · ".join(parts)
func _mark(b: Button, active: bool, col: Color = Color("285a3c")) -> void:
+70
View File
@@ -0,0 +1,70 @@
class_name SkillScreen
extends Control
## Skilltree: oben Level, Erfahrung bis zum nächsten Level und freie Skillpunkte; darunter drei
## Äste (Skills.BRANCHES) mit ihren Skills als Karten: Name, Wirkung, aktuelle und nächste Stufe
## in Zahlen (Skills.effect), Stufe und „Lernen“.
var app
var level_label: Label
var xp_label: Label
var points_label: Label
var msg_label: Label
var learn_buttons := {} # Skill-ID -> Knopf
var rank_labels := {} # Skill-ID -> Label
var now_labels := {} # Skill-ID -> Wirkung jetzt
var next_labels := {} # Skill-ID -> Wirkung der nächsten Stufe
func _ready() -> void:
Style.header(self, "Skilltree", func(): app.show_screen(MenuScreen.new()))
level_label = Style.chip(self, "", Rect2(40, 86, 220, 38))
xp_label = Style.chip(self, "", Rect2(276, 86, 400, 38), Style.BLUE)
points_label = Style.chip(self, "", Rect2(692, 86, 300, 38), Style.GREEN)
Style.label(self, "Erfahrung sind deine verdienten Münzen. Jedes Level bringt einen Skillpunkt.", 14, Style.MUTED, Rect2(40, 134, 1200, 22), "left")
for b in Skills.BRANCHES.size():
var branch: String = Skills.BRANCHES[b]
var x := 40 + b * 410
Style.panel(self, Rect2(x, 170, 390, 580))
Style.label(self, branch, 22, Style.GOLD, Rect2(x, 184, 390, 32), "center", true)
var y := 236
for id in Skills.in_branch(branch):
var sk: Dictionary = Skills.SKILLS[id]
Style.panel(self, Rect2(x + 16, y, 358, 176), Style.PANEL_LIGHT, 8)
Style.label(self, sk.name, 18, Style.TEXT, Rect2(x + 32, y + 10, 326, 26), "left", true)
Style.label(self, sk.text, 14, Style.MUTED, Rect2(x + 32, y + 38, 326, 22), "left")
now_labels[id] = Style.label(self, "", 14, Style.TEXT, Rect2(x + 32, y + 64, 326, 22), "left")
next_labels[id] = Style.label(self, "", 14, Style.GREEN, Rect2(x + 32, y + 86, 326, 22), "left")
rank_labels[id] = Style.label(self, "", 15, Style.GOLD, Rect2(x + 32, y + 126, 150, 30), "left", true)
learn_buttons[id] = Style.button(self, "Lernen", Rect2(x + 214, y + 122, 144, 40), learn.bind(id), Style.PRIMARY)
y += 192
msg_label = Style.label(self, "", 15, Style.RED, Rect2(0, 760, 1280, 24), "center")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
func learn(id: String) -> void:
var err: String = app.profile.learn(id)
msg_label.text = tr(err) if err != "" else ""
if err == "":
app.sfx.play("combo")
app.save()
refresh()
func refresh() -> void:
var p: Profile = app.profile
var lvl := p.player_level()
var base := Skills.xp_for_level(lvl)
level_label.text = tr("Level %d") % lvl
xp_label.text = tr("%d / %d EP bis Level %d") % [p.xp - base, Skills.xp_for_level(lvl + 1) - base, lvl + 1]
points_label.text = tr("Skillpunkte: %d") % p.skill_points()
for id in Skills.SKILLS:
var sk: Dictionary = Skills.SKILLS[id]
rank_labels[id].text = tr("Stufe %d / %d") % [p.skill(id), int(sk.max)]
now_labels[id].text = tr("Jetzt: %s") % Skills.effect(id, p.skill(id))
next_labels[id].text = tr("Nächste Stufe: %s") % Skills.effect(id, p.skill(id) + 1) if p.skill(id) < int(sk.max) else tr("Höchste Stufe erreicht")
learn_buttons[id].disabled = p.skill_points() <= 0 or p.skill(id) >= int(sk.max)
+1
View File
@@ -0,0 +1 @@
uid://b0dgryn2kpic5
-37
View File
@@ -1,37 +0,0 @@
class_name StartDeckScreen
extends Control
## Erster Start: eines von vier Startdecks wählen, je eines mit einem Element im Mittelpunkt
## (25 Karten davon, je fünf der anderen). Danach geht es ins Menü.
var app
func _ready() -> void:
Style.label(self, tr("Wähle dein Startdeck"), 40, Style.GOLD, Rect2(0, 60, 1280, 60), "center", true)
Style.label(self, tr("25 Karten des Elements, dazu je fünf der anderen. Booster und Werkstatt bringen später den Rest."), 17, Style.MUTED, Rect2(140, 124, 1000, 30), "center")
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var x := 70 + i * 290
var col: Color = Cards.ELEMENT_COLORS[e]
Style.panel(self, Rect2(x, 190, 260, 470), Style.darker(col, 0.75), 12, col, 2)
var ids := DeckGen.start_deck(e)
var sample: Array = ids.filter(func(id): return Cards.by_id(id).elements[0] == e)
sample.sort_custom(func(a, b): return Cards.by_id(a).cost > Cards.by_id(b).cost)
for k in 3:
var c: Cards.Card = Cards.by_id(sample[k * 7])
var v := CardView.new(c, Vector2(120, 152), false, "Common")
v.position = Vector2(x + 20 + k * 40, 210 + k * 14)
v.rotation_degrees = -6 + k * 6
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
Style.label(self, tr(e), 28, Style.TEXT, Rect2(x, 400, 260, 40), "center", true)
Style.label(self, tr(Cards.KEYWORD_TEXT[Cards.ELEMENT_KEYWORD[e]]), 15, Style.MUTED, Rect2(x + 16, 446, 228, 60), "center")
var b := Style.button(self, tr("%s wählen") % tr(e), Rect2(x + 30, 590, 200, 50), choose.bind(e), Style.PRIMARY)
b.add_theme_font_size_override("font_size", 18)
func choose(element: String) -> void:
app.profile.choose_start(element)
app.save()
app.sfx.play("combo")
app.show_screen(MenuScreen.new())
-1
View File
@@ -1 +0,0 @@
uid://bxl6isdrgqb2b
+10
View File
@@ -26,6 +26,11 @@ const DANGER := Color("5a2828")
## Schriftgröße mit der Einstellung „Große Schrift“.
## Faktor als Text: ×2, ×1,5 (Dezimaltrenner der Sprache).
static func factor(f: float) -> String:
return "×" + String.num(f, 1).trim_suffix(".0").replace(".", TranslationServer.translate(","))
static func fs(size: int) -> int:
return int(round(size * font_scale))
@@ -630,6 +635,11 @@ static func _glyph_plain(ci: CanvasItem, card, center: Vector2, size: float, tin
ci.draw_arc(center, size * 0.72, 0, TAU, 40, Color(col, 0.5), 2)
## Elementsymbol in einer Farbe (Filterknöpfe, Listen), Grundvariante.
static func element_icon(ci: CanvasItem, element: String, c: Vector2, size: float, col: Color) -> void:
_element_glyph(ci, element, c, size, 0, col)
static func _element_glyph(ci: CanvasItem, element: String, c: Vector2, size: float, variant: int, col: Color) -> void:
var h := size / 2
match element: