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

This commit is contained in:
team3
2026-10-10 14:30:29 +02:00
parent c245c7af87
commit a11cee2e0a
37 changed files with 2190 additions and 2414 deletions

View File

@@ -201,7 +201,7 @@ static func choose_action(game: Game, p: Game.Player) -> Dictionary:
best = firsts[i].action
if best.type == "end":
if game.can_draw(p)[0]:
return {"type": "draw", "index": pick_index(game, p)} # Mana übrig und Platz auf der Hand: lieber ziehen als passen
return {"type": "draw"} # Mana übrig und Platz auf der Hand: lieber ziehen als passen
return best
return map_action(game, view, best)
@@ -258,24 +258,10 @@ static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
if t != null:
return {"type": "attack", "creature": cr, "target": t}
if game.can_draw(p)[0]:
return {"type": "draw", "index": pick_index(game, p)}
return {"type": "draw"}
return {"type": "end"}
## Welche der offenen Karten: die wertvollste, die nach dem Ziehen noch bezahlbar wäre, sonst die billigste.
static func pick_index(game: Game, p: Game.Player) -> int:
var left: int = p.energy - game.draw_cost(p)
var best := 0
var best_score := -INF
for i in p.offer.size():
var c: Cards.Card = p.offer[i]
var score := float(c.attack + c.health) if c.cost <= left else -10.0 - c.cost
if score > best_score:
best_score = score
best = i
return best
## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
## der Held nur, wenn kein Wesen mehr steht.
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
@@ -302,7 +288,7 @@ static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void
"play":
game.play_card(p, action.card, bool(action.get("face_down", false)))
"draw":
game.draw(p, int(action.get("index", 0)))
game.draw(p)
"reveal":
game.reveal(p, action.creature)
"attack":

View File

@@ -64,9 +64,16 @@ static func stage(i: int) -> Dictionary:
return CHAPTERS[chapter(i) - 1][i % STAGES.size()]
## Boss einer Stufe mit dem Leben und den Merkmalen der Stufe.
static func opponent(stage_index: int) -> Game.Player:
## 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.
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)
for id in boss.deck:
boss.looks[id] = ch - 1
return boss

View File

@@ -93,9 +93,9 @@ static func best_element(own: Array, other: Array) -> String:
return best
## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet (0/2 kostet nichts).
## Kosten einer Karte: zwei Drittel Angriff plus ein Drittel Leben, abgerundet, höchstens 10 (0/2 kostet nichts).
static func cost_of(attack: int, health: int) -> int:
return (2 * attack + health) / 3
return mini(10, (2 * attack + health) / 3)
## Wert eines verschmolzenen Wesens: der bessere Wert plus ein Drittel des schwächeren (mindestens 1),
@@ -132,8 +132,7 @@ static func fused(a: Card, b: Card, attack: int = -1, health: int = -1) -> Card:
for k in KEYWORDS:
if k in a.keywords or k in b.keywords:
c.keywords.append(k)
c.fusions = maxi(a.fusions, b.fusions) + 1
c.look = maxi(a.look, b.look) # die höhere Stufe gewinnt; Game.fuse rechnet die Sammlung ein
c.look = maxi(a.look, b.look) # die höhere Stufe gewinnt
return c
@@ -157,19 +156,18 @@ class Card extends RefCounted:
var elements: Array
var attack: int = 0
var health: int = 0
var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet
var cost: int = 0 # abgeleitet: (2 · Angriff + Leben) / 3, abgerundet, höchstens 10 (aus den Grundwerten)
var keywords: Array = [] # feste Eigenschaften der Karte (Elementpaare geben ihre zur Laufzeit)
var fusions := 0 # wie oft verschmolzen (Verschmelzen kostet 1, 2, 4, 8 …)
var look := -1 # Seltenheitsstufe verschmolzener Karten (die höhere der Eltern); -1: aus der Sammlung des Besitzers
var look := -1 # Seltenheitsstufe des Exemplars (gibt +1/+1 je Stufe); -1: Grundkarte aus dem Pool
func to_dict() -> Dictionary:
var d := {"id": id, "name": name, "name_en": name_en, "elements": elements, "attack": attack, "health": health}
if not keywords.is_empty():
d["keywords"] = keywords
if fusions > 0:
d["fusions"] = fusions
if look >= 0:
d["look"] = look
if cost != Cards.cost_of(attack, health):
d["cost"] = cost
return d
## Stufe dieser Karte für einen Besitzer mit dieser Sammlung (Game.Player.looks).
@@ -184,9 +182,8 @@ class Card extends RefCounted:
c.elements = Array(d.elements)
c.attack = int(d.get("attack", 0))
c.health = int(d.get("health", 0))
c.cost = Cards.cost_of(c.attack, c.health)
c.cost = int(d.get("cost", Cards.cost_of(c.attack, c.health)))
c.keywords = Array(d.get("keywords", []))
c.fusions = int(d.get("fusions", 0))
c.look = int(d.get("look", -1))
return c
@@ -194,6 +191,15 @@ class Card extends RefCounted:
func copy() -> Card:
return Card.from_dict(to_dict())
## Exemplar in einer Seltenheitsstufe: +1/+1 je Stufe, die Kosten bleiben die der Grundkarte.
func leveled(level: int) -> Card:
var c := copy()
c.look = level
c.attack += level
c.health += level
c.cost = cost
return c
## Anzeigename in der aktuellen Sprache.
func title() -> String:
if name_en != "" and TranslationServer.get_locale().begins_with("en"):
@@ -206,10 +212,6 @@ class Card extends RefCounted:
func combo() -> String:
return Cards.combo_name(elements)
## Mana für die nächste Verschmelzung mit dieser Karte.
func fusion_cost() -> int:
return 1 << fusions
## Kartentext: die festen Eigenschaften.
func text() -> String:
if keywords.is_empty():

View File

