Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
272 lines
9.4 KiB
GDScript
272 lines
9.4 KiB
GDScript
class_name AI
|
|
## KI mit einfacher Vorausschau: jede mögliche Aktion wird auf einer Kopie des
|
|
## Spiels ausgeführt und die Stellung danach bewertet. Die gierige Variante
|
|
## (choose_action_greedy) dient als Vergleich.
|
|
|
|
|
|
# --- 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. Ein besiegter Gegner ist viel wert.
|
|
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
|
|
for e in g.players:
|
|
if e == p:
|
|
continue
|
|
if not e.alive():
|
|
score += 40.0
|
|
continue
|
|
alive_enemies += 1
|
|
score -= e.life * 0.6 + board_value(e) * 0.9 + e.hand.size() * 0.2
|
|
if e.life <= 6:
|
|
score += 3.0 # fast besiegt: dranbleiben
|
|
if alive_enemies == 0:
|
|
return 10000.0
|
|
return score
|
|
|
|
|
|
# --- Vorausschau ---------------------------------------------------------------
|
|
|
|
static func candidate_actions(game: Game, p: Game.Player) -> Array:
|
|
var out := []
|
|
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})
|
|
else:
|
|
var valid := game.valid_targets(p, tt)
|
|
if valid.is_empty():
|
|
out.append({"type": "play", "card": card, "target": null})
|
|
for t in valid:
|
|
out.append({"type": "play", "card": card, "target": t})
|
|
for cr in p.board:
|
|
if not cr.can_attack():
|
|
continue
|
|
for e in game.enemies_of(p):
|
|
for t in e.board + [e]:
|
|
if game.can_attack(cr, t)[0]:
|
|
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)]
|
|
|
|
|
|
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe
|
|
|
|
|
|
## 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: jede Aktion wird auf einer
|
|
## Kopie ausgeführt und mit ihrem besten Folgeschritt bewertet (z. B. verstärken, dann angreifen).
|
|
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 best := {"type": "end"}
|
|
var best_score := evaluate(game, p) + 0.01
|
|
for action in candidate_actions(game, p):
|
|
var g2 := game.clone_for(p)
|
|
apply_on_clone(g2, game, action)
|
|
var p2: Game.Player = g2.players[game.current_idx]
|
|
var score := evaluate(g2, p2)
|
|
if g2.winner == "":
|
|
var count := 0
|
|
for f in candidate_actions(g2, p2):
|
|
if count >= FOLLOW_UPS:
|
|
break
|
|
count += 1
|
|
var g3 := g2.clone_for(p2)
|
|
apply_on_clone(g3, g2, f)
|
|
score = maxf(score, evaluate(g3, g3.players[g2.current_idx]) - 0.001)
|
|
if score > best_score:
|
|
best_score = score
|
|
best = 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
|