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}) for pr in fusion_pairs(game, p, prune): 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 ## Verschmelzungen, die sich lohnen könnten: Paare eigener Wesen (mit prune die schwächsten, die ## Plätze frei machen), jedes angeschlagene Wesen mit der billigsten Handkarte, die zwei ## billigsten Handkarten. Das Ergebnis kommt auf die Hand, die Vorausschau legt es im Folgeschritt. static func fusion_pairs(game: Game, p: Game.Player, prune: bool) -> Array: var pairs := [] for i in p.board.size(): for j in range(i + 1, p.board.size()): 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) var cheap := _cheapest(p) if not cheap.is_empty(): for cr in p.board: if cr.health < cr.max_health: pairs.append([cr, cheap[0]]) if cheap.size() >= 2: pairs.append([cheap[0], cheap[1]]) return pairs.filter(func(pr): return game.can_fuse(p, pr[0], pr[1])[0]) ## Handkarten nach Kosten aufsteigend. static func _cheapest(p: Game.Player) -> Array: var cheap: Array = p.hand.duplicate() cheap.sort_custom(func(a, b): return a.cost < b.cost) return cheap ## Teil einer Verschmelzung in einem anderen Spiel: Handkarten sind in Kopien dieselben Objekte, ## Wesen werden über ihre uid gefunden. static func _part(g: Game, x): return x if x is Cards.Card else g.find_creature(x.uid) ## 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, _part(g2, action.creature), _part(g2, action.other)) _: 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"] = _part(game, action.creature) if action.has("other"): out["other"] = _part(game, action.other) 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() + Game.matchup_bonus(attacker, defender) >= 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: ein Wesen mit höchstens halbem Leben heilen (mit der billigsten Handkarte # verschmelzen, wenn das Ergebnis bezahlbar bleibt), dann das teuerste Spielbare legen (bei # vollem Feld die zwei schwächsten Wesen verschmelzen), dann aufdecken und angreifen, dann ziehen. var cheap := _cheapest(p) if not cheap.is_empty(): for cr in p.board: if cr.health * 2 <= cr.max_health and game.can_fuse(p, cr, cheap[0])[0] and Game.fusion_result(p, cr, cheap[0]).cost <= p.energy: return {"type": "fuse", "creature": cr, "other": cheap[0]} 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): 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()