@@ -1,16 +1,54 @@
class_name DeckGen
## Der gemeinsame Stapel (jede Karte dreimal) und Gegner.
## Stapel: die vier Startdecks, Auffüllen aus der Sammlung, zufällige Stapel für Gegner.
const COPIES := 3
const CHEAP_IN_RANDOM := 12 # so viele im ersten Zug bezahlbare Karten hat ein zufälliger Stapel mindestens
## Frisch gemischter Stapel; das Ende des Arrays ist oben.
static func deck(r: RandomNumberGenerator) -> Array:
## Zufälliger Stapel aus DECK_MIN verschiedenen Karten bis max_cost, mit genug billigen für den Anfang.
static func random_deck(r: RandomNumberGenerator, max_cost: int = 99) -> Array:
var cheap := Cards.all().filter(func(c): return c.cost <= Game.START_MANA)
var rest := Cards.all().filter(func(c): return c.cost > Game.START_MANA and c.cost <= max_cost)
Game.shuffle(cheap, r)
Game.shuffle(rest, r)
var out := []
for c in cheap.slice(0, CHEAP_IN_RANDOM) + rest + cheap.slice(CHEAP_IN_RANDOM):
if out.size() >= Game.DECK_MIN:
break
out.append(c.id)
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)])
## 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 := []
for c in Cards.all():
for i in COPIES:
out.append(c)
Game.shuffle(out, r)
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)
return out
## Füllt deck aus pool (Karten-IDs) bis limit auf, die teuersten zuerst; nichts doppelt.
static func fill(deck: Array, pool: Array, limit: int) -> Array:
var out := deck.duplicate()
var cards := pool.filter(func(id): return not (id in out)).map(func(id): return Cards.by_id(id))
cards.sort_custom(func(a, b): 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 out

View File

@@ -7,11 +7,11 @@ const MAX_BOARD := 5
const MAX_HAND := 5
const START_HAND := 5 # Startkarten, alle im ersten Zug bezahlbar
const START_MANA := 1 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt
const DRAW_COST := 2 # Ziehen: eine bezahlbare Karte vom Stapel
const DECK_MIN := 40 # Karten je Stapel: mindestens so viele (Gegner und Startdecks genau so viele)
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_MANA := 10 # je so viel Mana an ausgespielten Karten eines Elements: +1 Angriff für die eigenen Wesen des Elements
const OFFER := 3 # offene Karten auf dem Stapel, höchstens so teuer wie das Mana des Zugs; beim Ziehen wählt man eine, eine neue rückt nach
const ELEMENT_COUNT := 5 # 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).
@@ -129,17 +129,18 @@ class Player extends RefCounted:
var is_human: bool
var life: int
var max_life: int
var hand: Array = [] # eigene Exemplare (Cards.Card.copy)
var hand: Array = [] # eigene Exemplare (Cards.Card, in der eigenen Seltenheitsstufe)
var board: Array = []
var deck: Array = [] # eigener Stapel: Karten-IDs, das Ende ist oben
var energy := 0 # Mana in diesem Zug; der Rest verfällt
var max_energy := 0 # Mana zu Zugbeginn (START_MANA + eigene Züge)
var order := 0 # Platz in der Zugreihenfolge
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 -> Mana der bisher ausgespielten Karten (je ELEMENT_MANA: +1 Angriff)
var offer: Array = [] # die OFFER offenen Karten auf dem Stapel (nur der Besitzer sieht sie)
var drew := false # in diesem Zug schon gezogen? Das erste Ziehen ist frei
var played := {} # Element -> bisher ausgespielte Karten (je ELEMENT_COUNT: +1/+1)
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
func _init(n: String, h: bool, l: int, t: String = "") -> void:
name = n
@@ -153,6 +154,25 @@ class Player extends RefCounted:
func alive() -> bool:
return life > 0
## Eine Karte vom eigenen Stapel als Exemplar in der eigenen Stufe; null, wenn er leer ist.
## Mit max_cost kommt die oberste bezahlbare, zu teure wandern nach unten (Starthand).
func draw_from_deck(max_cost: int = 99) -> Cards.Card:
var skipped := []
var id := ""
while not deck.is_empty():
var top: String = deck.pop_back()
if Cards.by_id(top).cost <= max_cost:
id = top
break
skipped.append(top)
if id == "" and not skipped.is_empty(): # nichts Bezahlbares: dann eben die oberste
id = skipped.pop_front()
for sk in skipped:
deck.insert(0, sk)
if id == "":
return null
return Cards.by_id(id).leveled(int(looks.get(id, 0)))
## Offene eigene Wesen mit diesem Element.
func element_count(e: String) -> int:
var n := 0
@@ -172,20 +192,19 @@ class Player extends RefCounted:
p.looks = looks
p.turns = turns
p.played = played.duplicate()
p.offer = offer.duplicate()
p.drew = drew
p.deck = deck.duplicate()
p.draws = draws
p.fatigue = fatigue
for c in board:
p.board.append(c.clone(p))
return p
var human: Player # der (erste) menschliche Spieler
var humans: Array = [] # alle menschlichen Spieler (im Netz zwei)
var human: Player # der menschliche Spieler
var opponent: Player # der andere am Tisch
var players: Array = [] # in Zugreihenfolge
var winner_player: Player = null
var rng: RandomNumberGenerator
var deck: Array = [] # gemeinsamer Stapel, das Ende ist oben (nur Host und Einzelspieler)
var turn := 0
var current_idx := 0
var log: Array[String] = []
@@ -199,7 +218,6 @@ func _init(h: Player, o: Player, r: RandomNumberGenerator = null) -> void:
human = h
opponent = o
players = [h, o]
humans = players.filter(func(p): return p.is_human)
if r == null:
r = make_rng()
rng = r
@@ -250,7 +268,6 @@ func clone() -> Game:
var idx := players.find(human)
var g := Game.new(copies[idx], copies[1 - idx], RandomNumberGenerator.new())
g.players = copies # gleiche Zugreihenfolge wie das Original
g.deck = deck.duplicate()
g.turn = turn
g.current_idx = current_idx
g.winner = winner
@@ -278,16 +295,21 @@ func masked_clone(viewer: Player) -> Game:
# --- Zugablauf ---------------------------------------------------------------
## Jeder spielt mit seinem eigenen Stapel (leer: ein zufälliger); die Starthand sind fünf im
## ersten Zug bezahlbare Karten daraus.
func start(fixed_order: bool = false) -> void:
if not fixed_order:
shuffle(players, rng) # zufällige Zugreihenfolge
deck = DeckGen.deck(rng)
for i in players.size():
var p: Player = players[i]
p.order = i
for c in draw_cards(START_HAND, START_MANA):
p.hand.append(c.copy())
p.offer = draw_cards(OFFER, START_MANA)
if p.deck.is_empty():
p.deck = DeckGen.random_deck(rng)
shuffle(p.deck, rng)
for k in START_HAND:
var c := p.draw_from_deck(START_MANA)
if c != null:
p.hand.append(c)
turn = 1
current_idx = 0
say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name)))
@@ -297,54 +319,31 @@ func start(fixed_order: bool = false) -> void:
begin_turn(current())
## Nimmt n Karten vom Stapel, die höchstens max_cost kosten; zu teure wandern unter den Stapel.
## Ist der Stapel leer, kommt ein frischer.
func draw_cards(n: int, max_cost: int = 999) -> Array:
var out := []
var skipped := []
var looked := 0
while out.size() < n:
if deck.is_empty():
deck = DeckGen.deck(rng)
say("Der Stapel wird neu gemischt.")
var c: Cards.Card = deck.pop_back()
looked += 1
if c.cost <= max_cost or looked > deck.size() + skipped.size() + n:
out.append(c)
else:
skipped.append(c)
for c in skipped:
deck.insert(0, c)
return out
## Ziehen: eine der offenen Karten kommt auf die Hand, eine neue rückt nach. Das erste Ziehen
## im Zug ist frei, jedes weitere kostet DRAW_COST.
## Ziehen: eine Karte vom eigenen Stapel. Die erste im Zug ist frei, jede weitere kostet eins
## mehr (0, 1, 2 …).
func can_draw(p: Player) -> Array:
if winner != "" or p != current():
return [false, "Nicht dein Zug"]
if p.hand.size() >= MAX_HAND:
return [false, "Deine Hand ist voll"]
if p.deck.is_empty():
return [false, "Dein Stapel ist leer"]
if p.energy < draw_cost(p):
return [false, "Nicht genug Mana"]
return [true, ""]
func draw_cost(p: Player) -> int:
return 0 if not p.drew or "ziehen_gratis" in p.perks else DRAW_COST
return maxi(0, p.draws - (1 if "ziehen_gratis" in p.perks else 0))
func draw(p: Player, index: int = 0) -> String:
func draw(p: Player) -> String:
var ok := can_draw(p)
if not ok[0]:
return ok[1]
if index < 0 or index >= p.offer.size():
return "Keine Karte gewählt"
p.energy -= draw_cost(p)
p.drew = true
var card: Cards.Card = p.offer[index]
p.hand.append(card.copy())
p.offer[index] = draw_cards(1, maxi(p.max_energy, START_MANA))[0] # nie teurer als das Mana dieses Zugs
p.draws += 1
p.hand.append(p.draw_from_deck())
say("%s zieht." % p.name)
emit({"type": "draw", "player": players.find(p)})
return ""
@@ -359,9 +358,15 @@ func begin_turn(p: Player) -> void:
c.ready = true # lag eine Runde: greift jetzt an, Verdecktes darf aufgedeckt werden
c.attacks_done = 0
p.turns += 1
p.drew = false # das erste Ziehen im Zug ist wieder frei
p.draws = 0 # das erste Ziehen im Zug ist wieder frei
say("--- Zug %d: %s ---" % [turn, p.name])
emit({"type": "turn", "player": players.find(p)})
if p.deck.is_empty(): # Erschöpfung: ohne Stapel kostet jeder Zug mehr Leben, damit es ein Ende gibt
p.fatigue += 1
p.life -= p.fatigue
say("%s hat keine Karten mehr und verliert %d Leben." % [p.name, p.fatigue])
emit({"type": "damage", "target": ref_of(p), "amount": p.fatigue})
check_end()
## Zugende: Wesen mit Regeneration heilen ganz, dann ist der andere dran.
@@ -417,7 +422,7 @@ func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String:
cr.max_health += 1
p.board.append(cr)
for e in card.elements:
p.played[e] = int(p.played.get(e, 0)) + card.cost
p.played[e] = int(p.played.get(e, 0)) + 1
if face_down:
say("%s legt ein Wesen verdeckt." % p.name)
else:
@@ -492,9 +497,9 @@ static func look_of(p: Player, c: Cards.Card) -> int:
return c.look if c.look >= 0 else int(p.looks.get(c.id, 0))
## Kosten: 1 Mana, verdoppelt je Verschmelzung, die schon in einer der beiden steckt.
## Kosten: die Hälfte der Manasumme beider Karten, aufgerundet.
static func fusion_cost(a, b) -> int:
return 1 << maxi(card_of(a).fusions, card_of(b).fusions)
return ceili((card_of(a).cost + card_of(b).cost) / 2.0)
## a und b: zwei eigene Wesen auf dem Brett oder zwei eigene Handkarten.
@@ -539,11 +544,11 @@ func fuse(p: Player, a, b) -> String:
## das Ergebnis ist nur angriffsbereit, wenn beide es waren.
func _fuse_board(p: Player, a: Creature, b: Creature) -> String:
var card := Cards.fused(a.card, b.card, Cards.fusion_value(a.attack, b.attack),
Cards.fusion_value(a.max_health, b.max_health))
Cards.fusion_value(a.max_health - a.aura, b.max_health - b.aura))
card.look = maxi(look_of(p, a.card), look_of(p, b.card))
var cr := Creature.new(card, p, next_uid)
next_uid += 1
cr.health = mini(card.health, Cards.fusion_value(a.health, b.health))
cr.health = mini(card.health, Cards.fusion_value(mini(a.health, a.max_health - a.aura), mini(b.health, b.max_health - b.aura)))
cr.ready = a.ready and b.ready
cr.attacks_done = maxi(a.attacks_done, b.attacks_done)
var at := p.board.find(a)
@@ -560,11 +565,11 @@ func _fuse_board(p: Player, a: Creature, b: Creature) -> String:
# --- Elementboni ---------------------------------------------------------------
## +1 Angriff je volle ELEMENT_MANA an ausgespielten Karten eines Elements des Spielers; mehrere Elemente zählen alle.
## +1/+1 je volle ELEMENT_COUNT ausgespielte Karten eines Elements des Spielers; mehrere Elemente zählen alle.
static func aura_of(elements: Array, played: Dictionary) -> int:
var a := 0
for e in elements:
a += int(played.get(e, 0)) / ELEMENT_MANA
a += int(played.get(e, 0)) / ELEMENT_COUNT
return a
@@ -579,18 +584,24 @@ func aura_for(c: Creature) -> int:
return a
## Rechnet die Elementboni neu; sie wirken nur auf den Angriff (Creature.power).
## 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:
c.aura = aura_for(c)
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)) + card.cost
played[e] = int(played.get(e, 0)) + 1
return aura_of(card.elements, played) * (2 if "element_doppelt" in p.perks else 1)
@@ -685,20 +696,7 @@ func check_end() -> void:
return
winner_player = alive[0]
winner = "human" if winner_player.is_human else "opponents"
if humans.size() == 1:
say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.")
else:
say("%s gewinnt!" % winner_player.name)
# --- Zustand für das Netzwerk (siehe NetState) ------------------------------------
func snapshot(viewer: int) -> Dictionary:
return NetState.snapshot(self, viewer)
static func from_snapshot(d: Dictionary) -> Game:
return NetState.from_snapshot(d)
say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.")
# --- Gegner und Kampfstart ---------------------------------------------------
@@ -711,7 +709,7 @@ static func shuffle(arr: Array, r: RandomNumberGenerator) -> void:
arr[j] = tmp
## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}}
## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}, "deck": [ids]}
static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game:
if r == null:
r = make_rng()
@@ -724,6 +722,7 @@ static func new_battle_with(opp: Player, r: RandomNumberGenerator = null, fixed_
var human := Player.new("Spieler", true, START_LIFE)
human.perks = Array(rules.get("human_perks", []))
human.looks = rules.get("looks", {})
human.deck = rules.get("deck", []).duplicate()
var g := Game.new(human, opp, r)
for p in g.players:
if "leben_35" in p.perks:

View File

@@ -1,443 +0,0 @@
class_name Net
extends Node
## LAN-Mehrspieler: der Host führt das Spiel, Mitspieler senden Aktionen und erhalten
## Zustandsbilder aus ihrer Sicht (fremde Hände bleiben verborgen).
signal lobby_changed
signal started(seat: int)
signal snapshot_received(snap: Dictionary)
signal action_applied
signal ended(reason: String)
signal migrated # nach einem Host-Ausfall: dieser Rechner führt das Spiel jetzt
signal status_changed
const PORT := 24455
var peer: ENetMultiplayerPeer
var is_host := false
var connected := false
var my_name := "Spieler"
var my_pick := {"looks": {}} # eigene Kartenoptik (Seltenheit), die der Gegner sieht
var names := {} # peer_id -> Name (nur im Host vollständig, sonst Kopie)
var picks := {} # peer_id -> {"looks": {...}} (Host)
var seats := {} # peer_id -> Spielerindex (Host)
var my_seat := -1
var game: Game # Host
var status := ""
var waiting := {} # Name -> {"seat": int, "since": float}: Platz nach Verbindungsabbruch freihalten
var rejoin_seconds := 60.0
var spectator := false # eigene Rolle: nur zuschauen
var spectators := {} # Host: peer_id -> Name
var successor_ip := "" # Mitspieler: Adresse des Nachfolge-Hosts
var i_am_successor := false
var backup := {} # Nachfolger: voller Spielstand nach jeder Aktion
var backup_names := {} # Nachfolger: Platz -> Name aller Menschen
var last_winner := "" # Mitspieler: Sieger laut letztem Zustandsbild
var reconnecting := false
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(func():
if not reconnecting:
_fail("Verbindung fehlgeschlagen"))
multiplayer.server_disconnected.connect(_on_server_lost)
func host(port: int = PORT) -> Error:
peer = ENetMultiplayerPeer.new()
var err := peer.create_server(port, 8) # ein Mitspieler, der Rest Zuschauer
if err != OK:
status = "Kann nicht hosten (Port %d belegt?)" % port
return err
multiplayer.multiplayer_peer = peer
is_host = true
connected = true
names = {1: my_name}
picks = {1: my_pick}
spectators = {}
status = "Warte auf Mitspieler …"
lobby_changed.emit()
return OK
func join(ip: String, port: int = PORT) -> Error:
peer = ENetMultiplayerPeer.new()
var err := peer.create_client(ip, port)
if err != OK:
status = "Adresse ungültig"
return err
multiplayer.multiplayer_peer = peer
is_host = false
status = "Verbinde mit %s …" % ip
return OK
func leave() -> void:
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
peer = null
is_host = false
connected = false
names = {}
picks = {}
seats = {}
waiting = {}
my_seat = -1
game = null
status = ""
spectator = false
spectators = {}
successor_ip = ""
i_am_successor = false
backup = {}
backup_names = {}
last_winner = ""
func local_addresses() -> Array:
return Array(IP.get_local_addresses()).filter(func(a): return "." in a and not a.begins_with("127."))
func _fail(reason: String) -> void:
status = reason
ended.emit(reason)
leave()
func _on_connected() -> void:
connected = true
status = "Verbunden"
register.rpc_id(1, my_name, my_pick, spectator)
# --- Host-Wechsel ---------------------------------------------------------------
# Der Host schickt dem Mitspieler mit der kleinsten Kennung nach jeder Aktion den vollen
# Spielstand und allen die Adresse dieses Nachfolgers. Fällt der Host aus, eröffnet der
# Nachfolger das Spiel neu, die anderen verbinden sich mit ihm und kehren auf ihre Plätze zurück.
func _successor_id() -> int:
var ids: Array = seats.keys().filter(func(id): return id != 1)
ids.sort()
return ids[0] if not ids.is_empty() else -1
func _update_successor() -> void:
if not is_host or game == null:
return
var succ := _successor_id()
var ip := ""
if succ > 0 and peer != null and peer.get_peer(succ) != null:
ip = peer.get_peer(succ).get_remote_address()
for id in seats:
if id != 1:
set_successor.rpc_id(id, ip, id == succ)
## Platz -> Name aller Menschen (auch der gerade fehlenden), für die Rückkehr nach dem Wechsel.
func _seat_names() -> Dictionary:
var out := {}
for id in seats:
out[seats[id]] = names.get(id, "")
for pname in waiting:
out[waiting[pname].seat] = pname
return out
@rpc("authority", "reliable")
func set_successor(ip: String, me: bool) -> void:
successor_ip = ip
i_am_successor = me
@rpc("authority", "reliable")
func receive_backup(snap: Dictionary, seat_names: Dictionary) -> void:
backup = snap
backup_names = seat_names
func _on_server_lost() -> void:
if is_host:
return
if last_winner != "":
_fail("Host hat das Spiel beendet")
elif i_am_successor and not backup.is_empty():
_become_host()
elif successor_ip != "":
status = "Host weg, verbinde mit dem Nachfolger …"
status_changed.emit()
_reconnect(successor_ip)
else:
_fail("Host hat das Spiel beendet")
func _become_host() -> void:
var snap := backup
var seat_names := backup_names
var seat := my_seat
if multiplayer.multiplayer_peer != null:
multiplayer.multiplayer_peer.close()
var err := FAILED
for attempt in 5:
err = host()
if err == OK:
break
await get_tree().create_timer(1.0).timeout
if err != OK:
_fail("Host weg, Übernahme fehlgeschlagen")
return
game = Game.from_snapshot(snap)
seats = {1: seat}
my_seat = seat
waiting = {}
for s in seat_names:
if int(s) != seat:
waiting[seat_names[s]] = {"seat": int(s), "since": Time.get_unix_time_from_system()}
game.say("%s übernimmt als Host." % my_name)
status = "Du bist jetzt Host, die anderen verbinden neu …"
successor_ip = ""
i_am_successor = false
backup = {}
migrated.emit()
func _reconnect(ip: String) -> void:
reconnecting = true
await get_tree().create_timer(2.0).timeout
for attempt in 5:
if join(ip) == OK:
var t := 0.0
while not connected and t < 4.0 and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTING:
await get_tree().process_frame
t += get_process_delta_time()
if connected:
reconnecting = false
return
await get_tree().create_timer(1.0).timeout
reconnecting = false
_fail("Nachfolge-Host nicht erreichbar")
func _on_peer_connected(_id: int) -> void:
pass
func _on_peer_disconnected(id: int) -> void:
if not is_host:
return
var pname: String = names.get(id, "")
names.erase(id)
picks.erase(id)
spectators.erase(id)
if game != null and seats.has(id):
var p: Game.Player = game.players[seats[id]]
if game.winner == "":
waiting[pname] = {"seat": seats[id], "since": Time.get_unix_time_from_system()}
game.say("%s hat die Verbindung verloren, der Platz bleibt %d s frei." % [p.name, int(rejoin_seconds)])
seats.erase(id)
_update_successor()
action_applied.emit()
lobby_update.rpc(names, spectators)
lobby_changed.emit()
## Host, je Bild: wartende Spieler überspringen; nach Ablauf der Frist scheiden sie aus.
func tick() -> void:
if not is_host or game == null or game.winner != "":
return
var now := Time.get_unix_time_from_system()
for pname in waiting.keys():
var w: Dictionary = waiting[pname]
var p: Game.Player = game.players[w.seat]
if now - float(w.since) > rejoin_seconds:
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:
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, pick: Dictionary, as_spectator: bool = false) -> void:
if not is_host:
return
var id := multiplayer.get_remote_sender_id()
if as_spectator:
spectators[id] = name
lobby_update.rpc(names, spectators)
lobby_changed.emit()
if game != null:
start_game.rpc_id(id, -1)
receive_snapshot.rpc_id(id, game.snapshot(-2))
return
if game != null:
# Laufendes Spiel: nur Rückkehr auf einen freigehaltenen Platz
if waiting.has(name):
seats[id] = waiting[name].seat
names[id] = name
waiting.erase(name)
game.say("%s ist zurück." % name)
start_game.rpc_id(id, seats[id])
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
_update_successor()
action_applied.emit()
else:
status = "%s wollte beitreten, das Spiel läuft schon" % name
return
names[id] = name
picks[id] = pick
lobby_update.rpc(names, spectators)
lobby_changed.emit()
@rpc("authority", "reliable")
func lobby_update(n: Dictionary, sp: Dictionary = {}) -> void:
names = n
spectators = sp
lobby_changed.emit()
## Host: Duell mit dem ersten Mitspieler starten; allein gegen die KI.
func start_battle() -> void:
var ids: Array = names.keys()
ids.sort()
ids = ids.slice(0, 2)
var r := Game.make_rng()
var players := []
seats = {}
for id in 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):
p.looks[cid] = int(pick.looks[cid])
players.append(p)
if players.size() < 2:
players.append(DeckGen.random_opponent())
game = Game.new(players[0], players[1], r)
game.start()
for id in ids:
seats[id] = game.players.find(players[ids.find(id)])
my_seat = seats[1]
for id in ids:
if id != 1:
start_game.rpc_id(id, seats[id])
for id in spectators:
start_game.rpc_id(id, -1)
_update_successor()
started.emit(my_seat)
@rpc("authority", "reliable")
func start_game(seat: int) -> void:
my_seat = seat
started.emit(seat)
## Host: Zustand an alle senden (Mitspieler aus ihrer Sicht, Zuschauer ohne Verborgenes,
## der Nachfolger zusätzlich den vollen Spielstand).
func broadcast() -> void:
if not is_host or game == null:
return
for id in seats:
if id != 1:
receive_snapshot.rpc_id(id, game.snapshot(seats[id]))
for id in spectators:
receive_snapshot.rpc_id(id, game.snapshot(-2))
var succ := _successor_id()
if succ > 0:
receive_backup.rpc_id(succ, game.snapshot(-1), _seat_names())
@rpc("authority", "reliable")
func receive_snapshot(snap: Dictionary) -> void:
last_winner = str(snap.get("winner", ""))
snapshot_received.emit(snap)
## Mitspieler: Aktion an den Host schicken.
func send_action(action: Dictionary) -> void:
if is_host:
_apply(1, action)
else:
request_action.rpc_id(1, action)
@rpc("any_peer", "reliable")
func request_action(action: Dictionary) -> void:
if not is_host:
return
_apply(multiplayer.get_remote_sender_id(), action)
func _apply(id: int, action: Dictionary) -> void:
if game == null or not seats.has(id):
return
var p: Game.Player = game.players[seats[id]]
if game.winner != "":
return
if game.current() != p:
return
match str(action.get("type", "")):
"play":
var idx := int(action.get("hand", -1))
if idx < 0 or idx >= p.hand.size():
return
game.play_card(p, p.hand[idx], bool(action.get("face_down", false)))
"reveal":
var cr := game.find_creature(int(action.get("uid", -1)))
if cr != null:
game.reveal(p, cr)
"discard":
var hi := int(action.get("hand", -1))
if hi >= 0 and hi < p.hand.size():
game.discard(p, p.hand[hi])
"attack":
var cr := game.find_creature(int(action.get("uid", -1)))
var t = resolve_ref(action.get("target"))
if cr != null and t != null:
game.attack(cr, t)
"draw":
game.draw(p, int(action.get("index", 0)))
"fuse": # zwei Wesen (uid, other) oder zwei Handkarten (hand, other_hand)
var a = game.find_creature(int(action.get("uid", -1)))
var b = game.find_creature(int(action.get("other", -1)))
var i := int(action.get("hand", -1))
var j := int(action.get("other_hand", -1))
if a == null and i >= 0 and j >= 0 and i < p.hand.size() and j < p.hand.size():
a = p.hand[i]
b = p.hand[j]
if a != null and b != null:
game.fuse(p, a, b)
"end":
game.end_turn()
_:
return
action_applied.emit()
func resolve_ref(ref):
if ref == null or not (ref is Dictionary):
return null
if ref.get("kind") == "creature":
return game.find_creature(int(ref.get("uid", -1)))
var i := int(ref.get("index", -1))
if i >= 0 and i < game.players.size():
return game.players[i]
return null
static func ref_dict(target) -> Dictionary:
if target is Game.Creature:
return {"kind": "creature", "uid": target.uid}
return {"kind": "player", "index": target.order}

