using System.Collections.Generic; using System.Diagnostics; using UnityEngine; namespace TKS { /// 적 유닛 1기의 행동 후보 1건(가상 실행 결과 점수 포함). public class AiPlan { public Unit Unit; public Vector2 Target; public bool UseSkill; public float Score; /// 0 = 서포터(창병 밀기 · 책사 덤불 제거 · 이득 있는 버프) · 1 = 돌격(보·기·창) · 2 = 사격(궁·책). public int Group; public string Why = ""; public int Candidates; public float Ms; } /// /// 적 AI 예측 시뮬레이션(PD 2026-11-06 #1098 · 전술규칙_적AI_기획서 §2): 후보 행동을 실제 규칙(ActionResolver · 기믹 · 스킬 · 견제)으로 가상 실행(Sim)해 /// ai_weights.csv 점수가 가장 높은 것을 고른다. 후보 = 방위(유닛 ≤ many_units = dirs · 넘으면 dirs_many) × 파워 3단 × 액티브 사용 여부 · /// 궁·책 = 사거리 안 표적 + (책사) 덤불 중심 + 방위 이동 · 유닛 1기 예산 budget_ms · 난이도 = aiAccuracy(보스 +boss_accuracy · 투기장 mode_accuracy)로 /// 최고 선택 · 아니면 상위 top_n 무작위 + 파워 ±noise_power · 턴 운영 = 서포터 → 돌격 → 사격 순, 1기 실행마다 재평가(BattleManager). /// public static class TacticalAI { static readonly List lastEvalMs = new List(); /// 최근 평가 시간(ms · 성능 테스트용). public static IReadOnlyList LastEvalMs { get { return lastEvalMs; } } public static bool Available { get { return AiWeights.Load() != null; } } /// 이 적 보스가 액티브를 쓰는가(1부 보스만 · 다음 행동형만 · ai_weights boss_active · active_max_chapter). public static bool BossUsesActive(BalanceConfig.StageDef stage, BalanceConfig.EnemySpawn e) { var w = AiWeights.Load(); if (w == null || stage == null || e == null || !e.isBoss) return false; return w["boss_active"] >= 0.5f && stage.chapter >= 1 && stage.chapter <= Mathf.RoundToInt(w["active_max_chapter"]); } /// /// 다음에 움직일 적 1기와 그 행동: 남은 유닛 전원 평가 → 그룹(서포터 → 돌격 → 사격) 순 · 같은 그룹은 점수 높은 순 → 그 유닛의 후보에서 난이도대로 고른다. /// 교전 범위 밖(가장 가까운 아군이 aggro 보다 멂)은 대기 → null 이면 이번 턴 끝. /// public static AiPlan Next(BattleManager bm, List pending, float accuracy, float aggro, System.Random rng) { lastEvalMs.Clear(); var w = AiWeights.Load(); if (w == null) return null; var players = bm.AliveUnits(Faction.Player); if (players.Count == 0) return null; AiPlan best = null; List bestList = null; int enemyCount = bm.AliveCount(Faction.Enemy); foreach (var u in pending) { if (u == null || !u.IsAlive) continue; if (NearestDist(u, players) > aggro) continue; List list; var top = Evaluate(bm, u, enemyCount, out list); if (top == null) continue; if (best == null || top.Group < best.Group || (top.Group == best.Group && top.Score > best.Score)) { best = top; bestList = list; } } if (best == null) return null; return Choose(bm, best.Unit, bestList, accuracy, rng, w); } static float NearestDist(Unit u, List players) { float best = float.MaxValue; foreach (var p in players) { if (p.IsAlive) best = Mathf.Min(best, Vector2.Distance(u.Position, p.Position)); } return best; } /// 난이도: 최고 점수를 accuracy(+보스 · 투기장 = mode) 확률로 · 아니면 상위 top_n 중 무작위 + 파워 ±noise. static AiPlan Choose(BattleManager bm, Unit u, List list, float accuracy, System.Random rng, AiWeights w) { float acc = accuracy + (u.IsBoss ? w["boss_accuracy"] : 0f); if (bm.Mode != null && bm.Mode.kind == OutGame.ArenaRules.Arena) acc = w["mode_accuracy"]; if (list.Count == 1 || rng.NextDouble() < acc) return list[0]; int n = Mathf.Clamp(Mathf.RoundToInt(w["top_n"]), 1, list.Count); var pick = list[rng.Next(n)]; float noise = w["noise_power"]; Vector2 v = pick.Target - u.Position; float k = 1f + ((float)rng.NextDouble() * 2f - 1f) * noise; var copy = new AiPlan { Unit = pick.Unit, UseSkill = pick.UseSkill, Score = pick.Score, Group = pick.Group, Why = pick.Why + " · 오차", Candidates = pick.Candidates, Ms = pick.Ms }; float max = SkillMods.SlingMax(u); v *= k; if (v.magnitude > max) v = v.normalized * max; copy.Target = bm.Board.ClampInside(u.Position + v, u.Radius); return copy; } /// 유닛 1기 평가: 후보마다 가상 실행 → 점수 내림차순 목록(list[0] = 최고). public static AiPlan Evaluate(BattleManager bm, Unit u, int enemyCount, out List list) { list = new List(); var w = AiWeights.Load(); if (w == null || u == null || !u.IsAlive) return null; var sw = Stopwatch.StartNew(); float budget = Mathf.Max(5f, w["budget_ms"]); var cands = Candidates(bm, u, enemyCount, w); var skills = bm.Skills != null ? bm.Skills.Of(u) : null; bool canSkill = skills != null && skills.HasActive && skills.Active.Trigger == SkillTrigger.NextAction && skills.CooldownLeft(bm.TurnNumber) <= 0; var resolver = new ActionResolver(bm.Config, bm, new System.Random(12345)); var snap = Sim.Take(bm); int done = 0; foreach (var t in cands) { for (int s = 0; s < (canSkill ? 2 : 1); s++) { if (sw.Elapsed.TotalMilliseconds > budget && list.Count > 0) break; var plan = Simulate(bm, resolver, u, t, s == 1, snap, w); done++; if (plan != null) list.Add(plan); } } list.Sort((a, b) => b.Score.CompareTo(a.Score)); float ms = (float)sw.Elapsed.TotalMilliseconds; lastEvalMs.Add(ms); if (list.Count == 0) return null; list[0].Candidates = done; list[0].Ms = ms; return list[0]; } /// 후보 목표점: 방위 × 파워 3단 · 궁·책은 사거리(+접근) 안 표적 · 책사는 덤불 중심까지. static List Candidates(BattleManager bm, Unit u, int enemyCount, AiWeights w) { var res = new List(); float sling = SkillMods.SlingMax(u); int dirs = Mathf.Max(4, Mathf.RoundToInt(enemyCount > w["many_units"] ? w["dirs_many"] : w["dirs"])); float[] powers = { w["power_1"], w["power_2"], w["power_3"] }; if (UnitTypeInfo.IsRanged(u.Type)) { float reach = SkillMods.Range(u) + u.ArcherMoveAllow; foreach (var p in bm.AliveUnits(Faction.Player)) { float gap = Vector2.Distance(u.Position, p.Position) - u.Radius * Combat.CastRadiusScale - p.Radius; if (gap <= reach + 0.5f) res.Add(p.Position); // 표적 = 그 유닛 중심(목표점 명중 크리) } var layer = GimmickLayer.Current; if (u.Type == UnitType.Strategist && layer != null) { foreach (var g in layer.Items) { if (!g.BlocksMove || !g.Def.strategistClear) continue; if (Vector2.Distance(u.Position, g.Shape.Center) - g.Shape.BoundingRadius <= reach) res.Add(g.Shape.Center); } } dirs = Mathf.Max(4, dirs / 2); // 이동 후보(엄폐·고지 자리 잡기) } for (int i = 0; i < dirs; i++) { float a = i * Mathf.PI * 2f / dirs; Vector2 d = new Vector2(Mathf.Cos(a), Mathf.Sin(a)); foreach (var k in powers) res.Add(bm.Board.ClampInside(u.Position + d * sling * k, u.Radius)); } return res; } static AiPlan Simulate(BattleManager bm, ActionResolver resolver, Unit u, Vector2 target, bool useSkill, Sim.Snapshot snap, AiWeights w) { var o = new ActionOutcome(); float score; string why; Sim.Active = true; try { if (useSkill) bm.Skills.Of(u).Reserved = true; var before = Before(bm, snap); Sim.Run(resolver.Resolve(u, target, false, o)); Sim.Run(bm.ApplyOverwatch(u, o)); score = Score(bm, u, o, useSkill, before, snap, w, out why); } finally { Sim.Active = false; Sim.Restore(bm, snap); } int group = UnitTypeInfo.IsRanged(u.Type) ? 2 : 1; if (why.Contains("서포트") || why.Contains("덤불")) group = 0; return new AiPlan { Unit = u, Target = target, UseSkill = useSkill, Score = score, Group = group, Why = why }; } class Pre { public readonly Dictionary Units = new Dictionary(); public readonly HashSet Removed = new HashSet(); } static Pre Before(BattleManager bm, Sim.Snapshot snap) { var p = new Pre(); foreach (var x in snap.Units) p.Units[x.U] = x; var layer = GimmickLayer.Current; if (layer != null) { foreach (var g in layer.Items) { if (g.Removed || g.Broken) p.Removed.Add(g); } } return p; } /// 점수(기획서 §2-3) — 가상 실행 직후 상태 vs 실행 전. static float Score(BattleManager bm, Unit u, ActionOutcome o, bool useSkill, Pre pre, Sim.Snapshot snap, AiWeights w, out string why) { var parts = new List(); float s = 0f; float playerDmg = 0f; int kills = 0; foreach (var kv in pre.Units) { var x = kv.Value; var v = x.U; if (v == null || x.Hp <= 0) continue; int dmg = Mathf.Max(0, x.Hp - v.Hp); if (v.Faction == Faction.Player) { playerDmg += dmg; s += w["dmg_player"] * dmg; if (v.Hp <= 0) { kills++; s += w["kill_base"] + w["kill_hp_ratio"] * (x.MaxHp > 0 ? (float)x.Hp / x.MaxHp : 0f); } } else s += w["dmg_own"] * dmg; } if (playerDmg > 0f) parts.Add("피해 " + playerDmg); if (kills > 0) parts.Add("처치 " + kills); var layer = GimmickLayer.Current; if (layer != null) { foreach (var a in layer.Affected) { if (a.Unit == null) continue; float val = w["gimmick_dest_bonus"] + a.Value; s += a.Unit.Faction == Faction.Player ? val : -val; if (a.Unit.Faction == Faction.Player) parts.Add("기믹 " + a.Kind); } // 플레이어 유닛을 폭약 수레 반경으로 밀어 넣음 foreach (var d in o.Displaced) { if (d.Unit == null || d.Unit.Faction != Faction.Player || !d.Unit.IsAlive) continue; foreach (var g in layer.Items) { if (!g.BlocksMove || g.Def.projectile != GimmickProjectile.Destroy) continue; float r = g.P(1, 0f); if (Vector2.Distance(d.Unit.Position, g.Shape.Center) <= r && Vector2.Distance(d.From, g.Shape.Center) > r) { s += w["gimmick_dest_bonus"]; parts.Add("수레 반경"); } } } } if (o.Chains > 0) { s += w["chain"] * o.Chains; parts.Add("연쇄 " + o.Chains); } if (u.Type == UnitType.Spear) { var seen = new HashSet(); int reach = 0; foreach (var d in o.Displaced) { var a = d.Unit; if (a == null || a == u || a.Faction != u.Faction || !a.IsAlive || !seen.Add(a)) continue; if (Vector2.Distance(d.From, d.To) < 0.1f) continue; foreach (var p in bm.AliveUnits(Faction.Player)) { float before = Vector2.Distance(d.From, p.Position) - a.Radius - p.Radius; float after = Vector2.Distance(a.Position, p.Position) - a.Radius - p.Radius; if (after <= SkillMods.SlingMax(a) && after < before) reach++; } } if (reach > 0) { s += w["support_reach"] * reach; parts.Add("서포트 " + reach); } } if (u.Type == UnitType.Cavalry) { var hit = new HashSet(); foreach (var a in o.Attacked) { if (a != null && a.Faction == Faction.Player) hit.Add(a); } if (hit.Count > 0) { s += w["pierce_hit"] * hit.Count; // 복귀 성공·실패 점수 = 기병 복귀 제거로 삭제(PD 2026-11-10) parts.Add("관통 " + hit.Count); } } if (u.IsAlive && UnitTypeInfo.IsRanged(u.Type)) { if (layer != null && Covered(u, bm, layer)) { s += w["archer_cover"]; parts.Add("목책 엄폐"); } if (layer != null && layer.ZoneAt(u.Position, GimmickZone.High) != null) { s += w["high_ground"]; parts.Add("고지"); } if (OnCavalryLine(u, bm, layer)) { s += w["cav_line"]; parts.Add("기병 관통선"); } } if (u.Type == UnitType.Strategist) { var hit = new HashSet(); foreach (var a in o.Attacked) { if (a != null && a.Faction == Faction.Player) hit.Add(a); } if (hit.Count > 0) { s += w["splash_hit"] * hit.Count; parts.Add("광역 " + hit.Count); } if (layer != null) { int routes = OpenedRoutes(bm, u, layer, pre); if (routes > 0) { s += w["route_open"] * routes; parts.Add("덤불 제거 " + routes); } } } if (u.IsAlive && layer != null) { var burn = layer.ZoneAt(u.Position, GimmickZone.Burn); if (burn != null) s += w["zone_end"] * burn.P(1, 0f) * u.MaxHp; foreach (var g in layer.Items) { if (g.Zone != GimmickZone.Capture || !g.ZoneActive) continue; float r = g.P(1, 0f); if (Vector2.Distance(u.Position, g.Shape.Center) - u.Radius > r) continue; bool foe = false; foreach (var p in bm.AliveUnits(Faction.Player)) { if (Vector2.Distance(p.Position, g.Shape.Center) - p.Radius <= r) foe = true; } if (!foe && g.Owner != (int)u.Faction) { s += w["banner"]; parts.Add("군기"); } } } if (useSkill && playerDmg <= 0f) s += w["skill_waste"]; if (useSkill) parts.Add("액티브"); if (playerDmg <= 0f && kills == 0 && u.IsAlive) { var start = pre.Units[u].Pos; float before = float.MaxValue, after = float.MaxValue; foreach (var p in bm.AliveUnits(Faction.Player)) { before = Mathf.Min(before, Vector2.Distance(start, p.Position)); after = Mathf.Min(after, Vector2.Distance(u.Position, p.Position)); } if (before < float.MaxValue) { float gain = before - after; if (UnitTypeInfo.IsRanged(u.Type)) gain = Mathf.Min(gain, Mathf.Max(0f, before - SkillMods.Range(u))); // 원거리 = 사거리까지만 다가간다 s += w["approach"] * gain; } } why = string.Join(" · ", parts.ToArray()); return s; } /// 궁·책 종료 위치와 플레이어 근접 유닛 사이 직선에 목책(투사체 통과 · 이동 막힘)이 있는가. static bool Covered(Unit u, BattleManager bm, GimmickLayer layer) { foreach (var p in bm.AliveUnits(Faction.Player)) { if (!UnitTypeInfo.IsMelee(p.Type)) continue; if (Vector2.Distance(p.Position, u.Position) > SkillMods.SlingMax(p) + 2f) continue; foreach (var g in layer.Items) { if (!g.BlocksMove || g.Def.projectile != GimmickProjectile.Pass || g.Zone == GimmickZone.River) continue; if (g.Shape.SegmentTouches(u.Position, p.Position)) return true; } } return false; } /// 플레이어 기병의 관통 사정(슬링 + 반지름) 안이고 사이에 장애물이 없으면 관통선 위. static bool OnCavalryLine(Unit u, BattleManager bm, GimmickLayer layer) { foreach (var c in bm.AliveUnits(Faction.Player)) { if (c.Type != UnitType.Cavalry) continue; if (Vector2.Distance(c.Position, u.Position) > SkillMods.SlingMax(c) + u.Radius) continue; bool blocked = false; if (layer != null) { foreach (var g in layer.Items) { if (g.BlocksMove && g.Shape.SegmentTouches(c.Position, u.Position)) blocked = true; } } if (!blocked) return true; } return false; } /// 이번 가상 실행으로 지워진 덤불 때문에 새로 열린 (적 측 유닛 → 플레이어 유닛) 직선 수(사거리·슬링 안 · 다른 장애물 없음). static int OpenedRoutes(BattleManager bm, Unit shooter, GimmickLayer layer, Pre pre) { var cleared = new List(); foreach (var g in layer.Items) { if (g.Removed && !pre.Removed.Contains(g) && g.Def.strategistClear) cleared.Add(g); } if (cleared.Count == 0) return 0; int n = 0; foreach (var a in bm.AliveUnits(shooter.Faction)) { if (a == shooter) continue; float reach = UnitTypeInfo.IsRanged(a.Type) ? SkillMods.Range(a) + a.ArcherMoveAllow : SkillMods.SlingMax(a); foreach (var p in bm.AliveUnits(Faction.Player)) { if (Vector2.Distance(a.Position, p.Position) - a.Radius - p.Radius > reach) continue; bool wasBlocked = false; foreach (var g in cleared) { if (g.Shape.SegmentTouches(a.Position, p.Position, a.Radius * 0.8f)) wasBlocked = true; } if (!wasBlocked) continue; bool stillBlocked = false; foreach (var g in layer.Items) { if (g.BlocksMove && g.Shape.SegmentTouches(a.Position, p.Position, a.Radius * 0.8f)) stillBlocked = true; } if (!stillBlocked) n++; } } return n; } } }