class_name Game extends RefCounted ## Spielregeln: Spieler, Wesen, Züge, Angriffe, Effekte, Feldkombos, Resonanz. const MAX_BOARD := 5 const MAX_HAND := 8 const MAX_ENERGY := 10 # Einkommen je Zug, höher steigt es nicht const SHOP_SIZE := 5 # so viele Karten liegen im Laden const REROLL_COST := 1 # Jeder gegen jeden: alle Spieler haben dieselben Regeln und dasselbe Startleben. const START_LIFE := 30 # Merkmale der Kampagnen-Bosse (Player.perks). const PERKS := { "feuer_angriff": "Feuerwesen haben +1 Angriff", "flink_angriff": "Flinke Wesen haben +1 Angriff", "waechter_leben": "Wächter kommen mit +2 Leben", "wesen_leben": "Wesen kommen mit +1 Leben", "extra_mana": "Bekommt jeden Zug 1 Mana mehr", "leben_35": "Beginnt mit 5 Leben mehr", "schild_wesen": "Wesen kommen mit Schild", "alle_flink": "Alle Wesen sind flink", "start_energie": "Beginnt mit 3 Mana", "resonanz_doppelt": "Resonanz 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 resonance := 0 # +1, wenn ein anderes eigenes Wesen ein Element teilt var field_bonus := 0 # +1, wenn das Feld auf dem eigenen Element steht var health: int var max_health: int var shield: bool var frozen := false var ready: bool var attacks_left: int 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 shield = "Schild" in c.keywords ready = "Flink" in c.keywords attacks_left = attacks_per_turn() if ready else 0 func owner(): return owner_ref.get_ref() ## Angriffswert inklusive Resonanz, Feld und Boss-Merkmalen. func power() -> int: var v := attack + resonance + field_bonus var o = owner() if o != null: if "feuer_angriff" in o.perks and "Feuer" in card.elements: v += 1 if "flink_angriff" in o.perks and "Flink" in card.keywords: v += 1 return v func attacks_per_turn() -> int: return 2 if "Doppelschlag" in card.keywords else 1 func taunt() -> bool: return "Wächter" in card.keywords func can_attack() -> bool: return ready and attacks_left > 0 and not frozen and power() > 0 func clone(new_owner) -> Creature: var c := Creature.new(card, new_owner, uid) c.attack = attack c.resonance = resonance c.field_bonus = field_bonus c.health = health c.max_health = max_health c.shield = shield c.frozen = frozen c.ready = ready c.attacks_left = attacks_left return c class Player extends RefCounted: var name: String var title: String var is_human: bool var life: int var max_life: int var shop: Array = [] # SHOP_SIZE Plätze, gekaufte sind null var hand: Array = [] var board: Array = [] var energy := 0 # Mana; nicht ausgegebenes bleibt liegen var max_energy := 0 # Einkommen je Zug var start_bonus := 0 # einmaliges Extra-Mana für den späteren Start var order := 0 # Platz in der Zugreihenfolge var perks: Array = [] # Boss-Merkmale (siehe PERKS) var elements: Array = [] # Laden nur aus diesen Elementen (leer = alle) 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 = "", els: Array = []) -> void: name = n title = t is_human = h life = l max_life = l elements = els.duplicate() func alive() -> bool: return life > 0 func clone() -> Player: var p := Player.new(name, is_human, life, title, elements) p.max_life = max_life p.shop = shop.duplicate() p.hand = hand.duplicate() p.energy = energy p.max_energy = max_energy p.start_bonus = start_bonus 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 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 var field_element := "" # gemeinsames Feld aller am Tisch, "" = neutral var field_charge := {} # Element -> gespielte Karten seit dem letzten Wechsel 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 for e in Cards.ELEMENTS: field_charge[e] = 0 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, damit Effekte einheitlich bleiben). 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 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.field_element = field_element g.field_charge = field_charge.duplicate() g.quiet = true return g # --- Zugablauf --------------------------------------------------------------- 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 # Wer nicht beginnt, bekommt im ersten Zug einmalig 1 Mana extra. p.start_bonus = 0 if i == 0 else 1 roll_shop(p) 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()) ## Füllt den Laden neu: SHOP_SIZE Karten, höchstens so teuer wie das Einkommen plus eins. func roll_shop(p: Player) -> void: p.shop = DeckGen.shop_cards(rng, p.max_energy, p.elements) ## Kauft eine Karte aus dem Laden auf die Hand. Gibt "" zurück oder eine Fehlermeldung. func buy(p: Player, slot: int) -> String: if winner != "" or p != current(): return "Nicht dein Zug" if slot < 0 or slot >= p.shop.size() or p.shop[slot] == null: return "Kein Angebot" var card: Cards.Card = p.shop[slot] if p.energy < card.cost: return "Nicht genug Mana" if p.hand.size() >= MAX_HAND: return "Hand voll" p.energy -= card.cost p.shop[slot] = null p.hand.append(card) say("%s kauft %s." % [p.name, card.name]) emit({"type": "buy", "player": players.find(p), "card": card}) return "" ## Würfelt den Laden neu. Gibt "" zurück oder eine Fehlermeldung. func reroll(p: Player) -> String: if winner != "" or p != current(): return "Nicht dein Zug" if p.energy < REROLL_COST: return "Nicht genug Mana" p.energy -= REROLL_COST roll_shop(p) say("%s würfelt den Laden neu." % p.name) emit({"type": "reroll", "player": players.find(p)}) return "" func begin_turn(p: Player) -> void: p.max_energy = mini(MAX_ENERGY, p.max_energy + 1) # Nicht ausgegebenes Mana bleibt liegen. p.energy += p.max_energy + (1 if "extra_mana" in p.perks else 0) if p.turns == 0 and "start_energie" in p.perks: p.energy += 2 # einmalige Starthilfe, kein dauerhafter Vorsprung if p.start_bonus > 0: p.energy += p.start_bonus say("%s erhält %d Mana extra für den späteren Start." % [p.name, p.start_bonus]) p.start_bonus = 0 roll_shop(p) for c in p.board: c.ready = true if c.frozen: c.frozen = false c.attacks_left = 0 say("%s taut auf und setzt aus." % c.name) else: c.attacks_left = c.attacks_per_turn() p.turns += 1 say("--- Zug %d: %s ---" % [turn, p.name]) emit({"type": "turn", "player": players.find(p)}) func end_turn() -> void: if winner != "": return 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 nichts mehr: bezahlt wird beim Kauf im Laden. 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"] return [true, ""] func valid_targets(p: Player, target_type: String) -> Array: var enemies := enemies_of(p) var creatures := [] for e in enemies: creatures.append_array(e.board) match target_type: "enemy_creature": return creatures "enemy_any": return creatures + enemies "friendly_creature": return p.board.duplicate() return [] ## Spielt eine Karte. Gibt "" zurück oder eine Fehlermeldung. func play_card(p: Player, card: Cards.Card, target = null) -> String: var ok := can_play(p, card) if not ok[0]: return ok[1] var tt := card.target_type() if tt != "": var valid := valid_targets(p, tt) if not valid.is_empty() and not (target in valid): return "Ungültiges Ziel" if valid.is_empty(): target = null p.hand.erase(card) say("%s spielt %s." % [p.name, card.name]) var cr := _enter(p, card) for fx in card.effects: resolve(p, fx, target, cr, card.elements) if cr in p.board: trigger_field_combos(p, cr) charge_field(card) update_resonance() cleanup() check_end() return "" ## Jede gespielte Karte lädt ihre Elemente. Wer FIELD_CHARGE erreicht, kippt das ## gemeinsame Feld auf sein Element; danach beginnen alle Zähler von vorn. func charge_field(card: Cards.Card) -> void: for e in card.elements: field_charge[e] = int(field_charge.get(e, 0)) + 1 var best := "" for e in Cards.ELEMENTS: if e == field_element or int(field_charge.get(e, 0)) < Cards.FIELD_CHARGE: continue if best == "" or int(field_charge[e]) > int(field_charge[best]): best = e if best == "": return field_element = best for e in Cards.ELEMENTS: field_charge[e] = 0 say("Das Feld wird zu %s: %s." % [Cards.FIELD_NAMES[best], Cards.FIELD_TEXT[best]]) emit({"type": "field", "element": best}) update_resonance() # der Feldbonus gilt sofort ## Feldkombo: das neue Wesen bildet mit dem ersten eigenen Wesen aus anderem ## Element eine Kombo; pro Ausspielen löst höchstens eine Kombo aus. func trigger_field_combos(p: Player, cr: Creature) -> void: for other in p.board: if other == cr: continue for e1 in cr.card.elements: for e2 in other.card.elements: if e1 == e2: continue var name := Cards.combo_name([e1, e2]) say("Feldkombo %s: %s." % [name, Cards.FIELD_COMBO_TEXT[name]]) emit({"type": "combo", "name": name, "player": players.find(p), "uid": cr.uid}) resolve(p, Cards.field_combo_effect(name), null, cr) return ## Wesen betritt das Feld; Boss-Merkmale wirken beim Eintritt. func _enter(p: Player, card: Cards.Card) -> Creature: var cr := Creature.new(card, p, next_uid) next_uid += 1 if "waechter_leben" in p.perks and cr.taunt(): cr.health += 2 cr.max_health += 2 if "wesen_leben" in p.perks: cr.health += 1 cr.max_health += 1 if "schild_wesen" in p.perks: cr.shield = true if "alle_flink" in p.perks: cr.ready = true cr.attacks_left = cr.attacks_per_turn() p.board.append(cr) emit({"type": "play", "player": players.find(p), "uid": cr.uid, "card": card}) return cr ## Prüft eine Verschmelzung: zwei eigene Wesen, genug Mana. func can_fuse(p: Player, a: Creature, b: Creature) -> Array: if winner != "" or p != current(): return [false, "Nicht dein Zug"] if a == null or b == null or a == b: return [false, "Zwei verschiedene Wesen wählen"] if not (a in p.board) or not (b in p.board): return [false, "Nur eigene Wesen"] if p.energy < Cards.fusion_cost(a.card, b.card): return [false, "Nicht genug Mana"] return [true, ""] ## Verschmilzt zwei eigene Wesen zu einem. Es erbt Elemente und Schlüsselwörter beider ## Karten, seine Werte sind der abgerundete Durchschnitt plus 1. Kosten: Durchschnitt der ## Kartenkosten, aufgerundet. Eine Verschmelzung ist kein Ausspielen: sie löst keine ## Feldkombo aus und lädt das Feld nicht. func fuse(p: Player, a: Creature, b: Creature) -> String: var ok := can_fuse(p, a, b) if not ok[0]: return ok[1] p.energy -= Cards.fusion_cost(a.card, b.card) var card := Cards.fused(a.card, b.card, (a.attack + b.attack) / 2 + 1, (a.health + b.health) / 2 + 1) var cr := Creature.new(card, p, next_uid) next_uid += 1 cr.shield = cr.shield or a.shield or b.shield cr.frozen = a.frozen or b.frozen cr.ready = a.ready and b.ready cr.attacks_left = 0 if not cr.ready else mini(a.attacks_left, b.attacks_left) 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]}) update_resonance() cleanup() check_end() return "" ## Resonanz je Anzahl Partner mit gleichem Element: +1, +2, +3, +3. static func resonance_for(partners: int) -> int: return DeckGen.RESONANCE_STEPS[mini(partners, DeckGen.RESONANCE_STEPS.size() - 1)] ## Vorschau fürs Ausspielen: Angriffsbonus aus Resonanz und Feld sowie die Feldkombo, ## die die Karte mit dem ersten eigenen Wesen aus anderem Element auslösen würde. func forecast(p: Player, card: Cards.Card) -> Dictionary: var partners := 0 var combo := "" var partner: Creature = null for o in p.board: if o.card.elements.any(func(e): return e in card.elements): partners += 1 if combo == "": for e1 in card.elements: for e2 in o.card.elements: if e1 != e2 and combo == "": combo = Cards.combo_name([e1, e2]) partner = o var resonance := resonance_for(partners) * (2 if "resonanz_doppelt" in p.perks else 1) var field := 1 if field_element != "" and field_element in card.elements else 0 return {"resonance": resonance, "field": field, "combo": combo, "partner": partner} ## Resonanz: abnehmender Angriffsbonus je Partner mit gleichem Element (siehe resonance_for). func update_resonance() -> void: for p in players: for c in p.board: var bonus := 0 for o in p.board: if o != c and o.card.elements.any(func(e): return e in c.card.elements): bonus += 1 c.resonance = resonance_for(bonus) * (2 if "resonanz_doppelt" in p.perks else 1) c.field_bonus = 1 if field_element != "" and field_element in c.card.elements else 0 func resolve(p: Player, fx: Cards.Effect, target, source: Creature = null, elements: Array = []) -> void: var enemies := enemies_of(p) var enemy_creatures := [] for e in enemies: enemy_creatures.append_array(e.board) var targets := [] if fx.target in Cards.CHOSEN_TARGETS: targets = [target] if target != null else [] elif fx.target == "all_enemy_creatures": targets = enemy_creatures elif fx.target == "all_enemy_heroes": targets = enemies.duplicate() elif fx.target == "all_enemies": targets = enemy_creatures + enemies elif fx.target == "all_friendly_creatures": targets = p.board.duplicate() elif fx.target == "self_hero": targets = [p] elif fx.target == "self_creature": targets = [source] if source != null else [] elif fx.target == "strongest_enemy_creature": var best: Creature = null for c in enemy_creatures: if best == null or c.power() > best.power(): best = c targets = [best] if best != null else [] elif fx.target == "weakest_enemy_hero": var weakest: Player = null for e in enemies: if weakest == null or e.life < weakest.life: weakest = e targets = [weakest] if weakest != null else [] else: targets = [null] for t in targets: match fx.kind: "damage": damage(t, fx.amount + (Cards.matchup(elements, t.card.elements) if t is Creature else 0)) "heal": t.life = mini(t.max_life, t.life + fx.amount) say("%s heilt sich um %d." % [t.name, fx.amount]) emit({"type": "heal", "target": ref_of(t), "amount": fx.amount}) "mana": p.energy += fx.amount say("%s erhält %d Mana." % [p.name, fx.amount]) emit({"type": "mana", "player": players.find(p), "amount": fx.amount}) "freeze": t.frozen = true t.attacks_left = 0 say("%s ist eingefroren." % t.name) emit({"type": "freeze", "target": ref_of(t)}) "bounce": var o = t.owner() o.board.erase(t) emit({"type": "bounce", "target": ref_of(t)}) if o.hand.size() < MAX_HAND: o.hand.append(t.card) say("%s kehrt auf die Hand zurück." % t.name) else: say("%s wird weggeweht (Hand voll)." % t.name) "buff": t.attack += fx.amount t.health += fx.amount2 t.max_health += fx.amount2 say("%s erhält +%d/+%d." % [t.name, fx.amount, fx.amount2]) emit({"type": "buff", "target": ref_of(t), "text": "+%d/+%d" % [fx.amount, fx.amount2]}) "shield": t.shield = true say("%s erhält Schild." % t.name) emit({"type": "buff", "target": ref_of(t), "text": "Schild"}) # --- Kampf ------------------------------------------------------------------- func can_attack(cr: Creature, target) -> Array: if winner != "" or cr.owner() != current(): return [false, "Nicht dein Zug"] 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"] var has_taunt := false for c in tgt_owner.board: if c.taunt(): has_taunt = true if has_taunt and not (target is Creature and target.taunt()): return [false, "Ein Wächter muss zuerst angegriffen werden"] return [true, ""] ## Greift an. 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_left -= 1 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: 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: if target.shield: target.shield = false say("Schild von %s blockt den Schaden." % target.name) emit({"type": "blocked", "target": ref_of(target)}) return target.health -= amount else: target.life -= amount say("%s erleidet %d Schaden." % [target.name, amount]) emit({"type": "damage", "target": ref_of(target), "amount": amount}) func cleanup() -> void: 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 died: update_resonance() ## 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