View File

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

View File

@@ -1,109 +0,0 @@
class_name NetState
## Serialisierter Spielzustand für das Netzwerk (ausgelagert aus den Spielregeln).
## Karten reisen als ID; eigene Exemplare mit Schlüsselwörtern und verschmolzene als Ganzes.
static func card_ref(c: Cards.Card):
return c.id if Cards.has_id(c.id) and c.keywords.is_empty() and c.fusions == 0 else c.to_dict()
static func card_of(ref) -> Cards.Card:
return Cards.Card.from_dict(ref) if ref is Dictionary else Cards.by_id(ref).copy()
## Zustand aus Sicht eines Spielers: fremde Hand nur als Anzahl, fremde
## verdeckte Wesen ohne Karte und Werte. viewer -1: alles sichtbar (Host-Wechsel); -2: Zuschauer.
static func snapshot(g: Game, viewer: int) -> Dictionary:
var ps := []
for i in g.players.size():
var p: Game.Player = g.players[i]
var mine := i == viewer or viewer == -1
var board := []
for c in p.board:
var masked: bool = c.hidden and not mine
board.append({"uid": c.uid, "card": card_ref(Cards.unknown() if masked else c.card),
"attack": 0 if masked else c.attack, "health": 0 if masked else c.health,
"max_health": 0 if masked else c.max_health, "attacks_done": c.attacks_done,
"ready": c.ready, "hidden": c.hidden, "aura": c.aura})
ps.append({"name": p.name, "title": p.title, "is_human": p.is_human, "life": p.life, "max_life": p.max_life,
"energy": p.energy, "max_energy": p.max_energy,
"order": p.order, "perks": p.perks, "looks": p.looks, "turns": p.turns, "played": p.played,
"hand": p.hand.map(card_ref) if mine else p.hand.size(), "board": board,
"offer": p.offer.map(card_ref) if mine else [], "drew": p.drew})
var evs := []
for ev in g.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
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, "deck_size": g.deck.size(),
"log": g.log.slice(maxi(0, g.log.size() - 80)), "events": evs}
## Baut einen Spielzustand aus einem Snapshot; verborgene Karten werden durch Platzhalter ersetzt.
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)
p.max_life = int(pd.max_life)
p.energy = int(pd.energy)
p.max_energy = int(pd.max_energy)
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)
for e in pd.get("played", {}):
p.played[e] = int(pd.played[e])
if pd.hand is Array:
for ref in pd.hand:
p.hand.append(card_of(ref))
else:
for i in int(pd.hand):
p.hand.append(placeholder)
for ref in pd.get("offer", []):
p.offer.append(card_of(ref))
p.drew = bool(pd.get("drew", false))
for cd in pd.board:
var card := card_of(cd.card)
var c := Game.Creature.new(card, p, int(cd.uid))
c.attack = int(cd.attack)
c.health = int(cd.health)
c.max_health = int(cd.max_health)
c.attacks_done = int(cd.get("attacks_done", 0))
c.ready = bool(cd.get("ready", false))
c.hidden = bool(cd.get("hidden", false))
c.aura = int(cd.get("aura", 0))
p.board.append(c)
list.append(p)
var hi := int(d.human)
var g := Game.new(list[hi], list[1 - hi], RandomNumberGenerator.new())
g.players = list
g.turn = int(d.turn)
g.current_idx = int(d.current_idx)
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)
for i in int(d.get("deck_size", 0)):
g.deck.append(placeholder) # nur die Größe zählt; gezogen wird beim Host
for line in d.log:
g.log.append(str(line))
for ev in d.events:
var e: Dictionary = ev.duplicate()
if e.has("card"):
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"]:
if e.has(key):
var r: Dictionary = e[key]
if r.has("uid"):
r["uid"] = int(r.uid)
if r.has("index"):
r["index"] = int(r.index)
g.events.append(e)
return g

