Files
cards/scripts/game.gd
T
team3 04fe9716e8
Tests / headless (push) Waiting to run
Tests / fenster-schnell (push) Waiting to run
Tests / fenster-voll (push) Skipped
Tests / balance (push) Skipped
update
2026-10-15 17:16:01 +02:00

664 lines
23 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 := 30 # Karten je Stapel: mindestens so viele (Gegner genau so viele: alle Karten eines Elements)
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 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",
"kein_nachteil": "Wesen haben keinen Elementnachteil",
}
## 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ürs Elementpaar
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 Boss-Merkmalen.
func power() -> int:
var v := attack
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
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 cards_played := 0 # ausgespielte Karten (Münzen nach dem Kampf)
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
var bonus_attack := 0 # Skill Stark: so viel mehr Angriff auf jeder gezogenen Karte
var bonus_health := 0 # Skill Zäh: so viel mehr Leben
var cheap_from := 99 # Skill Sparsam: Karten ab diesen Kosten sind 1 Mana billiger
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
## Die oberste Karte des eigenen Stapels als Exemplar in der eigenen Stufe und mit den Skillboni
## (Stark, Zäh, Sparsam); null, wenn er leer ist.
func draw_from_deck() -> Cards.Card:
if deck.is_empty():
return null
var id: String = deck.pop_back()
return boost(Cards.by_id(id).leveled(int(looks.get(id, 0))))
## Legt die Skillboni auf ein Exemplar: Stark und Zäh getrennt von den Grundwerten (Fusionen
## rechnen ohne sie), Sparsam macht teure Karten 1 Mana billiger.
func boost(c: Cards.Card) -> Cards.Card:
c.boost_attack = bonus_attack
c.boost_health = bonus_health
c.attack += bonus_attack
c.health += bonus_health
if c.cost >= cheap_from:
c.cost -= 1
return c
## 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.cards_played = cards_played
p.deck = deck.duplicate()
p.draws = draws
p.fatigue = fatigue
p.bonus_attack = bonus_attack
p.bonus_health = bonus_health
p.cheap_from = cheap_from
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)
p.cards_played += 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})
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; das Elementpaar gilt sofort.
func _flip(cr: Creature) -> void:
cr.hidden = false
say("%s wird aufgedeckt." % cr.name)
emit({"type": "reveal", "uid": cr.uid, "card": cr.card})
# --- 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 p.boost(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 ""
# --- 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 macht auch im Vorteil nichts
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, als Verschiebung des Schadens: im Vorteil
## doppelt (+ Angriff), im Nachteil halb, aufgerundet (− Angriff / 2). Gegen Helden gibt es keinen;
## mit dem Merkmal kein_nachteil entfällt der Nachteil.
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 source.power()
if m < 0:
var o = source.owner()
return 0 if o != null and "kein_nachteil" in o.perks else -(source.power() / 2)
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; wer besiegt ist, nimmt seine Wesen mit vom Tisch.
func cleanup() -> void:
for p in players:
for c in p.board.duplicate():
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)})
## 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 (Tests, Simulation). rules: {"human_perks": [...], "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(), 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()
human.bonus_attack = int(rules.get("bonus_attack", 0))
human.bonus_health = int(rules.get("bonus_health", 0))
human.cheap_from = int(rules.get("cheap_from", 99))
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