class_name Game extends RefCounted ## Spielregeln: Duell, Mana je Zug, Ziehen aus dem gemeinsamen Stapel, verdeckte Wesen, ## Elementboni und Verschmelzen. const MAX_BOARD := 5 const START_HAND := 5 # Startkarten: die obersten des eigenen Stapels const START_MANA := 1 # Mana in Zug 1; jede Runde eins mehr, ohne Grenze; Rest verfällt const DECK_MIN := 40 # Karten je Stapel: mindestens so viele (Gegner und Startdecks genau so viele) const DECK_MAX := 60 # und höchstens so viele const START_LIFE := 30 const ELEMENT_PAIR := 2 # so viele eigene offene Wesen eines Elements: Sondereffekt des Elements const ELEMENT_COUNT := 3 # je so viele ausgespielte Karten eines Elements: +1/+1 für die eigenen Wesen des Elements const DISCARD_GAIN := 1 # Abwerfen einer Handkarte bringt so viel Mana # Merkmale der Kampagnen-Bosse (Player.perks). 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", "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: als Sondereffekt des Elementpaars der eigenen Seite, aus der Karte ## (verschmolzene erben) 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). ## Darf dieses Wesen angreifen? Ob es Schaden macht, hängt vom Ziel ab (Elementvorteil, Game.can_attack). func can_attack() -> bool: return not hidden and attacks_left() > 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, 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 -> 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 title = t is_human = h life = l max_life = l for e in Cards.ELEMENTS: played[e] = 0 func alive() -> bool: return life > 0 ## Die oberste Karte des eigenen Stapels als Exemplar in der eigenen Stufe; null, wenn er leer ist. func draw_from_deck() -> Cards.Card: if deck.is_empty(): return null var id: String = deck.pop_back() 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 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 p.played = played.duplicate() 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 menschliche Spieler var opponent: Player # der andere am Tisch var players: Array = [] # in Zugreihenfolge var winner_player: Player = null var rng: RandomNumberGenerator 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] 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.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 --------------------------------------------------------------- ## Jeder spielt mit seinem eigenen Stapel (leer: ein zufälliger); die Starthand sind die ## obersten fünf Karten daraus, wie gemischt. func start(fixed_order: bool = false) -> void: if not fixed_order: shuffle(players, rng) # zufällige Zugreihenfolge for i in players.size(): var p: Player = players[i] p.order = i 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() if c != null: p.hand.append(c) 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()) ## 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.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 maxi(0, p.draws - (1 if "ziehen_gratis" in p.perks else 0)) func draw(p: Player) -> String: var ok := can_draw(p) if not ok[0]: return ok[1] p.energy -= draw_cost(p) p.draws += 1 p.hand.append(p.draw_from_deck()) 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 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. 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) for e in card.elements: p.played[e] = int(p.played.get(e, 0)) + 1 if face_down: say("%s legt ein Wesen verdeckt." % p.name) else: say("%s spielt %s." % [p.name, card.name]) emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card, "hidden": face_down}) apply_auras() cleanup() check_end() return "" ## Abwerfen: eine Handkarte loswerden und dafür DISCARD_GAIN Mana bekommen. func can_discard(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"] return [true, ""] func discard(p: Player, card: Cards.Card) -> String: var ok := can_discard(p, card) if not ok[0]: return ok[1] p.hand.erase(card) p.energy += DISCARD_GAIN say("%s wirft %s ab." % [p.name, card.name]) emit({"type": "discard", "player": players.find(p)}) 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() # --- 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)) ## a und b: zwei verschiedene eigene Karten, auf der Hand oder als Wesen auf dem Brett, auch ## gemischt. Verschmelzen kostet kein Mana. 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"] for x in [a, b]: if not ((x in p.board) if x is Creature else (x in p.hand)): return [false, "Nur eigene Karten"] return [true, ""] ## Die Karte, die aus a und b entsteht (siehe Cards.fused), in der höheren Stufe der Eltern. static func fusion_result(p: Player, a, b) -> Cards.Card: return Cards.fused(card_of(a), card_of(b), maxi(look_of(p, card_of(a)), look_of(p, card_of(b)))) ## Verschmilzt zwei eigene Karten zu einer neuen Handkarte: Wesen verlassen das Brett, die neue ## Karte ist unverletzt und wird wie jede andere ausgespielt. Sie landet am Platz der vorderen ## Handkarte, sonst hinten. func fuse(p: Player, a, b) -> String: var ok := can_fuse(p, a, b) if not ok[0]: return ok[1] var card := fusion_result(p, a, b) var at := p.hand.size() var gone := [] for x in [a, b]: if x is Creature: p.board.erase(x) gone.append(x.uid) else: at = mini(at, p.hand.find(x)) p.hand.erase(x) p.hand.insert(mini(at, p.hand.size()), card) say("%s und %s verschmelzen zu %s (%d/%d) auf der Hand." % [a.name, b.name, card.name, card.attack, card.health]) emit({"type": "fuse", "player": players.find(p), "uid": -1, "gone": gone}) return "" # --- Elementboni --------------------------------------------------------------- ## +1/+1 je volle ELEMENT_COUNT ausgespielte Karten eines Elements des Spielers; mehrere Elemente ## zählen alle, auch Neutral (eigener Zähler, aber kein Paar). static func aura_of(elements: Array, played: Dictionary) -> int: var a := 0 for e in elements: a += int(played.get(e, 0)) / ELEMENT_COUNT return a ## Bonus eines offenen Wesens aus den ausgespielten Karten seines Besitzers; verdeckte bekommen nichts. func aura_for(c: Creature) -> int: var o = c.owner() if c.hidden or o == null: return 0 var a := aura_of(c.card.elements, o.played) if "element_doppelt" in o.perks: a *= 2 return a ## Rechnet die Elementboni neu. Kommt ein Bonus, steigen Leben und Höchstleben; geht er ## (beim Verdecken), sinkt nur das Höchstleben, das Leben wird gekappt. func apply_auras() -> void: for p in players: for c in p.board: var a := aura_for(c) if a != c.aura: var delta: int = a - c.aura c.max_health += delta c.health = mini(c.health + maxi(delta, 0), c.max_health) c.aura = a ## Vorschau fürs Ausspielen: der Elementbonus, den die Karte offen gelegt bekäme (sie zählt selbst mit). func forecast(p: Player, card: Cards.Card) -> int: var played := p.played.duplicate() for e in card.elements: played[e] = int(played.get(e, 0)) + 1 return aura_of(card.elements, played) * (2 if "element_doppelt" in p.perks else 1) # --- Kampf ------------------------------------------------------------------- ## Leben lassen sich erst angreifen, wenn der Gegner kein Wesen mehr hat; Wesen mit Spott ## 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: 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 cr.power() + matchup_bonus(cr, target) <= 0: # 0 Angriff reicht nur mit Elementvorteil return [false, "%s würde keinen Schaden machen" % cr.name] if not (target is Creature) and not tgt_owner.board.is_empty(): return [false, "Erst die Wesen"] 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: # Konter mit Elementvorteil; wer so nichts austeilt, kontert nicht (damage ignoriert 0) damage(cr, target.power() + matchup_bonus(target, cr)) cleanup() check_end() return "" ## Elementvorteil beim Zuschlagen und beim Konter: im Vorteil zählt der Stapelbonus des Ziels ## nicht (so viel Schaden mehr), im Nachteil der eigene nicht (so viel weniger). Gegen Helden ## gibt es keinen. static func matchup_bonus(source: Creature, target) -> int: if not (target is Creature): return 0 var m := Cards.matchup(source.card.elements, target.card.elements) if m > 0: return target.aura if m < 0: return -source.aura return 0 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" say("Der Gegner ist besiegt!" if winner == "human" else "Du wurdest besiegt.") # --- 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": {...}, "deck": [ids]} static func new_battle(r: RandomNumberGenerator = null, rules: Dictionary = {}) -> Game: if r == null: r = make_rng() return new_battle_with(DeckGen.random_opponent(int(rules.get("opponent_life", START_LIFE))), r, false, rules) 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", {}) human.deck = rules.get("deck", []).duplicate() 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