View File

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

View File

@@ -1,14 +1,16 @@
class_name Profile
extends RefCounted
## 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.
## 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
## gibt (Cards.Card.leveled); kommt eine seltenere Version, ersetzt sie die alte, die zu Staub wird.
const SAVE_VERSION := 6
const SAVE_VERSION := 7
const CRAFT_COST := 200 # Staub für eine neue Karte (Common)
const SAVE_PATH := "user://spielstand.json"
const BOOSTER_COST := 100
const BOOSTER_SIZE := 5
const RARITY_WEIGHTS := {"Common": 50.0, "Rare": 25.0, "Super Rare": 15.0, "Ultra Rare": 8.0, "Ultimate Rare": 2.0}
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
@@ -24,19 +26,34 @@ const DUST_UPGRADE := {"Rare": 100, "Super Rare": 200, "Ultra Rare": 500, "Ultim
var coins := 0
var dust := 0
var collection := {} # id -> Seltenheitsstufe (Index in Cards.RARITIES), fehlend = Common
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
var settings := {"volume": 0.0, "music": 0.0, "ai_speed": 1.0, "animations": true, "language": "de", "difficulty": "normal", "big_font": false}
var notices: Array[String] = [] # Meldungen für die Oberfläche (Kampagnenfortschritt)
var perk := "" # gewähltes eigenes Merkmal (nach Kapitel 1)
## Neues Profil: noch ohne Karten, das Startdeck wählt der Spieler (choose_start).
static func starter() -> Profile:
var p := Profile.new()
p.coins = START_COINS
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:
collection = {}
deck = DeckGen.start_deck(element)
for id in deck:
collection[id] = 0
## Lädt den Spielstand; bei Schaden die Sicherung; sonst ein neues Profil.
static func load_or_new(path: String = SAVE_PATH) -> Profile:
for candidate in [path, path + ".bak"]:
@@ -52,7 +69,7 @@ static func load_or_new(path: String = SAVE_PATH) -> Profile:
## Liest jede Spielstand-Version; ältere Stände behalten Münzen, Staub, Kampagne und
## Einstellungen, die alte Sammlung (Anzahl je Karte) hat keine Entsprechung mehr.
## Einstellungen und bekommen die Startsammlung.
static func from_dict(d: Dictionary) -> Profile:
var p := Profile.new()
p.coins = int(d.get("coins", 0))
@@ -63,6 +80,11 @@ static func from_dict(d: Dictionary) -> Profile:
for cid in coll:
if Cards.has_id(cid):
p.collection[cid] = clampi(int(coll[cid]), 0, Cards.RARITIES.size() - 1)
var ids := []
for cid in d.get("deck", []):
if p.collection.has(cid) and not (cid in ids):
ids.append(cid)
p.deck = ids.slice(0, Game.DECK_MAX)
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)),
@@ -77,7 +99,7 @@ static func from_dict(d: Dictionary) -> Profile:
func to_dict() -> Dictionary:
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection,
return {"version": SAVE_VERSION, "coins": coins, "dust": dust, "collection": collection, "deck": deck,
"campaign_stage": campaign_stage, "settings": settings, "perk": perk}
@@ -97,7 +119,11 @@ func take_notices() -> Array[String]:
return out
# --- Sammlung: Seltenheit als Aussehen --------------------------------------------
# --- Sammlung und Stapel -----------------------------------------------------------
func owns(cid: String) -> bool:
return collection.has(cid)
func level(cid: String) -> int:
return int(collection.get(cid, 0))
@@ -116,17 +142,29 @@ func looks() -> Dictionary:
return out
## Anzahl Karten über Common.
func upgraded_count() -> int:
var n := 0
for cid in collection:
if int(collection[cid]) > 0:
n += 1
return n
## Der Stapel für den Kampf: der gewählte, mit weiteren eigenen Karten bis DECK_MIN aufgefüllt,
## falls er zu klein ist (Karten können durch Zerlegen wegfallen).
func deck_cards() -> Array:
return DeckGen.fill(deck.filter(func(id): return collection.has(id)), collection.keys(), Game.DECK_MIN)
## Karte in den Stapel oder heraus. Gibt "" zurück oder eine Fehlermeldung.
func toggle_deck(cid: String) -> String:
if cid in deck:
deck.erase(cid)
return ""
if not owns(cid):
return "Karte nicht in der Sammlung"
if deck.size() >= Game.DECK_MAX:
return "Der Stapel ist voll (%d Karten)" % Game.DECK_MAX
deck.append(cid)
return ""
## Wertet eine Karte um eine Stufe auf. Gibt "" zurück oder eine Fehlermeldung.
func upgrade(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid)
if lvl >= Cards.RARITIES.size() - 1:
return "Schon auf der höchsten Stufe"
@@ -138,13 +176,29 @@ func upgrade(cid: String) -> String:
return ""
## Gibt eine Stufe ab und bekommt Staub dafür. Gibt "" zurück oder eine Fehlermeldung.
## Entzaubern: eine Stufe abgeben bringt Staub; eine Common-Karte verlässt die Sammlung.
## Gibt "" zurück oder eine Fehlermeldung.
func dismantle(cid: String) -> String:
if not owns(cid):
return "Karte nicht in der Sammlung"
var lvl := level(cid)
if lvl == 0:
return "Common-Karten lassen sich nicht zerlegen"
dust += DUST_DISMANTLE[Cards.RARITIES[lvl]]
collection[cid] = lvl - 1
if lvl == 0:
collection.erase(cid)
deck.erase(cid)
else:
collection[cid] = lvl - 1
return ""
## Herstellen: eine fehlende Karte für Staub als Common. Gibt "" zurück oder eine Fehlermeldung.
func craft(cid: String) -> String:
if owns(cid):
return "Karte schon in der Sammlung"
if dust < CRAFT_COST:
return "Nicht genug Staub (%d nötig)" % CRAFT_COST
dust -= CRAFT_COST
collection[cid] = 0
return ""
@@ -164,8 +218,9 @@ static func roll_booster(r: RandomNumberGenerator) -> Array:
return out
## Ö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.
## Ö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:
return []
@@ -175,14 +230,20 @@ func open_booster(r: RandomNumberGenerator = null) -> Array:
var out := roll_booster(r)
for res in out:
var lvl: int = Cards.RARITIES.find(res.rarity)
if lvl > level(res.card.id):
res["new"] = not owns(res.card.id)
res["upgraded"] = false
res["dust"] = 0
if res.new:
collection[res.card.id] = lvl
if deck.size() < Game.DECK_MAX:
deck.append(res.card.id)
elif lvl > level(res.card.id):
res["upgraded"] = true
res["dust"] = 0
res["dust"] = DUST_DISMANTLE[rarity(res.card.id)]
collection[res.card.id] = lvl
else:
res["upgraded"] = false
res["dust"] = DUST_DISMANTLE[res.rarity]
dust += res.dust
dust += res.dust
return out

View File

@@ -76,7 +76,7 @@ func _next_zone(step: int) -> void:
func items() -> Array:
match zone:
"deck":
return screen.offer_views
return [screen.deck] if screen.deck.visible else []
"hand":
return screen.hand_views
"own":
@@ -105,7 +105,7 @@ func confirm() -> void:
var item = items[index]
match zone:
"deck":
screen.on_click("deck", index)
screen.on_click("deck", null)
"hand":
screen.on_click("hand", item.card)
"own":

View File

