Spiel untersucht und optimiert: KI-Suche, Hintergrund-Threads, Dialog-Helfer
- KI-Vorausschau in zwei Stufen mit Zielvorauswahl (höchstens 4 Ziele je Karte und Angreifer, die 8 besten Erstschritte vertieft): 5 Gegner 2063 ms -> 101 ms, 1 Gegner 160 ms -> 36 ms, alle Kandidaten im Budget; 52:48 gegen die alte Suche - KI rechnet im WorkerThreadPool auf einer Spielkopie, schon während der Wartezeit; längste Bildzeit im Kampf mit 5 Gegnern 108 ms -> 38 ms - Musik wird im Hintergrund erzeugt (Start 185 ms schneller) - Style.overlay und Style.confirm ersetzen vier kopierte Dialoge - Übersetzungen für Abbrechen und Zurücksetzen, Archetypen neu gemessen - tests/perf_probe.gd (Laufzeiten) und tests/frame_probe.tscn (Bildzeiten) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPgKvy5W9WRNFHjkm5QsHx
This commit is contained in:
134
scripts/ai.gd
134
scripts/ai.gd
@@ -1,7 +1,9 @@
|
||||
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.
|
||||
## 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 -------------------------------------------------------------------
|
||||
@@ -52,27 +54,88 @@ static func evaluate(g: Game, p: Game.Player) -> float:
|
||||
|
||||
# --- Vorausschau ---------------------------------------------------------------
|
||||
|
||||
static func candidate_actions(game: Game, p: Game.Player) -> Array:
|
||||
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})
|
||||
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})
|
||||
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]:
|
||||
out.append({"type": "attack", "creature": cr, "target": t})
|
||||
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
|
||||
|
||||
|
||||
@@ -94,8 +157,14 @@ static func map_target(g2: Game, game: Game, t):
|
||||
return g2.players[game.players.find(t)]
|
||||
|
||||
|
||||
const FOLLOW_UPS := 10 # Folgeschritte je Kandidat in der zweiten Suchstufe
|
||||
static var time_budget_ms := 150 # Zeitbudget je Entscheidung; danach der beste bisherige Zug
|
||||
## Ü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.
|
||||
@@ -103,37 +172,48 @@ 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).
|
||||
## 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 best := {"type": "end"}
|
||||
var best_score := evaluate(game, p) + 0.01
|
||||
var t0 := Time.get_ticks_msec()
|
||||
var examined := 0
|
||||
for action in candidate_actions(game, p):
|
||||
if examined > 0 and Time.get_ticks_msec() - t0 > time_budget_ms:
|
||||
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
|
||||
examined += 1
|
||||
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)
|
||||
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 f in candidate_actions(g2, p2):
|
||||
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, f)
|
||||
score = maxf(score, evaluate(g3, g3.players[g2.current_idx]) - 0.001)
|
||||
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 = action
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user