ThreeKingdomsSlingshot/Assets/Scripts/Runtime/AI/TacticalAI.cs

478 lines
21 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine;
namespace TKS
{
/// <summary>적 유닛 1기의 행동 후보 1건(가상 실행 결과 점수 포함).</summary>
public class AiPlan
{
public Unit Unit;
public Vector2 Target;
public bool UseSkill;
public float Score;
/// <summary>0 = 서포터(창병 밀기 · 책사 덤불 제거 · 이득 있는 버프) · 1 = 돌격(보·기·창) · 2 = 사격(궁·책).</summary>
public int Group;
public string Why = "";
public int Candidates;
public float Ms;
}
/// <summary>
/// 적 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).
/// </summary>
public static class TacticalAI
{
static readonly List<float> lastEvalMs = new List<float>();
/// <summary>최근 평가 시간(ms · 성능 테스트용).</summary>
public static IReadOnlyList<float> LastEvalMs
{
get { return lastEvalMs; }
}
public static bool Available
{
get { return AiWeights.Load() != null; }
}
/// <summary>이 적 보스가 액티브를 쓰는가(1부 보스만 · 다음 행동형만 · ai_weights boss_active · active_max_chapter).</summary>
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"]);
}
/// <summary>
/// 다음에 움직일 적 1기와 그 행동: 남은 유닛 전원 평가 → 그룹(서포터 → 돌격 → 사격) 순 · 같은 그룹은 점수 높은 순 → 그 유닛의 후보에서 난이도대로 고른다.
/// 교전 범위 밖(가장 가까운 아군이 aggro 보다 멂)은 대기 → null 이면 이번 턴 끝.
/// </summary>
public static AiPlan Next(BattleManager bm, List<Unit> 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<AiPlan> 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<AiPlan> 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<Unit> 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;
}
/// <summary>난이도: 최고 점수를 accuracy(+보스 · 투기장 = mode) 확률로 · 아니면 상위 top_n 중 무작위 + 파워 ±noise.</summary>
static AiPlan Choose(BattleManager bm, Unit u, List<AiPlan> 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;
}
/// <summary>유닛 1기 평가: 후보마다 가상 실행 → 점수 내림차순 목록(list[0] = 최고).</summary>
public static AiPlan Evaluate(BattleManager bm, Unit u, int enemyCount, out List<AiPlan> list)
{
list = new List<AiPlan>();
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];
}
/// <summary>후보 목표점: 방위 × 파워 3단 · 궁·책은 사거리(+접근) 안 표적 · 책사는 덤불 중심까지.</summary>
static List<Vector2> Candidates(BattleManager bm, Unit u, int enemyCount, AiWeights w)
{
var res = new List<Vector2>();
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<Unit, Sim.UnitSnap> Units = new Dictionary<Unit, Sim.UnitSnap>();
public readonly HashSet<GimmickInstance> Removed = new HashSet<GimmickInstance>();
}
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;
}
/// <summary>점수(기획서 §2-3) — 가상 실행 직후 상태 vs 실행 전.</summary>
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<string>();
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<Unit>();
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<Unit>();
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<Unit>();
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;
}
/// <summary>궁·책 종료 위치와 플레이어 근접 유닛 사이 직선에 목책(투사체 통과 · 이동 막힘)이 있는가.</summary>
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;
}
/// <summary>플레이어 기병의 관통 사정(슬링 + 반지름) 안이고 사이에 장애물이 없으면 관통선 위.</summary>
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;
}
/// <summary>이번 가상 실행으로 지워진 덤불 때문에 새로 열린 (적 측 유닛 → 플레이어 유닛) 직선 수(사거리·슬링 안 · 다른 장애물 없음).</summary>
static int OpenedRoutes(BattleManager bm, Unit shooter, GimmickLayer layer, Pre pre)
{
var cleared = new List<GimmickInstance>();
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;
}
}
}