@@ -1,31 +1,29 @@
class_name BattleLayout
## Feste Bildschirmaufteilung des Duells (1280x800). Mitte von oben nach unten: Hand des Gegners
## (Rückseiten), sein Brett, seine Elementleiste, die eigene Elementleiste (die Naht liegt auf halber
## Höhe), eigenes Brett, eigene Hand. Links: Kasten des Gegners, die drei offenen Karten des
## Stapels (mittig), eigener Kasten. Rechts oben die Knöpfe, rechts mittig die Vorschau, darunter Meldungen.
## (Rückseiten), sein Brett, eigenes Brett, eigene Hand. Links: Kasten des Gegners, daneben die
## Elementzähler beider Seiten je neben ihrem Brett, Zusatzzeile und Meldungen. Rechts von oben:
## Stapel und Kasten des Gegners, die Vorschau, eigener Kasten und Stapel. Optionen per Esc.
const B_GIVEUP := Rect2(1070, 10, 96, 32)
const B_LOG := Rect2(1172, 10, 96, 32)
const B_END := Rect2(1070, 48, 198, 32)
const OPP_BOX := Rect2(20, 11, 210, 96)
const B_UNDO := Rect2(20, 10, 210, 32) # links oben: Rückgängig, darunter Überspringen (Gegnerzug)
const B_SKIP := Rect2(20, 48, 210, 32)
const OPP_DECK := Rect2(1090, 10, 130, 182) # Stapel des Gegners rechts oben
const OPP_BOX := Rect2(1050, 200, 210, 70) # Kasten des Gegners darunter
const OPP_HAND_Y := 11
const FIELD_OPP := Rect2(240, 364, 800, 32)
const FIELD := Rect2(240, 404, 800, 32)
const MSG := Rect2(1040, 630, 220, 60)
const STATUS := Rect2(20, 694, 210, 96)
const B_UNDO := Rect2(1070, 86, 198, 32)
const OPP_ELEMENTS := Rect2(20, 220, 210, 128) # Elementzähler des Gegners, links von seinem Brett
const OWN_ELEMENTS := Rect2(20, 440, 210, 128) # eigene Elementzähler, links vom eigenen Brett
const INFO := Rect2(20, 580, 210, 60) # Zusatzzeile zur Vorschau
const MSG := Rect2(20, 650, 210, 80) # Meldungen
const STATUS := Rect2(1050, 526, 210, 70) # eigener Kasten rechts über dem Stapel
const DECK := Rect2(1090, 606, 130, 182) # eigener Stapel rechts unten
const OPTIONS := Rect2(440, 180, 400, 420) # Optionsmenü (Esc)
const OWN_X := 345
const HAND_W := 110 # Handkarten so groß wie die Wesen auf dem Feld
const HAND_H := 140
const HAND_GAP := 10
const HAND_Y := 610
const HAND_ARC := 3.0 # Grad Neigung und Pixel Absenkung je Platz von der Mitte (leichter Bogen)
const OFFER_X := 70 # die drei offenen Karten des Stapels, links übereinander um die Mitte
const OFFER_Y := 180
const OFFER_GAP := 10
const DECK_LABEL := Rect2(20, 632, 210, 22)
const LOG_PANEL := Rect2(240, 60, 800, 560)
const PREVIEW_SIZE := Vector2(180, 252)
const PREVIEW_SIZE := Vector2(170, 238)
const CENTER_X := 240
const CENTER_W := 800
@@ -61,11 +59,11 @@ static func hand_arc(count: int, i: int, mirrored: bool = false) -> Array:
return [t * HAND_ARC * sign, t * t * HAND_ARC * sign]
## Vorschau der gezeigten Karte: fest rechts auf halber Höhe.
## Vorschau der gezeigten Karte: fest rechts zwischen den beiden Kästen.
static func preview_rect(_anchor: Rect2) -> Rect2:
return Rect2(Vector2(1050, 400 - PREVIEW_SIZE.y / 2), PREVIEW_SIZE)
return Rect2(Vector2(1070, 278), PREVIEW_SIZE)
## Zusatzzeile (Elementbonus, Eigenschaften) unter der Vorschau.
static func info_rect(preview: Rect2) -> Rect2:
return Rect2(1030, preview.end.y + 8, 230, 80)
## Zusatzzeile (Elementbonus, Eigenschaften) links unter den Elementzählern.
static func info_rect(_preview: Rect2) -> Rect2:
return INFO

View File

