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 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_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 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.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 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 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 ## 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.offer = offer.duplicate() p.drew = drew 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()) p.offer = draw_cards(OFFER, START_MANA) 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; 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. 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 not p.drew or "ziehen_gratis" in p.perks else DRAW_COST func draw(p: Player, index: int = 0) -> 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 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.drew = false # das erste Ziehen im Zug ist wieder frei say("--- Zug %d: %s ---" % [turn, p.name]) emit({"type": "turn", "player": players.find(p)}) ## 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)) + card.cost 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)) ## 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, b.max_health)) 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.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 --------------------------------------------------------------- ## +1 Angriff je volle ELEMENT_MANA an ausgespielten 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 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; sie wirken nur auf den Angriff (Creature.power). func apply_auras() -> void: for p in players: for c in p.board: c.aura = aura_for(c) ## 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 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: +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