Files
cards/scripts/ai.gd
Team3 4c6bf96eeb Kampf fortsetzen, Gamepad im Deckbau, Deck-Codes, Kapitel 3, Elementbooster, englische Karten, Host-Wechsel und Zuschauer
- KI-Bewertung mit Bedrohung und Führenden-Bremse (62:38 gegen die alte im Duell, gleichauf zu viert)
- Balance: Sand +1/+1 statt Schild, Erde+Luft und Erde geschwächt, Wasser angehoben, Messlauf mit N=200 möglich
- Einzelspieler-Kämpfe werden laufend gespeichert (voller Spielstand, viewer -1) und im Menü fortgesetzt
- Karten, Deckzeilen und Kartenrücken fokussierbar: Gamepad in Deckbau, Werkstatt und Booster
- Kartennamen (name_en) und Kartentexte übersetzbar, Übersetzungstest deckt cards.gd mit ab
- Deck-Code DINGER1:id*anzahl mit Zwischenablage; acht neue Karten; Kampagne Kapitel 3; Elementbooster 120 Münzen
- Netzwerk: Nachfolge-Host mit vollem Spielstand, Neuverbindung der anderen, Zuschauer (Sicht -2); Bot kann hosten
- Große Schrift: Kartennamen, Kartentext, Knopfleisten, Gegnervorschau, Dialogtext
- Anleitung 1 garantiert ein 1-Energie-Wesen; Tests: DINGER_ONLY für einzelne Oberflächentests
- Oberflächentest auch auf Xephyr mit llvmpipe (wie Xvfb in der CI) grün

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
2026-09-16 18:50:09 +02:00

378 lines
14 KiB
GDScript