@@ -1,17 +1,16 @@
class_name BattleScreen
extends Control
## Kampf: Zustand, Eingabe und Abgleich der Ansicht. Layout in BattleLayout, Animationen in BattleFx.
## Esc öffnet die Optionen (Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden); den Zug beendet
## die Leertaste oder der Rechtsklick ins Leere.
## Mittlere Maustaste auf eine Handkarte legt sie verdeckt. Rechtsklick auf eine Handkarte oder
## ein eigenes Wesen öffnet das Kartenmenü: verdeckt legen, aufdecken, Eigenschaften kaufen.
## Verschmelzen: eine Karte auf eine andere ziehen und dort eine Sekunde stillhalten. Mit
## Gamepad öffnet Y das Menü, dort zusätzlich mit „Verschmelzen mit …“.
## Modi: Einzelspieler, Kampagne, Netzwerk (Host, Mitspieler oder Zuschauer).
## Modi: Einzelspieler und Kampagne.
var app
var stage := -1 # Kampagnenstufe oder -1
var net_play := false # Netzwerkspiel (auch als Zuschauer)
var online_seat := -1 # Netzwerk: eigener Platz, Zuschauer -1
var net: Net
var game: Game
var perspective: Game.Player # der Mensch, dessen Hand unten liegt
var fx: BattleFx
@@ -60,12 +59,10 @@ var flash_until := {} # Spielerindex -> Zeitpunkt (ms)
var hero_layer: Control
var tile_layer: Control
var hand_layer: Control
var offer_views: Array = [] # die offenen Karten des Stapels (CardView)
var offer_layer: Control
var deck_label: Label
var draw_enabled := false
var deck: Deck
var opp_deck: Deck # Stapel des Gegners, nur zum Ansehen
var log_overlay: Control
var b_log: Button
var options: Control # Optionsmenü (Esc): Verlauf, Aufgeben, Einstellungen, Hauptmenü, Beenden
var status_panel: Panel
var fx_layer: Control
var log_box: RichTextLabel
@@ -74,8 +71,6 @@ var name_label: Label
var life_label: Label
var energy_label: Label
var msg_label: Label
var b_end: Button
var b_giveup: Button
var element_view: ElementView # eigene Elementleiste
var opp_element_view: ElementView # die des Gegners darüber
var info_label: Label # Vorschau: Elementbonus, Merkmale
@@ -89,7 +84,6 @@ var reward_label: Label
var notice_label: Label
var confirm: Control
var cursor_layer: Control
var wait_label: Label
var b_skip: Button
var b_undo: Button
@@ -98,30 +92,6 @@ func _init(campaign_stage: int = -1) -> void:
stage = campaign_stage
static func online(seat: int) -> BattleScreen:
var b := BattleScreen.new(-1)
b.net_play = true
b.online_seat = seat
return b
func is_online() -> bool:
return net_play
## Zuschauer im Netzwerk: kein eigener Platz, keine Hand.
func spectating() -> bool:
return is_online() and online_seat < 0
func is_client() -> bool:
return is_online() and not net.is_host
func counts_for_profile() -> bool:
return not is_online()
func _ready() -> void:
var s: Dictionary = app.profile.settings
fx = BattleFx.new(self)
@@ -129,31 +99,19 @@ func _ready() -> void:
pad = BattleGamepad.new(self)
ai_delay = 0.75 / float(s.ai_speed)
_build_static()
if is_online():
net = app.net
net.ended.connect(_on_net_ended)
net.migrated.connect(_on_migrated)
net.status_changed.connect(_on_net_status)
if net.is_host:
game = net.game
net.action_applied.connect(after_action)
else:
net.snapshot_received.connect(_on_snapshot)
wait_label.visible = true
return # der erste Zustand kommt vom Host
elif stage >= 0:
if stage >= 0:
var r := Game.make_rng()
game = Game.new_battle_with(Campaign.opponent(stage), r, false, own_rules())
game = Game.new_battle_with(Campaign.opponent(stage, r), r, false, own_rules())
else:
game = Game.new_battle(Game.make_rng(), own_rules())
smart_ai = bool(app.profile.difficulty().smart) or not counts_for_profile()
smart_ai = bool(app.profile.difficulty().smart)
_begin()
## Kartenoptik, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1).
## Eigener Stapel und Kartenstufen, Gegnerleben (Schwierigkeit) und das eigene Merkmal (nach Kapitel 1).
func own_rules() -> Dictionary:
var p: Profile = app.profile
var rules := {"looks": p.looks()}
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():
@@ -162,37 +120,22 @@ func own_rules() -> Dictionary:
func _begin() -> void:
if is_online():
perspective = game.players[maxi(online_seat, 0)]
else:
game.events.clear()
perspective = game.current() if game.current().is_human else game.humans[0]
game.events.clear()
perspective = game.human
build_hero_boxes()
if is_online() and net.is_host:
net.broadcast()
game.events.clear()
refresh()
announce_turn()
func _build_static() -> void:
var L := BattleLayout
# oben links: Aufgeben und Verlauf, klein
b_giveup = Style.button(self, "Aufgeben", L.B_GIVEUP, func(): confirm.visible = true)
b_giveup.add_theme_font_size_override("font_size", 13)
b_log = Style.button(self, "Verlauf", L.B_LOG, toggle_log)
b_log.add_theme_font_size_override("font_size", 13)
b_end = Style.button(self, "Zug beenden", L.B_END, end_turn, Style.PRIMARY)
b_end.add_theme_font_size_override("font_size", 15)
b_skip = Style.button(self, "Überspringen", L.B_END, func(): skip_ai = true)
b_skip.add_theme_font_size_override("font_size", 15)
b_skip = Style.button(self, "Überspringen", L.B_SKIP, func(): skip_ai = true)
b_skip.add_theme_font_size_override("font_size", 13)
b_skip.visible = false
# Mitte: Elementzähler, Meldung, eigenes Brett mit fünf Plätzen, eigene Leiste links daneben
Style.panel(self, L.FIELD_OPP, Style.darker(Style.PANEL, 0.9), 8)
opp_element_view = ElementView.new(L.FIELD_OPP)
# Elementzähler beider Seiten links neben ihrem Brett
opp_element_view = ElementView.new(L.OPP_ELEMENTS)
add_child(opp_element_view)
Style.panel(self, L.FIELD, Style.darker(Style.PANEL, 0.9), 8)
element_view = ElementView.new(L.FIELD)
element_view = ElementView.new(L.OWN_ELEMENTS)
add_child(element_view)
msg_label = Style.label(self, "", 14, Style.ORANGE, L.MSG, "center", true)
msg_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@@ -207,18 +150,21 @@ func _build_static() -> void:
var sx: float = L.STATUS.position.x
var sy: float = L.STATUS.position.y
var sw: float = L.STATUS.size.x
name_label = Style.label(self, "", 18, Style.TEXT, Rect2(sx + 8, sy + 6, sw - 16, 26), "center", true)
name_label = Style.label(self, "", 16, Style.TEXT, Rect2(sx + 8, sy + 4, sw - 16, 22), "center", true)
name_label.clip_text = true
life_label = Style.label(self, "", 30, Style.RED, Rect2(sx + 8, sy + 42, sw / 2 - 8, 40), "left", true)
energy_label = Style.label(self, "", 30, Style.BLUE, Rect2(sx + sw / 2, sy + 42, sw / 2 - 8, 40), "right", true)
life_label = Style.label(self, "", 26, Style.RED, Rect2(sx + 10, sy + 30, sw / 2 - 10, 34), "left", true)
energy_label = Style.label(self, "", 26, Style.BLUE, Rect2(sx + sw / 2, sy + 30, sw / 2 - 10, 34), "right", true)
b_undo = Style.button(self, "Rückgängig", L.B_UNDO, undo)
b_undo.add_theme_font_size_override("font_size", 13)
b_undo.visible = false
deck_label = Style.label(self, "", 13, Style.MUTED, L.DECK_LABEL, "center")
wait_label = Style.label(self, "Warte auf den Host …", 22, Style.MUTED, Rect2(0, 300, 1280, 40), "center")
wait_label.visible = false
deck = Deck.new(L.DECK)
deck.clicked.connect(func(): on_click("deck", null))
add_child(deck)
opp_deck = Deck.new(L.OPP_DECK)
opp_deck.interactive = false
opp_deck.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(opp_deck)
hero_layer = _layer()
offer_layer = _layer()
tile_layer = _layer()
hand_layer = _layer()
preview_holder = _layer()
@@ -252,6 +198,28 @@ func _build_static() -> void:
log_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
log_overlay.add_child(log_box)
Style.button(log_overlay, "Schließen", Rect2(L.LOG_PANEL.end.x - 180, L.LOG_PANEL.end.y - 52, 160, 40), func(): log_overlay.visible = false)
# Optionsmenü (Esc)
options = Style.overlay(self)
options.get_child(0).gui_input.connect(func(ev):
if ev is InputEventMouseButton and ev.pressed:
options.visible = false)
Style.panel(options, L.OPTIONS, Style.PANEL, 10, Style.GOLD, 2)
Style.label(options, "Optionen", 24, Style.GOLD, Rect2(L.OPTIONS.position.x, L.OPTIONS.position.y + 18, L.OPTIONS.size.x, 34), "center", true)
var oy := L.OPTIONS.position.y + 76
for entry in [["Verlauf", func():
options.visible = false
log_overlay.visible = true],
["Aufgeben", func():
options.visible = false
confirm.visible = true],
["Einstellungen", func():
options.visible = false
app.show_screen(SettingsScreen.new(), true)],
["Hauptmenü", leave],
["Beenden", func(): get_tree().quit()]]:
var ob := Style.button(options, entry[0], Rect2(L.OPTIONS.position.x + 60, oy, L.OPTIONS.size.x - 120, 48), entry[1])
ob.add_theme_font_size_override("font_size", 18)
oy += 60
# Spielende
overlay = _overlay()
result_label = Style.label(overlay, "", 56, Style.GREEN, Rect2(0, 250, 1280, 80), "center", true)
@@ -261,8 +229,8 @@ func _build_static() -> void:
confirm = Style.confirm(self, "Wirklich aufgeben?", "", "Ja, aufgeben", leave, "Weiterspielen")
func toggle_log() -> void:
log_overlay.visible = not log_overlay.visible
func toggle_options() -> void:
options.visible = not options.visible
func _layer() -> Control:
@@ -277,8 +245,6 @@ func _overlay() -> Control:
func leave() -> void:
if is_online():
net.leave()
app.show_screen(MenuScreen.new())
@@ -336,24 +302,22 @@ func build_hero_boxes() -> void:
var title: String = ("%s · %s" % [o.name, o.title]) if o.title != "" else o.name
if not o.perks.is_empty():
title += " ★"
labels["name"] = Style.label(hero_layer, title, 18, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 6, box.size.x - 16, 26), "center", true)
labels["name"] = Style.label(hero_layer, title, 16, Style.TEXT, Rect2(box.position.x + 8, box.position.y + 4, box.size.x - 16, 22), "center", true)
labels.name.clip_text = true
labels.name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
labels["life"] = Style.label(hero_layer, "", 30, Style.RED, Rect2(box.position.x + 8, box.position.y + 42, box.size.x / 2 - 8, 40), "left", true)
labels["stats"] = Style.label(hero_layer, "", 30, Style.BLUE, Rect2(box.position.x + box.size.x / 2, box.position.y + 42, box.size.x / 2 - 8, 40), "right", true)
labels["life"] = Style.label(hero_layer, "", 26, Style.RED, Rect2(box.position.x + 10, box.position.y + 30, box.size.x / 2 - 10, 34), "left", true)
labels["stats"] = Style.label(hero_layer, "", 26, Style.BLUE, Rect2(box.position.x + box.size.x / 2, box.position.y + 30, box.size.x / 2 - 10, 34), "right", true)
hero_labels[o] = labels
# --- Ablauf und Eingabe ------------------------------------------------------------------
func my_turn() -> bool:
if spectating():
return false
return game != null and game.winner == "" and game.current() == perspective
func any_overlay() -> bool:
return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible
return confirm.visible or overlay.visible or log_overlay.visible or menu_layer.visible or options.visible
func _input(event: InputEvent) -> void:
@@ -400,10 +364,12 @@ func _input(event: InputEvent) -> void:
close_menu()
elif log_overlay.visible:
log_overlay.visible = false
elif options.visible:
options.visible = false
elif selected != null or fuse_first != null:
cancel_all()
elif not any_overlay():
confirm.visible = true
options.visible = true
KEY_SPACE:
if my_turn() and not any_overlay():
end_turn()
@@ -451,8 +417,6 @@ func _process(delta: float) -> void:
_end_drag()
do_fuse(a, b)
after_action()
if is_online() and net.is_host:
net.tick()
if game.winner != "":
if not ended:
ended = true
@@ -461,8 +425,6 @@ func _process(delta: float) -> void:
var cur := game.current()
if cur.is_human:
return
if is_client():
return
if skip_ai:
ai_timer = 0.0
else:
@@ -506,15 +468,10 @@ func _exit_tree() -> void:
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, stage, factor)
app.save()
app.sfx.play("win" if won else "lose")
else:
var wp := game.winner_player
var mine: bool = wp != null and wp == perspective
app.sfx.play("win" if mine else "lose")
var factor: float = float(app.profile.difficulty().reward) if stage < 0 else 1.0
reward = app.profile.record_battle(won, stage, factor)
app.save()
app.sfx.play("win" if won else "lose")
refresh()
@@ -631,10 +588,7 @@ func fusion_partners(target) -> Array:
func play(card: Cards.Card, face_down: bool = false) -> void:
if is_client():
net.send_action({"type": "play", "hand": perspective.hand.find(card), "face_down": face_down})
return
var before: Game = game.clone() if not is_online() else null
var before: Game = game.clone()
var log_len := game.log.size()
var from := _hand_rect_of(card)
var err := game.play_card(perspective, card, face_down)
@@ -655,9 +609,6 @@ func play(card: Cards.Card, face_down: bool = false) -> void:
## Deckt ein eigenes verdecktes Wesen auf (kostenlos).
func do_reveal(cr: Game.Creature) -> void:
clear_undo()
if is_client():
net.send_action({"type": "reveal", "uid": cr.uid})
return
var err := game.reveal(perspective, cr)
if err != "":
say(tr(err))
@@ -667,7 +618,7 @@ func do_reveal(cr: Game.Creature) -> void:
## Kartenmenü neben einer Handkarte oder einem eigenen Wesen. full: auch verschmelzen anbieten
## (Gamepad, das nicht ziehen kann).
func open_menu(target, anchor: Rect2, full: bool = false) -> void:
if spectating() or not my_turn():
if not my_turn():
return
cancel()
Style.clear(menu_layer)
@@ -685,7 +636,7 @@ func open_menu(target, anchor: Rect2, full: bool = false) -> void:
entries.append([tr("Aufdecken"), func(): do_reveal(target), game.can_reveal(perspective, target)[0]])
if full:
var partners := fusion_partners(target)
entries.append([tr("Verschmelzen mit … ◆ %d") % card.fusion_cost(), func():
entries.append([tr("Verschmelzen mit …"), func():
fuse_first = target
say(tr("Ziel wählen"))
refresh(), not partners.is_empty()])
@@ -729,22 +680,16 @@ func close_menu() -> void:
## Abwerfen: eine Handkarte gegen Mana loswerden.
func do_discard(card: Cards.Card) -> void:
clear_undo()
if is_client():
net.send_action({"type": "discard", "hand": perspective.hand.find(card)})
return
var err := game.discard(perspective, card)
if err != "":
say(tr(err))
after_action()
## Ziehen: kostet Mana, eine Karte kommt auf die Hand.
func do_draw(index: int = 0) -> void:
## Ziehen: eine Karte vom eigenen Stapel, die erste im Zug frei.
func do_draw() -> void:
clear_undo()
if is_client():
net.send_action({"type": "draw", "index": index})
return
var err := game.draw(perspective, index)
var err := game.draw(perspective)
if err != "":
say(tr(err))
else:
@@ -755,12 +700,6 @@ func do_draw(index: int = 0) -> void:
func do_fuse(a, b) -> void:
cancel()
clear_undo()
if is_client():
if a is Cards.Card:
net.send_action({"type": "fuse", "hand": perspective.hand.find(a), "other_hand": perspective.hand.find(b)})
else:
net.send_action({"type": "fuse", "uid": a.uid, "other": b.uid})
return
var err := game.fuse(perspective, a, b)
if err != "":
say(tr(err))
@@ -784,8 +723,6 @@ func undo() -> void:
restored.say("Rückgängig.")
game = restored
perspective = game.players[idx]
if is_online() and net.is_host:
net.game = game
clear_undo()
cancel()
build_hero_boxes()
@@ -797,9 +734,6 @@ func end_turn() -> void:
return
cancel()
clear_undo()
if is_client():
net.send_action({"type": "end"})
return
game.end_turn()
ai_timer = ai_delay
after_action()
@@ -823,17 +757,14 @@ func on_click(kind: String, obj) -> void:
selected = null
if kind == "enemy" or kind == "hero":
clear_undo()
if is_client():
net.send_action({"type": "attack", "uid": cr.uid, "target": Net.ref_dict(obj)})
else:
var err := g.attack(cr, obj)
if err != "":
say(tr(err))
var err := g.attack(cr, obj)
if err != "":
say(tr(err))
after_action()
return
match kind:
"deck":
do_draw(int(obj) if obj != null else 0)
do_draw()
"hand":
var ok := g.can_play(perspective, obj)
if not ok[0]:
@@ -949,6 +880,41 @@ func update_attack_preview() -> void:
v.queue_redraw()
## Der eigene Stapel: Kartenstoß mit Kosten des nächsten Ziehens und Restzahl.
class Deck extends Control:
signal clicked
var enabled := true
var interactive := true # false: der Stapel des Gegners, nur Anzahl
var count := 0
var cost := 0
func _init(rect: Rect2) -> void:
position = rect.position
size = rect.size
mouse_filter = Control.MOUSE_FILTER_STOP
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
clicked.emit()
func _draw() -> void:
for k in range(2, -1, -1):
var r := Rect2(Vector2(k * 3, -k * 3), size - Vector2(6, 12))
r.position.y += 6
draw_style_box(Style.box(Color("3c285a"), 8, Style.GOLD if enabled and interactive else Style.MUTED, 2), r)
if k == 0:
Style.border(self, r.grow(-12), Color("5a3c82"), 2, 6)
if interactive:
Style.text(self, tr("Ziehen"), 16, Style.TEXT if enabled else Style.MUTED, r.get_center() + Vector2(0, -12), "center", true)
Style.text(self, "◆ %d" % cost, 20, Style.BLUE if enabled else Style.MUTED, r.get_center() + Vector2(0, 14), "center", true)
Style.text(self, str(count), 12, Style.MUTED, Vector2(r.get_center().x, r.end.y - 18), "center")
else:
Style.text(self, str(count), 22, Style.TEXT, r.get_center(), "center", true)
if interactive and not enabled:
draw_rect(Rect2(Vector2.ZERO, size), Color(0, 0, 0, 0.35))
## Eine verdeckte Handkarte des Gegners in voller Größe.
class CardBack extends Control:
func _init(rect: Rect2) -> void:
@@ -967,51 +933,10 @@ class CardBack extends Control:
# --- Netzwerk -------------------------------------------------------------------------------
func _on_snapshot(snap: Dictionary) -> void:
var first := game == null
game = Game.from_snapshot(snap)
wait_label.visible = false
if first:
_begin()
return
perspective = game.players[maxi(online_seat, 0)]
build_hero_boxes()
after_action()
## Host-Wechsel: das Spiel läuft jetzt auf diesem Rechner weiter.
func _on_migrated() -> void:
game = net.game
if not net.action_applied.is_connected(after_action):
net.action_applied.connect(after_action)
wait_label.visible = false
perspective = game.players[online_seat]
build_hero_boxes()
say("Du bist jetzt Host.")
after_action()
func _on_net_status() -> void:
wait_label.text = net.status
wait_label.visible = true
func _on_net_ended(reason: String) -> void:
if game == null or game.winner == "":
say(reason)
await get_tree().create_timer(1.5).timeout
if is_inside_tree():
app.show_screen(MenuScreen.new())
# --- Ereignisse ----------------------------------------------------------------------------
## Nach jeder Aktion: Ereignisse sichern, Ansicht angleichen, Animationen abspielen.
func after_action() -> void:
if is_online() and net.is_host:
net.broadcast()
var evs := game.events.duplicate()
game.events.clear()
var centers := {}
@@ -1138,23 +1063,12 @@ func refresh() -> void:
tiles.erase(uid)
fx.fade_out(t)
update_attack_preview()
Style.clear(offer_layer)
offer_views = []
draw_enabled = mine and g.can_draw(h)[0]
if not spectating():
for i in h.offer.size():
var oc: Cards.Card = h.offer[i]
var orect := Rect2(BattleLayout.OFFER_X, BattleLayout.OFFER_Y + i * (BattleLayout.HAND_H + BattleLayout.OFFER_GAP), BattleLayout.HAND_W, BattleLayout.HAND_H)
var ov := CardView.new(oc, orect.size, false, oc.rarity_in(h.looks))
ov.position = orect.position
ov.dim = not draw_enabled
var idx := i
ov.clicked.connect(func(_v): on_click("deck", idx))
ov.hover.connect(func(c, e): show_preview(c, e, h.looks, "", orect))
offer_layer.add_child(ov)
offer_views.append(ov)
deck_label.text = "%s ◆ %d · %d" % [tr("Ziehen"), g.draw_cost(h), g.deck.size()]
deck_label.visible = not spectating()
deck.enabled = mine and g.can_draw(h)[0]
deck.count = h.deck.size()
deck.cost = g.draw_cost(h)
deck.queue_redraw()
opp_deck.count = opponent().deck.size()
opp_deck.queue_redraw()
Style.clear(hand_layer)
for o in others(): # die Hand des Gegners oben, nur Rückseiten, spiegelverkehrter Bogen
@@ -1165,9 +1079,7 @@ func refresh() -> void:
back.rotation_degrees = arc[0]
back.position.y += arc[1]
hand_layer.add_child(back)
if spectating():
Style.label(hand_layer, "Zuschauer: Hände sind verdeckt", 18, Style.MUTED, Rect2(240, BattleLayout.HAND_Y + 18, 800, 30), "center")
else:
if true:
for i in h.hand.size():
var card: Cards.Card = h.hand[i]
var playable: bool = mine and g.can_play(h, card)[0]
@@ -1199,10 +1111,8 @@ func refresh() -> void:
name_label.text = h.name
life_label.text = "♥ %d" % maxi(h.life, 0)
energy_label.text = "◆ %d" % h.energy
b_end.disabled = not mine
var ai_running: bool = g.winner == "" and not g.current().is_human and not is_client()
var ai_running: bool = g.winner == "" and not g.current().is_human
b_skip.visible = ai_running
b_end.visible = not ai_running and not spectating()
b_undo.visible = undo_state != null and mine
element_view.update_from(g, h)
opp_element_view.update_from(g, opponent())
@@ -1222,20 +1132,13 @@ func refresh() -> void:
pad.draw_cursor()
if g.winner != "":
var wp := g.winner_player
if counts_for_profile():
var won := g.winner == "human"
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)
var notes: Array[String] = app.profile.take_notices()
if not notes.is_empty():
notice_label.text = "\n".join(notes)
else:
var mine_win: bool = wp != null and wp == perspective
result_label.text = ("%s gewinnt!" % wp.name) if wp != null and wp.is_human else "Die KI gewinnt"
result_label.add_theme_color_override("font_color", Style.GREEN if mine_win else Style.RED)
reward_label.text = "Gemeinsame Partie, keine Münzen"
var won := g.winner == "human"
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)
var notes: Array[String] = app.profile.take_notices()
if not notes.is_empty():
notice_label.text = "\n".join(notes)
overlay.visible = true
confirm.visible = false

