310 lines
12 KiB
GDScript
310 lines
12 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.
|
|
## Verdeckte gegnerische Wesen kennt die KI nicht (Game.masked_clone). 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.attacks_per_turn() == 2:
|
|
v += c.power() * 0.5
|
|
if c.has("Regeneration"):
|
|
v += 1.0
|
|
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
|
|
|
|
|
|
## Karten auf der Hand zählen nach ihren Werten: ein Drache ist mehr wert als ein Kobold.
|
|
static func hand_value(p: Game.Player) -> float:
|
|
var v := 0.0
|
|
for c in p.hand:
|
|
v += 0.15 * (c.attack + c.health)
|
|
return v
|
|
|
|
|
|
## Stellung aus Sicht von p: eigenes Leben, Brett und Hand zählen positiv, die des Gegners
|
|
## negativ. Ohne eigene Wesen zielt gegnerische Angriffskraft aufs Leben, 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 + hand_value(p) + p.energy * 0.01
|
|
var alive_enemies := 0
|
|
var threat := 0.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
|
|
for c in e.board:
|
|
if not c.hidden:
|
|
threat += c.power() * c.attacks_per_turn()
|
|
if alive_enemies == 0:
|
|
return 10000.0
|
|
if p.board.is_empty():
|
|
if threat >= p.life:
|
|
score -= 25.0
|
|
else:
|
|
score -= threat * 0.3
|
|
else:
|
|
score -= threat * 0.12 # auch hinter Wesen tut gegnerische Angriffskraft weh
|
|
return score
|
|
|
|
|
|
# --- Vorausschau ---------------------------------------------------------------
|
|
|
|
const MAX_TARGETS := 4 # je 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
|
|
|
|
|
|
## Rangfolge eines Angriffsziels: sichere Abtausche zuerst, 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 Angreifer.
|
|
static func candidate_actions(game: Game, p: Game.Player, prune: bool = false) -> Array:
|
|
var out := []
|
|
for card in p.hand:
|
|
if game.can_play(p, card)[0]:
|
|
out.append({"type": "play", "card": card, "face_down": false})
|
|
for cr in p.board:
|
|
if cr.hidden and game.can_reveal(p, cr)[0]:
|
|
out.append({"type": "reveal", "creature": cr})
|
|
var pairs := []
|
|
for i in p.board.size():
|
|
for j in range(i + 1, p.board.size()):
|
|
if game.can_fuse(p, p.board[i], p.board[j])[0]:
|
|
pairs.append([p.board[i], p.board[j]])
|
|
if prune and pairs.size() > MAX_TARGETS:
|
|
pairs = _top(pairs, func(pr): return _value(pr[0]) + _value(pr[1]), MAX_TARGETS)
|
|
for pr in pairs:
|
|
out.append({"type": "fuse", "creature": pr[0], "other": pr[1]})
|
|
for cr in p.board:
|
|
if not cr.can_attack():
|
|
continue
|
|
var targets := []
|
|
for e in game.enemies_of(p):
|
|
targets.append_array(e.board)
|
|
if e.board.is_empty():
|
|
targets.append(e)
|
|
targets = targets.filter(func(t): return game.can_attack(cr, t)[0])
|
|
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]
|
|
match action.type:
|
|
"play":
|
|
g2.play_card(p2, action.card, bool(action.get("face_down", false)))
|
|
"reveal":
|
|
g2.reveal(p2, g2.find_creature(action.creature.uid))
|
|
"fuse":
|
|
g2.fuse(p2, g2.find_creature(action.creature.uid), g2.find_creature(action.other.uid))
|
|
_:
|
|
g2.attack(g2.find_creature(action.creature.uid), map_target(g2, game, action.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.has("other"):
|
|
out["other"] = game.find_creature(action.other.uid)
|
|
if action.get("target") != null:
|
|
out["target"] = map_target(game, copy, action.target)
|
|
return out
|
|
|
|
|
|
## Beste Aktion nach einer Vorausschau von zwei Schritten auf einer Kopie, die verdeckte
|
|
## gegnerische Wesen nicht kennt. 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"}
|
|
var view := game.masked_clone(p)
|
|
var me: Game.Player = view.players[game.players.find(p)]
|
|
var t0 := Time.get_ticks_msec()
|
|
var base := evaluate(view, me) + 0.01
|
|
var firsts := [] # {action, game, score}
|
|
for action in candidate_actions(view, me, true):
|
|
if not firsts.is_empty() and Time.get_ticks_msec() - t0 > time_budget_ms:
|
|
break
|
|
var g2 := view.clone()
|
|
apply_on_clone(g2, view, action)
|
|
firsts.append({"action": action, "game": g2, "score": evaluate(g2, g2.players[view.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[view.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()
|
|
apply_on_clone(g3, g2, nxt)
|
|
score = maxf(score, evaluate(g3, g3.players[view.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
|
|
if best.type == "end":
|
|
if game.can_draw(p)[0]:
|
|
return {"type": "draw"} # Mana übrig und Platz auf der Hand: lieber ziehen als passen
|
|
return best
|
|
return map_action(game, view, best)
|
|
|
|
|
|
# --- Einfache Heuristiken ----------------------------------------------------------
|
|
|
|
static func _kills(attacker, defender) -> bool:
|
|
if defender is Game.Player:
|
|
return attacker.power() >= defender.life
|
|
return attacker.power() + Cards.matchup(attacker.card.elements, defender.card.elements) >= defender.health
|
|
|
|
|
|
static func _value(c) -> int:
|
|
if c is Game.Player:
|
|
return 1000
|
|
return c.power() + c.health
|
|
|
|
|
|
static func _best(list: Array, key: Callable):
|
|
var best = null
|
|
var best_v := -INF
|
|
for t in list:
|
|
var v := float(key.call(t))
|
|
if v > best_v:
|
|
best_v = v
|
|
best = t
|
|
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"}
|
|
# Gierig: erst das teuerste Spielbare legen (bei vollem Feld die zwei schwächsten Wesen
|
|
# verschmelzen), dann aufdecken und angreifen, dann Doppelschlag kaufen, dann ziehen.
|
|
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, "face_down": false}
|
|
if p.board.size() >= Game.MAX_BOARD and p.hand.any(func(c): return c.cost <= p.energy - 1):
|
|
var weakest: Array = p.board.duplicate()
|
|
weakest.sort_custom(func(a, b): return _value(a) < _value(b))
|
|
if game.can_fuse(p, weakest[0], weakest[1])[0]:
|
|
return {"type": "fuse", "creature": weakest[0], "other": weakest[1]}
|
|
for cr in p.board:
|
|
if cr.hidden and game.can_reveal(p, cr)[0]:
|
|
return {"type": "reveal", "creature": cr}
|
|
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}
|
|
if game.can_draw(p)[0]:
|
|
return {"type": "draw"}
|
|
return {"type": "end"}
|
|
|
|
|
|
## Ziel eines Angriffs: sichere Abtausche, dann gute Abtausche, dann das schwächste Wesen;
|
|
## der Held nur, wenn kein Wesen mehr steht.
|
|
static func pick_attack_target(game: Game, p: Game.Player, cr: Game.Creature):
|
|
for e in game.enemies_of(p):
|
|
if e.board.is_empty():
|
|
return e if game.can_attack(cr, e)[0] else null
|
|
var creatures: Array = e.board.filter(func(c): return game.can_attack(cr, c)[0])
|
|
if creatures.is_empty():
|
|
continue
|
|
var good: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and not _kills(c, cr))
|
|
if not good.is_empty():
|
|
return _best(good, Callable(AI, "_value"))
|
|
var trade: Array = creatures.filter(func(c): return not c.hidden and _kills(cr, c) and _value(c) > _value(cr))
|
|
if not trade.is_empty():
|
|
return _best(trade, Callable(AI, "_value"))
|
|
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, bool(action.get("face_down", false)))
|
|
"draw":
|
|
game.draw(p)
|
|
"reveal":
|
|
game.reveal(p, action.creature)
|
|
"attack":
|
|
game.attack(action.creature, action.target)
|
|
"fuse":
|
|
game.fuse(p, action.creature, action.other)
|
|
_:
|
|
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
|
|
game.end_turn()
|