using System.Collections.Generic; using UnityEngine; namespace TKS { /// 피해 공식과 전장 기하 질의(경로 위 적·이동 가능 거리·최종 목표점 명중). public static class Combat { /// 경로 질의에 쓰는 원 반지름 배율(살짝 작게 캐스트해 스침을 관대하게). public const float CastRadiusScale = 0.9f; public const float Epsilon = 0.02f; /// 두 유닛 가장자리 간격이 이 이하면 「닿아 있다」(밀린 아군의 공격 기회 판정). public const float ContactTolerance = 0.12f; // ---------- 피해 공식 ---------- /// /// 피해 = (공격 × 상성 × 크리 − 방어 × defenseFactor) × 궁병 근접 배율 × 난수. /// melee = 근접 전투(돌진·반격·관통·견제·밀려 닿은 공격). 원거리 병종(궁병·책사)이 근접 유닛(보병·기병·창병)과 근접 전투하면 /// 주는 피해 × archerMeleeDealtRatio · 받는 피해 × archerMeleeTakenRatio (PD 2026-11-04 #1080 · 책사 #1087 ⑤ 동일) · 원거리 사격은 패널티 없음. /// public static int Damage(BalanceConfig cfg, Unit attacker, Unit defender, bool crit, System.Random rng, bool melee) { float mult = cfg.Matchup(attacker.Type, defender.Type); float raw = attacker.Atk * mult * (crit ? cfg.critMultiplier : 1f) - defender.Def * cfg.defenseFactor; if (melee) { if (UnitTypeInfo.IsRanged(attacker.Type) && UnitTypeInfo.IsMelee(defender.Type)) raw *= cfg.archerMeleeDealtRatio; if (UnitTypeInfo.IsRanged(defender.Type) && UnitTypeInfo.IsMelee(attacker.Type)) raw *= cfg.archerMeleeTakenRatio; } float variance = 1f + ((float)rng.NextDouble() * 2f - 1f) * cfg.damageVariance; return Mathf.Max(1, Mathf.RoundToInt(raw * variance)); } /// 책사 광역 피해(PD 2026-11-05 #1087 ⑤) = 대상별 사격 피해(상성 적용 · 크리 없음) × ratio · ratio ≤ 0 이면 0. public static int SplashDamage(BalanceConfig cfg, Unit attacker, Unit defender, float ratio, System.Random rng) { if (ratio <= 0f) return 0; int baseDmg = Damage(cfg, attacker, defender, false, rng, false); return Mathf.Max(1, Mathf.RoundToInt(baseDmg * ratio)); } public static int CounterDamage(BalanceConfig cfg, Unit defender, Unit attacker, System.Random rng) { int baseDmg = Damage(cfg, defender, attacker, false, rng, true); return Mathf.Max(1, Mathf.RoundToInt(baseDmg * cfg.counterRatio)); } public static int OverwatchDamage(BalanceConfig cfg, Unit spear, Unit mover, System.Random rng) { int baseDmg = Damage(cfg, spear, mover, false, rng, true); return Mathf.Max(1, Mathf.RoundToInt(baseDmg * cfg.overwatchDamageRatio)); } // ---------- 기하 질의 ---------- static RaycastHit2D[] Cast(Unit actor, Vector2 origin, Vector2 dir, float dist) { Physics2D.SyncTransforms(); var hits = Physics2D.CircleCastAll(origin, actor.Radius * CastRadiusScale, dir, dist); System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance)); return hits; } static Unit UnitOf(Collider2D c) { return c != null ? c.GetComponent() : null; } /// 경로(원 캐스트) 위 가장 가까운 적. hitDist = 그 적에 닿기까지 이동 거리. public static Unit FirstEnemyOnPath(Unit actor, Vector2 origin, Vector2 dir, float dist, out float hitDist) { hitDist = 0f; foreach (var h in Cast(actor, origin, dir, dist)) { var u = UnitOf(h.collider); if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue; hitDist = h.distance; return u; } return null; } /// 경로(원 캐스트) 위 가장 가까운 유닛(아군·적군 불문). hitDist = 닿기까지 이동 거리. 벽은 무시한다(호출 쪽에서 FreeTravel 과 비교). public static Unit FirstUnitOnPath(Unit actor, Vector2 origin, Vector2 dir, float dist, out float hitDist) { hitDist = 0f; foreach (var h in Cast(actor, origin, dir, dist)) { var u = UnitOf(h.collider); if (u == null || u == actor || !u.IsAlive) continue; hitDist = h.distance; return u; } return null; } /// /// 화살 투사체(PD 2026-11-05 #1087 ⑥): from 에서 dir 로 반지름 arrowRadius 원을 dist 만큼 날려 처음 닿는 유닛(아군·적군 불문 · 쏜 유닛 제외). /// 벽에 먼저 닿거나 아무도 없으면 null. travel = 화살이 멈춘 거리(유닛·벽 접촉 · 없으면 dist). /// public static Unit FirstUnitOnArrow(Unit shooter, Vector2 from, Vector2 dir, float dist, float arrowRadius, out float travel) { travel = dist; Physics2D.SyncTransforms(); var hits = Physics2D.CircleCastAll(from, arrowRadius, dir, dist); System.Array.Sort(hits, (a, b) => a.distance.CompareTo(b.distance)); foreach (var h in hits) { if (h.collider.GetComponent() != null) { travel = h.distance; return null; } var u = UnitOf(h.collider); if (u == null || u == shooter || !u.IsAlive) continue; travel = h.distance; return u; } return null; } /// 점 center 반경 radius 원에 몸(원)이 닿는 살아 있는 유닛(가까운 순 · 진영 불문 · exclude 둘 제외) — 책사 광역(보스는 반지름이 커서 더 잘 닿는다). public static List UnitsInCircle(Vector2 center, float radius, Unit excludeA, Unit excludeB) { Physics2D.SyncTransforms(); var list = new List(); foreach (var c in Physics2D.OverlapCircleAll(center, radius)) { var u = UnitOf(c); if (u == null || u == excludeA || u == excludeB || !u.IsAlive || list.Contains(u)) continue; list.Add(u); } list.Sort((a, b) => Vector2.Distance(center, a.Position).CompareTo(Vector2.Distance(center, b.Position))); return list; } /// u 와 가장자리가 닿아 있는(ContactTolerance 이내) 가장 가까운 상대 진영 유닛. public static Unit TouchingOpponent(Unit u) { Physics2D.SyncTransforms(); Unit best = null; float bestGap = float.MaxValue; foreach (var c in Physics2D.OverlapCircleAll(u.Position, u.Radius + ContactTolerance + 0.5f)) { var o = UnitOf(c); if (o == null || o == u || !o.IsAlive || o.Faction == u.Faction) continue; float gap = Vector2.Distance(u.Position, o.Position) - u.Radius - o.Radius; if (gap <= ContactTolerance && gap < bestGap) { bestGap = gap; best = o; } } return best; } /// 경로 위의 모든 적(가까운 순)과 각 접촉 거리. public static List EnemiesOnPath(Unit actor, Vector2 origin, Vector2 dir, float dist, List distances) { var list = new List(); distances.Clear(); foreach (var h in Cast(actor, origin, dir, dist)) { var u = UnitOf(h.collider); if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue; if (list.Contains(u)) continue; list.Add(u); distances.Add(h.distance); } return list; } /// 벽·(옵션) 유닛에 막히기 전까지 실제로 나아갈 수 있는 거리. ignore = 밀어 주는 유닛처럼 출발 지점에서 닿아 있어도 무시할 유닛. public static float FreeTravel(Unit actor, Vector2 origin, Vector2 dir, float dist, bool enemiesBlock, bool friendsBlock = true, Unit ignore = null) { float min = dist; foreach (var h in Cast(actor, origin, dir, dist)) { if (h.collider.GetComponent() != null) { min = Mathf.Min(min, h.distance); continue; } var u = UnitOf(h.collider); if (u == null || u == actor || u == ignore || !u.IsAlive) continue; bool enemy = u.Faction != actor.Faction; if (enemy && !enemiesBlock) continue; if (!enemy && !friendsBlock) continue; min = Mathf.Min(min, h.distance); } return Mathf.Max(0f, min - Epsilon); } /// 목표점이 적 원 안(여유 포함)에 들어왔으면 그 적. public static Unit EnemyAtPoint(Unit actor, Vector2 point, float tolerance) { Physics2D.SyncTransforms(); Unit best = null; float bestD = float.MaxValue; foreach (var c in Physics2D.OverlapCircleAll(point, tolerance)) { var u = UnitOf(c); if (u == null || u == actor || !u.IsAlive || u.Faction == actor.Faction) continue; float d = Vector2.Distance(point, u.Position); if (d < bestD) { bestD = d; best = u; } } return best; } /// 점 p 에서 가장 가까운 지정 진영 유닛(터치 여유 extra 포함). public static Unit UnitNear(IEnumerable units, Vector2 p, Faction? faction, float extra) { Unit best = null; float bestD = float.MaxValue; foreach (var u in units) { if (!u.IsAlive) continue; if (faction.HasValue && u.Faction != faction.Value) continue; float d = Vector2.Distance(p, u.Position); if (d <= u.Radius + extra && d < bestD) { bestD = d; best = u; } } return best; } /// 선분 ab 와 점 p 의 최단 거리. public static float SegmentPointDistance(Vector2 a, Vector2 b, Vector2 p) { Vector2 ab = b - a; float len2 = ab.sqrMagnitude; if (len2 < 1e-6f) return Vector2.Distance(a, p); float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / len2); return Vector2.Distance(a + ab * t, p); } } }