View File

@@ -137,13 +137,15 @@ func refresh() -> void:
elif revealed.has(i):
var res: Dictionary = cards[i]
var c: Cards.Card = res.card
var v := CardView.new(c, r.size, false, res.rarity)
var v := CardView.new(c.leveled(Cards.RARITIES.find(res.rarity)), r.size, false, res.rarity)
v.position = r.position
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)
if res.upgraded:
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)
elif 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, "+%d Staub" % res.dust, 14, Style.MUTED, Rect2(r.position.x, r.end.y + 32, r.size.x, 24), "center")

View File

@@ -1,7 +1,7 @@
class_name CollectionScreen
extends Control
## Sammlung: alle Karten gehören dir. Gesammelt wird die Seltenheitsstufe — reine Optik.
## Aufwerten kostet Staub, eine Stufe abgeben bringt Staub.
## Sammlung: alle Karten des Spiels, die fehlenden abgedunkelt. Eigene Karten lassen sich
## aufwerten (Staub) oder zerlegen (bringt Staub), fehlende herstellen (Staub).
var app
var filter := ""
@@ -15,6 +15,7 @@ var preview_holder: Control
var info_label: Label
var b_dismantle: Button
var b_upgrade: Button
var b_craft: Button
func _ready() -> void:
@@ -43,6 +44,7 @@ func _ready() -> void:
info_label = Style.label(self, "", 15, Style.MUTED, Rect2(840, 476, 380, 30), "center")
b_upgrade = Style.button(self, "", Rect2(840, 530, 380, 46), upgrade, Style.PRIMARY)
b_dismantle = Style.button(self, "", Rect2(840, 586, 380, 46), dismantle, Color("5a3c28"))
b_craft = Style.button(self, "", Rect2(840, 530, 380, 46), craft, Style.PRIMARY)
msg_label = Style.label(self, "", 15, Style.GREEN, Rect2(840, 650, 380, 48), "center")
refresh()
@@ -57,7 +59,7 @@ func set_filter(e: String) -> void:
refresh()
## Aufgewertete Karten zuerst, sonst in Kartenreihenfolge.
## Eigene Karten zuerst (höhere Stufen vorn), dann die fehlenden, sonst in Kartenreihenfolge.
func cards() -> Array:
var out: Array = Cards.all().duplicate()
if filter != "":
@@ -69,7 +71,10 @@ func cards() -> Array:
var order := {}
for i in out.size():
order[out[i].id] = i
out.sort_custom(func(a, b): return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id])
out.sort_custom(func(a, b):
if p.owns(a.id) != p.owns(b.id):
return p.owns(a.id)
return p.level(a.id) > p.level(b.id) if p.level(a.id) != p.level(b.id) else order[a.id] < order[b.id])
return out
@@ -111,11 +116,31 @@ func dismantle() -> void:
refresh()
func craft() -> void:
if selected == null:
return
var err: String = app.profile.craft(selected.id)
if err == "":
app.sfx.play("combo")
app.save()
say(tr("%s hergestellt") % selected.title(), true)
else:
say(err, false)
refresh()
func refresh() -> void:
var p: Profile = app.profile
dust_label.text = "Staub: %d" % p.dust
grid.fill(cards(), func(c): return p.rarity(c.id))
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 p.owns(shown[i].id)
Style.clear(preview_holder)
b_craft.visible = false
b_upgrade.visible = true
b_dismantle.visible = true
if selected == null:
info_label.text = "Keine Karte gewählt"
b_upgrade.text = "Aufwerten"
@@ -123,11 +148,19 @@ func refresh() -> void:
b_upgrade.disabled = true
b_dismantle.disabled = true
return
var v := CardView.new(selected, Vector2(220, 308), false, p.rarity(selected.id))
var v := CardView.new(selected.leveled(p.level(selected.id)), Vector2(220, 308), false, p.rarity(selected.id))
v.position = Vector2(920, 152)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
preview_holder.add_child(v)
var lvl := p.level(selected.id)
if not p.owns(selected.id):
info_label.text = tr("Nicht in der Sammlung")
b_upgrade.visible = false
b_dismantle.visible = false
b_craft.visible = true
b_craft.text = tr("Herstellen (%d Staub)") % Profile.CRAFT_COST
b_craft.disabled = p.dust < Profile.CRAFT_COST
return
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"
@@ -136,5 +169,5 @@ func refresh() -> void:
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
b_dismantle.text = "Zerlegen (+%d Staub)" % Profile.DUST_DISMANTLE[Cards.RARITIES[lvl]]
b_dismantle.disabled = false

93
scripts/ui/deck_screen.gd Normal file
View File

@@ -0,0 +1,93 @@
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 teuersten eigenen Karten des gewählten Filters.
var app
var filter := ""
var grid: CardGrid
var count_label: Label
var msg_label: Label
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(""))
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[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)
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)
var hint := Style.label(self, "Klick nimmt eine Karte in den Stapel oder heraus. Auffüllen nimmt die teuersten Karten des Filters.", 15, Style.MUTED, Rect2(840, 160, 380, 60), "center")
hint.autowrap_mode = TextServer.AUTOWRAP_WORD
Style.button(self, "Auffüllen", Rect2(840, 240, 380, 46), fill_up)
Style.button(self, "Leeren", Rect2(840, 296, 380, 46), clear_deck, Color("5a3c28"))
msg_label = Style.label(self, "", 15, Style.RED, Rect2(840, 360, 380, 48), "center")
refresh()
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
app.show_screen(MenuScreen.new())
func set_filter(e: String) -> void:
filter = e
refresh()
func toggle(card: Cards.Card) -> void:
var err: String = app.profile.toggle_deck(card.id)
msg_label.text = err
if err == "":
app.sfx.play("click")
app.save()
refresh()
## Füllt mit den teuersten eigenen Karten des gewählten Filters bis zum Höchstwert auf.
func fill_up() -> void:
var p: Profile = app.profile
var pool: Array = p.collection.keys()
if filter != "":
pool = pool.filter(func(id): return filter in Cards.by_id(id).elements)
p.deck = DeckGen.fill(p.deck, pool, Game.DECK_MAX)
app.save()
refresh()
func clear_deck() -> void:
app.profile.deck = []
app.save()
refresh()
## Eigene Karten, die im Stapel zuerst, sonst nach Kosten.
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
return a.cost < b.cost if a.cost != b.cost else a.id < b.id)
return out
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)
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)

View File

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

View File

