2026-11-05 16:20:07 +00:00
|
|
|
|
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)
|
|
|
|
|
|
{
|
2026-11-10 08:25:47 +00:00
|
|
|
|
s += w["pierce_hit"] * hit.Count; // 복귀 성공·실패 점수 = 기병 복귀 제거로 삭제(PD 2026-11-10)
|
|
|
|
|
|
parts.Add("관통 " + hit.Count);
|
2026-11-05 16:20:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|