class_name AI
## KI mit Vorausschau: jede mögliche Aktion wird auf einer Kopie des Spiels
## ausgeführt und die Stellung danach bewertet; die besten Erstschritte werden um
## einen Folgeschritt vertieft. Die gierige Variante (choose_action_greedy) dient
## als Vergleich und für die Schwierigkeit Leicht. Alles hier ist zustandslos und
## darf in einem Hintergrund-Thread auf einer Spielkopie laufen.
# --- Bewertung -------------------------------------------------------------------
static func creature_value(c: Game.Creature) -> float:
var v := float(c.power() + c.health)
if c.taunt():
v += 2.0
if c.shield:
v += 1.5
if c.attacks_per_turn() == 2:
v += c.power()
if c.frozen:
v -= c.power() # setzt den nächsten Angriff aus: der Angriffswert zählt nicht
if not c.ready:
v -= c.power() * 0.25
return v
static func board_value(p: Game.Player) -> float:
var v := 0.0
for c in p.board:
v += creature_value(c)
return v
## Stellung aus Sicht von p: eigenes Leben, Feld und Hand zählen positiv, die der Gegner
## negativ, der stärkste Gegner etwas mehr (jeder gegen jeden: den Führenden bremsen).
## Gegnerische Angriffskraft ohne eigenen Wächter ist eine Bedrohung, tödliche besonders.
static func evaluate(g: Game, p: Game.Player) -> float:
if not p.alive():
return -10000.0
var score := p.life * 1.0 + board_value(p) * 1.0 + p.hand.size() * 0.8 + p.traps.size() * 2.5
var alive_enemies := 0
var threat := 0.0
var leader := 0.0
for e in g.players:
if e == p:
continue
if not e.alive():
score += 40.0
continue
alive_enemies += 1
var ev: float = e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
score -= ev
leader = maxf(leader, ev)
if e.life <= 6:
score += 3.0 # fast besiegt: dranbleiben
for c in e.board:
if not c.frozen:
threat += c.power() * c.attacks_per_turn()
if alive_enemies == 0:
return 10000.0
score -= leader * 0.25
var guarded := false
for c in p.board:
if c.taunt():
guarded = true
break
if guarded:
score -= threat * 0.1
elif threat >= p.life:
score -= 25.0
else:
score -= threat * 0.3
return score
# --- Vorausschau ---------------------------------------------------------------
const MAX_TARGETS := 4 # je Karte bzw. Angreifer nur die aussichtsreichsten Ziele prüfen
const DEEPEN := 8 # so viele beste Erstschritte bekommen Folgeschritte
const FOLLOW_UPS := 10 # Folgeschritte je vertieftem Kandidat
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
## Grobe Rangfolge eines Ziels für einen Karteneffekt (nur zum Vorsortieren).
static func _target_rank(kind: String, amount: int, t) -> float:
if t is Game.Player:
return 50.0 - t.life if kind == "damage" else 0.0
match kind:
"damage":
return 100.0 + _value(t) if not t.shield and t.health <= amount else float(_value(t))
"freeze", "bounce":
return t.card.cost * 10.0 + t.power()
"buff", "shield":
return (100.0 if t.can_attack() else 0.0) + t.power()
return float(_value(t))
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, dann Helden, dann der Rest.
static func _attack_rank(cr: Game.Creature, t) -> float:
if t is Game.Player:
return 50.0 - t.life
if _kills(cr, t):
return (100.0 if not _kills(t, cr) else 60.0) + _value(t)
return -float(t.health)
static func _top(list: Array, rank: Callable, n: int) -> Array:
if list.size() <= n:
return list
var ranked := []
for t in list:
ranked.append([rank.call(t), t])
ranked.sort_custom(func(a, b): return a[0] > b[0])
return ranked.slice(0, n).map(func(pair): return pair[1])
## Mögliche Aktionen; mit prune nur die MAX_TARGETS besten Ziele je Karte und Angreifer.
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
var out := []
var valid_cache := {}
for card in p.hand:
if not game.can_play(p, card)[0]:
continue
var tt: String = card.target_type()
if tt == "":
out.append({"type": "play", "card": card, "target": null})
continue
if not valid_cache.has(tt):
valid_cache[tt] = game.valid_targets(p, tt)
var valid: Array = valid_cache[tt]
if valid.is_empty():
out.append({"type": "play", "card": card, "target": null})
continue
if prune:
var fx: Cards.Effect = null
for f in card.effects:
if f.target == tt:
fx = f
break
var kind: String = fx.kind if fx != null else ""
var amount: int = fx.amount if fx != null else 0
var key := "%s/%s/%d" % [tt, kind, amount]
if not valid_cache.has(key):
valid_cache[key] = _top(valid, func(t): return _target_rank(kind, amount, t), MAX_TARGETS)
valid = valid_cache[key]
for t in valid:
out.append({"type": "play", "card": card, "target": t})
for cr in p.board:
if not cr.can_attack():
continue
var targets := []
for e in game.enemies_of(p):
for t in e.board + [e]:
if game.can_attack(cr, t)[0]:
targets.append(t)
if prune:
targets = _top(targets, func(t): return _attack_rank(cr, t), MAX_TARGETS)
for t in targets:
out.append({"type": "attack", "creature": cr, "target": t})
return out
## Führt eine Aktion des Originals auf der Kopie aus.
static func apply_on_clone(g2: Game, game: Game, action: Dictionary) -> void:
var p2: Game.Player = g2.players[game.current_idx]
var target = null
if action.get("target") != null:
target = map_target(g2, game, action.target)
if action.type == "play":
g2.play_card(p2, action.card, target)
else:
g2.attack(g2.find_creature(action.creature.uid), target)
static func map_target(g2: Game, game: Game, t):
if t is Game.Creature:
return g2.find_creature(t.uid)
return g2.players[game.players.find(t)]
## Überträgt eine auf der Kopie gewählte Aktion zurück auf das Original.
static func map_action(game: Game, copy: Game, action: Dictionary) -> Dictionary:
var out := action.duplicate()
if action.has("creature"):
out["creature"] = game.find_creature(action.creature.uid)
if action.get("target") != null:
out["target"] = map_target(game, copy, action.target)
return out
## Mulligan der KI: teure Karten im ersten Zug zurücklegen.
static func mulligan_cards(p: Game.Player) -> Array:
return p.hand.filter(func(c): return c.cost >= 5)
## Beste Aktion nach einer Vorausschau von zwei Schritten: erst wird jede Aktion auf einer
## Kopie ausgeführt und bewertet, dann bekommen die DEEPEN besten Folgeschritte
## (z. B. verstärken, dann angreifen). Bei Zeitmangel gilt der beste bisherige Zug.
static func choose_action(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"}
if not p.mulligan_done and p.turns == 1 and p == game.current():
return {"type": "mulligan", "cards": mulligan_cards(p)}
var t0 := Time.get_ticks_msec()
var base := evaluate(game, p) + 0.01
var firsts := [] # {action, game, score}
for action in candidate_actions(game, p, true):
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
break
var g2 := game.clone_for(p)
apply_on_clone(g2, game, action)
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[game.current_idx])})
firsts.sort_custom(func(a, b): return a.score > b.score)
var best := {"type": "end"}
var best_score := base
for i in mini(firsts.size(), DEEPEN):
var f: Dictionary = firsts[i]
var g2: Game = f.game
var score: float = f.score
if g2.winner == "":
var p2: Game.Player = g2.players[game.current_idx]
var count := 0
for nxt in candidate_actions(g2, p2, true):
if count >= FOLLOW_UPS or Time.get_ticks_msec() - t0 > time_budget_ms:
break
count += 1
var g3 := g2.clone_for(p2)
apply_on_clone(g3, g2, nxt)
score = maxf(score, evaluate(g3, g3.players[game.current_idx]) - 0.001)
if score > best_score:
best_score = score
best = f.action
# Erstschritte ohne Vertiefung zählen mit ihrer Einzelbewertung
for i in range(DEEPEN, firsts.size()):
if firsts[i].score > best_score:
best_score = firsts[i].score
best = firsts[i].action
return best
# --- Gierige Vergleichs-KI -------------------------------------------------------
static func _kills(attacker, defender) -> bool:
return not defender.shield and defender.health <= attacker.power()
static func _value(c) -> int:
return c.power() + c.health + (2 if c.taunt() else 0)
static func _best(arr: Array, key: Callable):
var best = null
var best_v := 0
for x in arr:
var v: int = key.call(x)
if best == null or v > best_v:
best = x
best_v = v
return best
static func _threat_score(h) -> int:
var board_attack := 0
for c in h.board:
board_attack += c.power()
return h.life - board_attack
static func _weakest(heroes: Array):
var best = null
for h in heroes:
if best == null or _threat_score(h) < _threat_score(best):
best = h
return best
static func choose_action_greedy(game: Game, p: Game.Player) -> Dictionary:
if game.winner != "" or game.enemies_of(p).is_empty():
return {"type": "end"}
if not p.mulligan_done and p.turns == 1 and p == game.current():
return {"type": "mulligan", "cards": mulligan_cards(p)}
var playable := p.hand.filter(func(c): return game.can_play(p, c)[0])
if not playable.is_empty():
Game.shuffle(playable, game.rng)
var card = playable[0]
for c in playable:
if c.cost > card.cost:
card = c
return {"type": "play", "card": card, "target": pick_target(game, p, card)}
for cr in p.board:
if cr.can_attack():
var t = pick_attack_target(game, p, cr)
if t != null:
return {"type": "attack", "creature": cr, "target": t}
return {"type": "end"}
static func pick_target(game: Game, p: Game.Player, card: Cards.Card):
var tt := card.target_type()
if tt == "":
return null
var valid := game.valid_targets(p, tt)
if valid.is_empty():
return null
var fx: Cards.Effect = null
for f in card.effects:
if f.target == tt:
fx = f
break
var creatures := valid.filter(func(t): return t is Game.Creature)
var heroes := valid.filter(func(t): return t is Game.Player)
match fx.kind:
"damage":
var killable := creatures.filter(func(c): return not c.shield and c.health <= fx.amount)
if not killable.is_empty():
return _best(killable, Callable(AI, "_value"))
if not heroes.is_empty():
return _weakest(heroes)
return _best(creatures, Callable(AI, "_value"))
"freeze", "bounce":
return _best(creatures, func(c): return c.card.cost * 100 + c.power())
"buff", "shield":
return _best(creatures, func(c): return (100 if c.can_attack() else 0) + c.power())
return valid[0]
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
var enemies := game.enemies_of(p)
var total := 0
for c in p.board:
if c.can_attack():
total += c.power() * c.attacks_left
var options := []
for e in enemies:
var taunts: Array = e.board.filter(func(c): return c.taunt())
if not taunts.is_empty():
options.append_array(taunts)
else:
if total >= e.life:
return e
options.append_array(e.board)
options.append(e)
var creatures := options.filter(func(t): return t is Game.Creature)
var heroes := options.filter(func(t): return t is Game.Player)
var good := creatures.filter(func(c): return _kills(cr, c) and not _kills(c, cr))
if not good.is_empty():
return _best(good, Callable(AI, "_value"))
var trade := creatures.filter(func(c): return _kills(cr, c) and _value(c) > _value(cr))
if not trade.is_empty():
return _best(trade, Callable(AI, "_value"))
if not heroes.is_empty():
return _weakest(heroes)
if not creatures.is_empty():
return _best(creatures, func(c): return 1000 - c.health)
return null
# --- Ausführung -------------------------------------------------------------------
static func apply_action(game: Game, p: Game.Player, action: Dictionary) -> void:
match action.type:
"play":
game.play_card(p, action.card, action.target)
"attack":
game.attack(action.creature, action.target)
"mulligan":
game.mulligan(p, action.cards)
_:
game.end_turn()
## Spielt den ganzen Zug (für Simulationen und Tests). greedy: gierige KI statt Vorausschau.
static func take_turn(game: Game, p: Game.Player, greedy: bool = false, limit: int = 100) -> void:
for i in limit:
var action := choose_action_greedy(game, p) if greedy else choose_action(game, p)
apply_action(game, p, action)
if action.type == "end" or game.winner != "":
return