class_name Game extends RefCounted ## Spielregeln: Duell, Mana je Zug, Ziehen aus dem gemeinsamen Stapel, verdeckte Wesen, ## Elementboni, Verschmelzen und Verbessern. 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 START_LIFE := 30 const ELEMENT_PAIR := 2 # so viele eigene offene Wesen eines Elements: Sondereffekt des Elements const ELEMENT_STACK := 3 # so viele offene Wesen eines Elements auf dem Tisch: +1/+1 # Merkmale der Kampagnen-Bosse (Player.perks). const PERKS := { "feuer_angriff": "Feuerwesen haben +1 Angriff", "ziehen_gratis": "Ziehen kostet kein Mana", "wesen_angriff": "Wesen kommen mit +1 Angriff", "wesen_leben": "Wesen kommen mit +1 Leben", "extra_mana": "Bekommt jeden Zug 1 Mana mehr", "leben_35": "Beginnt mit 5 Leben mehr", "alle_flink": "Wesen greifen sofort an", "verbessern_billig": "Eigenschaften kosten 1 weniger", "start_energie": "Hat im ersten Zug 2 Mana mehr", "element_doppelt": "Elementbonus zählt doppelt", } ## Für wiederholbare Tests: 0 = echter Zufall, sonst Startwert (zählt je Aufruf hoch). static var test_seed := 0 static func make_rng() -> RandomNumberGenerator: var r := RandomNumberGenerator.new() if test_seed != 0: r.seed = test_seed test_seed += 1 else: r.randomize() return r class Creature extends RefCounted: var card: Cards.Card var owner_ref: WeakRef # schwache Referenz: kein Zyklus Spieler <-> Wesen var name: String var uid: int var attack: int var health: int 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 func _init(c: Cards.Card, o, id: int) -> void: card = c owner_ref = weakref(o) name = c.name uid = id attack = c.attack health = c.health max_health = c.health func owner(): return owner_ref.get_ref() ## Eigenschaft: gekauft, als Sondereffekt des Elementpaars der eigenen Seite oder als ## Boss-Merkmal. Verdeckte Wesen haben keine. func has(kw: String) -> bool: if hidden: return false if kw in card.keywords: return true var o = owner() if o == null: return false if kw == "Flink" and "alle_flink" in o.perks: return true for e in card.elements: if Cards.ELEMENT_KEYWORD.get(e, "") == kw and o.element_count(e) >= Game.ELEMENT_PAIR: return true return false ## Alle wirksamen Eigenschaften in fester Reihenfolge. func keywords() -> Array[String]: var out: Array[String] = [] for k in Cards.KEYWORDS: if has(k): out.append(k) return out ## Angriffswert inklusive Elementbonus und Boss-Merkmalen. func power() -> int: var v := attack + aura var o = owner() if o != null: if "feuer_angriff" in o.perks and "Feuer" in card.elements: v += 1 return v func attacks_per_turn() -> int: return 2 if has("Doppelschlag") else 1 func attacks_left() -> int: return maxi(0, attacks_per_turn() - attacks_done) ## Angreifen darf, wer offen liegt, noch Angriffe hat und seit einer Runde liegt (oder flink ist). func can_attack() -> bool: return not hidden and attacks_left() > 0 and power() > 0 and (ready or has("Flink")) func clone(new_owner) -> Creature: var c := Creature.new(card, new_owner, uid) c.attack = attack c.health = health c.max_health = max_health c.attacks_done = attacks_done c.ready = ready c.hidden = hidden c.aura = aura return c class Player extends RefCounted: var name: String var title: String var is_human: bool var life: int var max_life: int var hand: Array = [] # eigene Exemplare (Cards.Card.copy) var board: Array = [] 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 func _init(n: String, h: bool, l: int, t: String = "") -> void: name = n title = t is_human = h life = l max_life = l func alive() -> bool: return life > 0 ## Offene eigene Wesen mit diesem Element. func element_count(e: String) -> int: var n := 0 for c in board: if not c.hidden and e in c.card.elements: n += 1 return n func clone() -> Player: var p := Player.new(name, is_human, life, title) p.max_life = max_life p.hand = hand.duplicate() p.energy = energy p.max_energy = max_energy p.order = order p.perks = perks.duplicate() p.looks = looks p.turns = turns 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 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] = [] var events: Array = [] # strukturierte Ereignisse für Animationen var winner := "" # "", "human" oder "opponents" var quiet := false # in KI-Kopien: kein Verlauf, keine Ereignisse var next_uid := 1 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 func current() -> Player: return players[current_idx] func say(t: String) -> void: if not quiet: log.append(t) func emit(ev: Dictionary) -> void: if not quiet: events.append(ev) func ref_of(target) -> Dictionary: if target is Creature: return {"kind": "creature", "uid": target.uid} return {"kind": "player", "index": players.find(target)} ## Der Gegner von p, solange er lebt (als Liste, für Schleifen über Ziele). func enemies_of(p: Player) -> Array: return players.filter(func(o): return o != p and o.alive()) func enemy_of(p: Player) -> Player: return opponent if p == human else human func find_creature(uid: int) -> Creature: for p in players: for c in p.board: if c.uid == uid: return c return null ## Unabhängige Kopie des Spielzustands (für Rückgängig und die KI-Vorausschau). func clone() -> Game: var copies := [] for p in players: copies.append(p.clone()) 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 g.next_uid = next_uid g.quiet = true return g ## Kopie aus Sicht von viewer: verdeckte gegnerische Wesen sind unbekannt (für die KI). func masked_clone(viewer: Player) -> Game: var g := clone() var me: Player = g.players[players.find(viewer)] for p in g.players: if p == me: continue for c in p.board: if c.hidden: c.card = Cards.unknown() c.name = c.card.name c.attack = c.card.attack c.health = c.card.health c.max_health = c.card.health return g # --- Zugablauf --------------------------------------------------------------- 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()) turn = 1 current_idx = 0 say("Zugreihenfolge: " + ", ".join(players.map(func(p): return p.name))) for p in players: for perk in p.perks: say("%s: %s." % [p.name, PERKS[perk]]) begin_turn(current()) ## Nimmt n Karten vom Stapel, die höchstens max_cost kosten (was man sich in diesem Zug leisten ## kann); zu teure wandern unter den Stapel. Ist der Stapel leer, kommt ein frischer. func draw_cards(n: int, max_cost: int) -> 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: kostet DRAW_COST, eine Karte kommt auf die Hand. 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.energy < draw_cost(p): return [false, "Nicht genug Mana"] return [true, ""] func draw_cost(p: Player) -> int: return 0 if "ziehen_gratis" in p.perks else DRAW_COST ## Preis einer Eigenschaft für p. func upgrade_cost(p: Player, keyword: String) -> int: var c := int(Cards.KEYWORD_COST[keyword]) return maxi(1, c - 1) if "verbessern_billig" in p.perks else c func draw(p: Player) -> String: var ok := can_draw(p) if not ok[0]: return ok[1] p.energy -= draw_cost(p) var card: Cards.Card = draw_cards(1, p.max_energy)[0] p.hand.append(card.copy()) say("%s zieht." % p.name) emit({"type": "draw", "player": players.find(p)}) return "" func begin_turn(p: Player) -> void: p.max_energy = START_MANA + p.turns p.energy = p.max_energy + (1 if "extra_mana" in p.perks else 0) # der Rest vom letzten Zug ist verfallen if p.turns == 0 and "start_energie" in p.perks: p.energy += 2 for c in p.board: c.ready = true # lag eine Runde: greift jetzt an, Verdecktes darf aufgedeckt werden c.attacks_done = 0 p.turns += 1 say("--- Zug %d: %s ---" % [turn, p.name]) emit({"type": "turn", "player": players.find(p)}) # Jeder Zug beginnt mit einer Karte vom Stapel, die man sich in diesem Zug leisten kann. if p.hand.size() < MAX_HAND: p.hand.append(draw_cards(1, p.max_energy)[0].copy()) say("%s zieht eine Karte." % p.name) emit({"type": "draw", "player": players.find(p)}) else: say("%s hat die Hand voll und zieht nicht." % p.name) ## Zugende: Wesen mit Regeneration heilen ganz, dann ist der andere dran. func end_turn() -> void: if winner != "": return for c in current().board: if c.has("Regeneration") and c.health < c.max_health: c.health = c.max_health say("%s regeneriert sich." % c.name) emit({"type": "heal", "uid": c.uid}) var n := players.size() for i in range(1, n + 1): var idx := (current_idx + i) % n if players[idx].alive(): if idx <= current_idx: turn += 1 current_idx = idx break begin_turn(current()) # --- Karten spielen ---------------------------------------------------------- ## Ausspielen kostet die Kartenkosten; verdeckt gelegt ist es gleich teuer. func can_play(p: Player, card: Cards.Card) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] if not (card in p.hand): return [false, "Nicht auf der Hand"] if p.board.size() >= MAX_BOARD: return [false, "Dein Feld ist voll"] if p.energy < card.cost: return [false, "Nicht genug Mana"] return [true, ""] ## Spielt eine Karte, offen oder verdeckt. Das Wesen greift erst im nächsten Zug an (außer flink). ## Gibt "" zurück oder eine Fehlermeldung. func play_card(p: Player, card: Cards.Card, face_down: bool = false) -> String: var ok := can_play(p, card) if not ok[0]: return ok[1] p.hand.erase(card) p.energy -= card.cost var cr := Creature.new(card, p, next_uid) next_uid += 1 cr.hidden = face_down if "wesen_angriff" in p.perks: cr.attack += 1 if "wesen_leben" in p.perks: cr.health += 1 cr.max_health += 1 p.board.append(cr) 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 "" ## Ein eigenes verdecktes Wesen aufdecken: kostenlos, im eigenen Zug, frühestens einen Zug ## nach dem Legen. Aufgedeckt greift es sofort an. func can_reveal(p: Player, cr: Creature) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] if cr == null or not (cr in p.board): return [false, "Nur eigene Wesen"] if not cr.hidden: return [false, "%s liegt schon offen" % cr.name] if not cr.ready: return [false, "%s kann erst im nächsten Zug aufgedeckt werden" % cr.name] return [true, ""] func reveal(p: Player, cr: Creature) -> String: var ok := can_reveal(p, cr) if not ok[0]: return ok[1] _flip(cr) cleanup() check_end() return "" ## Deckt ein Wesen auf; die Elementboni gelten 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() # --- Verbessern: Eigenschaften für Mana ------------------------------------- ## target: eigenes Wesen auf dem Brett oder eigene Handkarte. func can_upgrade(p: Player, target, keyword: String) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] if not Cards.KEYWORD_COST.has(keyword): return [false, "Unbekannte Eigenschaft"] var card: Cards.Card = target.card if target is Creature else target if target is Creature: if not (target in p.board): return [false, "Nur eigene Wesen"] elif not (target in p.hand): return [false, "Nicht auf der Hand"] if keyword in card.keywords: return [false, "%s hat schon %s" % [card.name, keyword]] if p.energy < upgrade_cost(p, keyword): return [false, "Nicht genug Mana"] return [true, ""] func upgrade(p: Player, target, keyword: String) -> String: var ok := can_upgrade(p, target, keyword) if not ok[0]: return ok[1] p.energy -= upgrade_cost(p, keyword) var card: Cards.Card = (target.card if target is Creature else target).copy() # Kopien (KI) teilen sich Karten card.keywords.append(keyword) if target is Creature: target.card = card else: p.hand[p.hand.find(target)] = card say("%s bekommt %s." % [card.name, keyword]) emit({"type": "upgrade", "player": players.find(p), "keyword": keyword, "uid": target.uid if target is Creature else -1}) return "" # --- Verschmelzen ------------------------------------------------------------ static func card_of(x) -> Cards.Card: return x.card if x is Creature else x ## Seltenheitsstufe einer Karte von p: bei verschmolzenen die geerbte, sonst die der Sammlung. 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. static func fusion_cost(a, b) -> int: return 1 << maxi(card_of(a).fusions, card_of(b).fusions) ## a und b: zwei eigene Wesen auf dem Brett oder zwei eigene Handkarten. func can_fuse(p: Player, a, b) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] if a == null or b == null or a == b: return [false, "Zwei verschiedene Karten wählen"] if a is Creature and b is Creature: if not (a in p.board) or not (b in p.board): return [false, "Nur eigene Wesen"] elif a is Creature or b is Creature: return [false, "Nur Hand mit Hand oder Brett mit Brett"] elif not (a in p.hand) or not (b in p.hand): return [false, "Nicht auf der Hand"] if p.energy < fusion_cost(a, b): return [false, "Nicht genug Mana"] return [true, ""] ## Verschmilzt zwei Handkarten zu einer oder zwei eigene Wesen zu einem offenen: Elemente und ## Eigenschaften beider, Werte nach Cards.fusion_value, Kosten folgen den Werten. func fuse(p: Player, a, b) -> String: var ok := can_fuse(p, a, b) if not ok[0]: return ok[1] p.energy -= fusion_cost(a, b) if a is Creature: return _fuse_board(p, a, b) var card := Cards.fused(a, b) card.look = maxi(look_of(p, a), look_of(p, b)) var at := p.hand.find(a) p.hand.erase(a) p.hand.erase(b) p.hand.insert(mini(at, p.hand.size()), card) say("%s und %s verschmelzen auf der Hand zu %s (%d/%d)." % [a.name, b.name, card.name, card.attack, card.health]) emit({"type": "fuse", "player": players.find(p), "uid": -1, "gone": []}) return "" ## Auf dem Brett zählen die Werte der Wesen (ohne Elementbonus, den bekommt das neue selbst); ## 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 - 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(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) p.board.erase(a) p.board.erase(b) p.board.insert(mini(at, p.board.size()), cr) say("%s und %s verschmelzen zu %s (%d/%d)." % [a.name, b.name, cr.name, cr.attack, cr.health]) emit({"type": "fuse", "player": players.find(p), "uid": cr.uid, "gone": [a.uid, b.uid]}) apply_auras() cleanup() check_end() return "" # --- Elementboni --------------------------------------------------------------- ## Offene Wesen je Element auf dem ganzen Tisch. func element_counts() -> Dictionary: var counts := {} for e in Cards.ELEMENTS: counts[e] = 0 for p in players: for c in p.board: if not c.hidden: for e in c.card.elements: counts[e] += 1 return counts ## +1 je Element mit ELEMENT_STACK offenen Wesen auf dem Tisch; mehrere Elemente zählen alle. static func aura_of(elements: Array, counts: Dictionary) -> int: var a := 0 for e in elements: if int(counts[e]) >= ELEMENT_STACK: a += 1 return a ## Bonus eines offenen Wesens; verdeckte zählen nicht und bekommen nichts. func aura_for(c: Creature, counts: Dictionary) -> int: if c.hidden: return 0 var a := aura_of(c.card.elements, counts) var o = c.owner() if o != null and "element_doppelt" in o.perks: a *= 2 return a ## Rechnet die Elementboni neu. Kommt ein Bonus, steigen Leben und Höchstleben; geht er, ## sinkt nur das Höchstleben, das Leben wird höchstens darauf gekappt. func apply_auras() -> void: var counts := element_counts() for p in players: for c in p.board: var a := aura_for(c, counts) 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. func forecast(p: Player, card: Cards.Card) -> int: var counts := element_counts() for e in card.elements: counts[e] += 1 return aura_of(card.elements, counts) * (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 ## müssen vor den anderen angegriffen werden. func can_attack(cr: Creature, target) -> Array: if winner != "" or cr.owner() != current(): return [false, "Nicht dein Zug"] if cr.hidden: return [false, "%s liegt verdeckt" % cr.name] if not cr.ready and not cr.has("Flink") and cr.attacks_left() > 0 and cr.power() > 0: return [false, "%s greift erst im nächsten Zug an" % cr.name] if not cr.can_attack(): return [false, "%s kann nicht angreifen" % cr.name] 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 not (target is Creature) and not tgt_owner.board.is_empty(): return [false, "Erst die Wesen"] if target is Creature and not target.has("Spott") and tgt_owner.board.any(func(c): return c.has("Spott")): return [false, "Erst die Wesen mit Spott"] return [true, ""] ## Greift an. Ein verdecktes Ziel wird zuerst aufgedeckt. Gibt "" zurück oder eine Fehlermeldung. func attack(cr: Creature, target) -> String: var ok := can_attack(cr, target) if not ok[0]: return ok[1] cr.attacks_done += 1 if target is Creature and target.hidden: _flip(target) say("%s greift %s an." % [cr.name, target.name]) emit({"type": "attack", "attacker": ref_of(cr), "target": ref_of(target)}) damage(target, cr.power() + matchup_bonus(cr, target)) if target is Creature and target.power() > 0: # wer nichts austeilt, kontert auch nicht damage(cr, target.power() + matchup_bonus(target, cr)) cleanup() check_end() return "" ## Elementvorteil beim Zuschlagen und beim Konter: +1 im Vorteil, -1 im Nachteil, ## gegen Helden gibt es keinen. static func matchup_bonus(source: Creature, target) -> int: if not (target is Creature): return 0 return Cards.matchup(source.card.elements, target.card.elements) func damage(target, amount: int) -> void: if amount <= 0: return if target is Creature: target.health -= amount else: target.life -= amount say("%s erleidet %d Schaden." % [target.name, amount]) 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. 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() ## Ende: einer von beiden hat kein Leben mehr. func check_end() -> void: if winner != "": return var alive := players.filter(func(p): return p.alive()) if alive.size() != 1: 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) # --- Gegner und Kampfstart --------------------------------------------------- static func shuffle(arr: Array, r: RandomNumberGenerator) -> void: for i in range(arr.size() - 1, 0, -1): var j := r.randi_range(0, i) var tmp = arr[i] arr[i] = arr[j] arr[j] = tmp ## Duell gegen einen zufälligen Gegner. rules: {"human_perks": [...], "opponent_life": int, "looks": {...}} 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) static func new_battle_with(opp: Player, r: RandomNumberGenerator = null, fixed_order: bool = false, rules: Dictionary = {}) -> Game: if r == null: r = make_rng() var human := Player.new("Spieler", true, START_LIFE) human.perks = Array(rules.get("human_perks", [])) human.looks = rules.get("looks", {}) var g := Game.new(human, opp, r) for p in g.players: if "leben_35" in p.perks: p.life += 5 p.max_life += 5 g.start(fixed_order) return g