@@ -1,10 +1,11 @@
class_name ElementView
extends Control
## Elementzähler der eigenen Seite, mittig: je Element zweimal das Symbol seines Sondereffekts in
## der Elementfarbe, leuchtend je offenem eigenen Wesen (beide: Effekt aktiv), daneben in der
## Elementfarbe das Mana der bisher ausgespielten Karten (je Game.ELEMENT_MANA gibt es +1 Angriff).
## 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), die Zahl der ausgespielten Karten und ab
## Game.ELEMENT_COUNT der Bonus +1/+1.
const ENTRY_W := 180.0
const ROW := 32.0
var own := {} # offene eigene Wesen je Element
var played := {} # ausgespielte Karten je Element
@@ -31,37 +32,41 @@ func paired(e: String) -> bool:
func bonus(e: String) -> int:
return int(played.get(e, 0)) / Game.ELEMENT_MANA
return int(played.get(e, 0)) / Game.ELEMENT_COUNT
## Elemente, bei denen sich etwas getan hat (offene Wesen oder ausgespielte Karten).
func shown() -> Array:
return Cards.ELEMENTS.filter(func(e): return int(own.get(e, 0)) > 0 or int(played.get(e, 0)) > 0)
## Der Inhalt als Text (Tests, Protokoll): Element offen/gespielt, dazu Effekt und Bonus.
func text() -> String:
var parts: Array[String] = []
for e in Cards.ELEMENTS:
for e in shown():
var s := "%s %d/%d" % [tr(e), int(own.get(e, 0)), int(played.get(e, 0))]
if paired(e):
s += " (%s)" % tr(Cards.ELEMENT_KEYWORD[e])
if bonus(e) > 0:
s += " (+%d)" % bonus(e)
s += " (+%d/+%d)" % [bonus(e), bonus(e)]
parts.append(s)
return " · ".join(parts)
func _draw() -> void:
var x0 := (size.x - ENTRY_W * Cards.ELEMENTS.size()) / 2
var cy := size.y / 2
for i in Cards.ELEMENTS.size():
var e: String = Cards.ELEMENTS[i]
var y := ROW / 2
for e in shown():
var col: Color = Cards.ELEMENT_COLORS[e]
var active := paired(e) or bonus(e) > 0
var px := x0 + i * ENTRY_W + 10
Style.text(self, tr(e), 13, Style.TEXT if active else Style.MUTED, Vector2(px, cy), "midleft", active)
px += Style.FONT_BOLD.get_string_size(tr(e), HORIZONTAL_ALIGNMENT_LEFT, -1, Style.fs(13)).x + 10
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)
px = 76
for k in Game.ELEMENT_PAIR:
var filled := k < int(own.get(e, 0))
Style.keyword_icon(self, Cards.ELEMENT_KEYWORD[e], Vector2(px + k * 24 + 10, cy), 8, Style.lighter(col, 0.45) if filled else Color(col, 0.3))
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, cy), "midleft", true)
px += 20
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" % bonus(e), 12, Style.GREEN, Vector2(px, cy), "midleft", true)
Style.text(self, "+%d/+%d" % [bonus(e), bonus(e)], 12, Style.GREEN, Vector2(size.x - 6, y), "midright", true)
y += ROW

View File

@@ -1,107 +0,0 @@
class_name LobbyScreen
extends Control
## Netzwerkspiel im lokalen Netz: Spiel eröffnen oder beitreten.
var app
var net: Net
var status_label: Label
var list_label: Label
var ip_box: LineEdit
var b_host: Button
var b_join: Button
var b_watch: Button
var b_start: Button
var addr_label: Label
func _ready() -> void:
net = app.net
net.my_name = "Spieler"
net.my_pick = {"looks": app.profile.looks()}
Style.header(self, "Netzwerkspiel", _back)
var col := Style.column(self, 760, 110, 10)
var hr := Style.row(col, 12)
hr.alignment = BoxContainer.ALIGNMENT_CENTER
b_host = Style.cbutton(hr, "Spiel eröffnen", Vector2(200, 46), _host, Color("285a3c"))
ip_box = LineEdit.new()
ip_box.custom_minimum_size = Vector2(220, 46)
ip_box.placeholder_text = "Adresse des Hosts"
ip_box.text = "127.0.0.1"
hr.add_child(ip_box)
b_join = Style.cbutton(hr, "Beitreten", Vector2(160, 46), _join)
b_watch = Style.cbutton(hr, "Zuschauen", Vector2(150, 46), _watch)
addr_label = Style.clabel(col, "", 14, Style.MUTED)
status_label = Style.clabel(col, "", 16, Style.BLUE)
Style.spacer(col, 6)
Style.clabel(col, "Am Tisch", 20, Style.TEXT, "center", true)
list_label = Style.clabel(col, "", 16, Style.TEXT)
Style.spacer(col, 10)
var br := Style.row(col, 20)
br.alignment = BoxContainer.ALIGNMENT_CENTER
b_start = Style.cbutton(br, "Start", Vector2(200, 48), _start, Color("285a3c"))
net.lobby_changed.connect(refresh)
net.started.connect(_started)
net.ended.connect(func(_reason): refresh())
refresh()
func _exit_tree() -> void:
if net.lobby_changed.is_connected(refresh):
net.lobby_changed.disconnect(refresh)
if net.started.is_connected(_started):
net.started.disconnect(_started)
func _host() -> void:
net.host()
refresh()
func _join() -> void:
net.join(ip_box.text.strip_edges())
refresh()
## Als Zuschauer beitreten: sieht die Partie ohne Hände, spielt nicht mit.
func _watch() -> void:
net.spectator = true
net.join(ip_box.text.strip_edges())
refresh()
func _start() -> void:
net.start_battle()
func _started(seat: int) -> void:
app.show_screen(BattleScreen.online(seat))
func _back() -> void:
net.leave()
app.show_screen(MenuScreen.new())
func refresh() -> void:
status_label.text = net.status
var lines: Array[String] = []
var ids: Array = net.names.keys()
ids.sort()
for id in ids:
lines.append("%s%s" % [net.names[id], " (Host)" if id == 1 else ""])
for id in net.spectators:
lines.append("%s (%s)" % [net.spectators[id], tr("Zuschauer")])
list_label.text = "\n".join(lines) if not lines.is_empty() else "–"
b_host.disabled = net.connected
b_join.disabled = net.connected
b_watch.disabled = net.connected
ip_box.editable = not net.connected
var humans := ids.size()
b_start.disabled = not net.is_host
b_start.text = tr("Start") if humans >= 2 else tr("Start gegen die KI")
addr_label.text = ("Deine Adressen: " + ", ".join(net.local_addresses())) if net.is_host else ""
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not ip_box.has_focus():
_back()

View File

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

View File

@@ -5,7 +5,6 @@ var profile: Profile
var screen: Control
var save_path: String = Profile.SAVE_PATH
var sfx: Sfx
var net: Net
func _ready() -> void:
@@ -17,14 +16,9 @@ func _ready() -> void:
add_child(bg)
sfx = Sfx.new()
add_child(sfx)
# Fester Pfad /root/Net: Netzwerkaufrufe werden über den Knotenpfad zugestellt,
# der bei Host und Mitspielern gleich sein muss.
net = Net.new()
net.name = "Net"
get_tree().root.add_child.call_deferred(net)
profile = Profile.load_or_new(save_path)
apply_settings()
show_screen(MenuScreen.new())
show_screen(MenuScreen.new() if profile.started() else StartDeckScreen.new())
func apply_settings() -> void:
@@ -36,20 +30,32 @@ func apply_settings() -> void:
theme = Style.make_theme()
## Neues Profil: Sammlung und Fortschritt werden verworfen.
## Neues Profil: Sammlung und Fortschritt werden verworfen, das Startdeck wird neu gewählt.
func reset_profile() -> void:
profile = Profile.starter()
save()
apply_settings()
show_screen(StartDeckScreen.new())
func set_language(code: String) -> void:
TranslationServer.set_locale(code)
func show_screen(s: Control) -> void:
var kept: Array = [] # vorige Bildschirme, zu denen back() zurückführt (Einstellungen aus dem Kampf)
## keep: der aktuelle Bildschirm bleibt erhalten und kommt mit back() zurück.
func show_screen(s: Control, keep: bool = false) -> void:
if screen != null:
screen.queue_free()
if keep:
remove_child(screen)
kept.append(screen)
else:
screen.queue_free()
for k in kept:
k.queue_free()
kept.clear()
screen = s
s.app = self
s.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -106,11 +112,17 @@ func _first_button(node: Node) -> Button:
return null
## Zurück: zum gemerkten Bildschirm, sonst ins Hauptmenü.
func back() -> void:
if kept.is_empty():
show_screen(MenuScreen.new())
return
var prev: Control = kept.pop_back()
screen.queue_free()
screen = prev
add_child(prev)
func save() -> void:
profile.save(save_path)
func _exit_tree() -> void:
if net != null and net.is_inside_tree():
net.leave()
net.queue_free()

View File

@@ -17,7 +17,7 @@ func _ready() -> void:
var y := 230
for entry in [["Einzelspieler", func(): app.show_screen(OpponentSelectScreen.new())],
["Kampagne", func(): app.show_screen(CampaignScreen.new())],
["Netzwerkspiel", func(): app.show_screen(LobbyScreen.new())]]:
["Deck", func(): app.show_screen(DeckScreen.new())]]:
var b := Style.button(self, entry[0], Rect2(80, y, 380, 62), entry[1], Style.PRIMARY)
b.add_theme_font_size_override("font_size", 24)
y += 74
@@ -35,9 +35,9 @@ func _ready() -> void:
refresh()
## Drei Karten der Sammlung, die höchsten Stufen zuerst, leicht gefächert.
## Drei eigene Karten, die höchsten Stufen zuerst, leicht gefächert.
func _showcase(p: Profile) -> void:
var cards: Array = Cards.all().duplicate()
var cards: Array = p.collection.keys().map(func(id): return Cards.by_id(id))
cards.sort_custom(func(a, b):
if p.level(a.id) != p.level(b.id):
return p.level(a.id) > p.level(b.id)
@@ -45,7 +45,7 @@ func _showcase(p: Profile) -> void:
var spots := [[Vector2(830, 300), -0.14], [Vector2(950, 270), 0.0], [Vector2(1070, 300), 0.14]]
for i in 3:
var c: Cards.Card = cards[i]
var v := CardView.new(c, Vector2(180, 252), false, p.rarity(c.id))
var v := CardView.new(c.leveled(p.level(c.id)), Vector2(180, 252), false, p.rarity(c.id))
v.position = spots[i][0]
v.rotation = spots[i][1]
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -55,8 +55,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("Aufgewertet: %d von %d Karten · Kampagne: %d/%d") % [
p.upgraded_count(), Cards.all().size(), p.campaign_stage, Campaign.total()]
coll_label.text = tr("Sammlung: %d von %d Karten · Kampagne: %d/%d") % [
p.collection.size(), 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

@@ -16,7 +16,7 @@ var speed_label: Label
func _ready() -> void:
var s: Dictionary = app.profile.settings
Style.header(self, "Einstellungen", func(): app.show_screen(MenuScreen.new()))
Style.header(self, "Einstellungen", func(): app.back())
var col := Style.column(self, 700, 110, 24)
var grid := GridContainer.new()
grid.columns = 3
@@ -86,7 +86,6 @@ func _ready() -> void:
func _reset() -> void:
app.reset_profile()
app.show_screen(MenuScreen.new())
func _on_big_font(on: bool) -> void:
@@ -139,4 +138,4 @@ func _on_anim(on: bool) -> void:
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
app.show_screen(MenuScreen.new())
app.back()

View File

@@ -0,0 +1,37 @@
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(110, 140), 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())

View File

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