using System.Collections; using System.Collections.Generic; using UnityEngine; namespace TKS { /// 슬링샷 한 번의 결과 요약. public class ActionOutcome { public bool BonusMove; // 보병: 최종 목표점 명중 → 추가 이동 public bool Returned; // 기병: 최종 목표점 명중 → 제자리 복귀 public bool Overwatch; // 창병: 최종 목표점 명중 → 대기(견제) public bool Crit; // 궁병: 최종 목표점 명중 → 크리티컬 public bool MovedOnly; // 행동 유닛 자신은 공격하지 않음(이동 · 밀어내기만) public int DamageDealt; public int CounterTaken; public List Attacked = new List(); /// 충돌로 밀려난 유닛(아군·적군 · PD 2026-11-04 #1080). public List Pushed = new List(); /// 밀린 아군이 적과 닿아 얻은 공격 등 부가 설명(HUD 문구). public List Notes = new List(); /// 실제로 지나간 경로(시작 포함). 창병 견제 판정용. public List Path = new List(); } /// /// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙). /// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 멈춤 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회 /// ② 기병: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 · 목표점 명중 시 제자리 복귀 /// ③ 창병: 첫 유닛에 부딪히면 튕기지 않고 남은 거리만큼 밀며 함께 전진 · 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제) /// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 · /// 더 멀면 목표점까지 이동 · 반격 없음 · 목표점 명중 시 크리티컬 · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage) /// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음) /// public class ActionResolver { readonly BalanceConfig cfg; readonly BattleManager battle; readonly System.Random rng; public ActionResolver(BalanceConfig cfg, BattleManager battle, System.Random rng) { this.cfg = cfg; this.battle = battle; this.rng = rng; } public IEnumerator Resolve(Unit u, Vector2 target, bool moveOnly, ActionOutcome o) { Vector2 origin = u.Position; Vector2 delta = target - origin; float dist = delta.magnitude; o.Path.Add(origin); if (dist < 0.001f || !u.IsAlive) yield break; Vector2 dir = delta / dist; switch (u.Type) { case UnitType.Infantry: yield return InfantryRoutine(u, origin, dir, dist, target, moveOnly, o); break; case UnitType.Spear: yield return SpearRoutine(u, origin, dir, dist, target, moveOnly, o); break; case UnitType.Cavalry: yield return CavalryRoutine(u, origin, dir, dist, target, moveOnly, o); break; case UnitType.Archer: yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o); break; } } // ---------- 미리보기(드라이런) ---------- /// 조작 중 표시용: 이 슬링샷으로 행동 유닛이 직접 공격하게 될 적 목록(밀린 아군의 공격은 예측하지 않음). public List PredictTargets(Unit u, Vector2 target, bool moveOnly) { var result = new List(); if (moveOnly) return result; Vector2 origin = u.Position; Vector2 delta = target - origin; float dist = delta.magnitude; if (dist < 0.001f) return result; Vector2 dir = delta / dist; float hitDist; switch (u.Type) { case UnitType.Infantry: case UnitType.Spear: { var first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist); if (first != null && first.Faction != u.Faction) result.Add(first); break; } case UnitType.Cavalry: { float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); travel = StopBeforeUnclearedAllies(u, origin, dir, travel); var dists = new List(); result.AddRange(Combat.EnemiesOnPath(u, origin, dir, travel, dists)); break; } case UnitType.Archer: { var first = Combat.FirstEnemyOnPath(u, origin, dir, u.AttackRange + u.ArcherMoveAllow, out hitDist); if (first != null) result.Add(first); break; } } return result; } // ---------- 공통 질의 ---------- /// 경로 위 첫 유닛. 벽이 그보다 먼저 있으면 null. Unit FirstUnitBeforeWall(Unit u, Vector2 origin, Vector2 dir, float dist, out float hitDist) { Unit first = Combat.FirstUnitOnPath(u, origin, dir, dist, out hitDist); if (first == null) return null; float wall = Combat.FreeTravel(u, origin, dir, dist, false, false); if (hitDist > wall + Combat.Epsilon) { hitDist = 0f; return null; } return first; } // ---------- 보병: 알까기 충돌 ---------- IEnumerator InfantryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) { if (moveOnly) { yield return MoveFree(u, origin, dir, dist, o); yield break; } float hitDist; Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist); if (first == null) { yield return MoveFree(u, origin, dir, dist, o); yield break; } Vector2 contact = origin + dir * hitDist; yield return MoveStraight(u, contact, o); // 등속으로 가서 부딪힌 순간 바로 반응(PD #1081) float remaining = Mathf.Max(0f, dist - hitDist); bool enemy = first.Faction != u.Faction; bool isFinal = enemy && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first; if (enemy) { ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격을 즉시 주고받는다(연출 대기 없음) if (!u.IsAlive) yield break; } else { o.MovedOnly = true; } // 알까기: 부딪힌 순간 양쪽이 동시에 튕긴다 — 상대 = 접촉 방향 × 남은 힘 × collisionPushRatio · 자신 = 반대 방향 × 남은 힘 × collisionSelfBounceRatio Vector2 n = first.Position - contact; n = n.sqrMagnitude > 1e-6f ? n.normalized : dir; yield return Bounce(u, first, n, remaining, o); if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o); if (isFinal && u.IsAlive) o.BonusMove = true; } // ---------- 창병: 밀며 함께 전진 ---------- IEnumerator SpearRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) { if (moveOnly) { yield return MoveFree(u, origin, dir, dist, o); yield break; } float hitDist; Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist); if (first == null) { yield return MoveFree(u, origin, dir, dist, o); yield break; } Vector2 contact = origin + dir * hitDist; yield return MoveStraight(u, contact, o); // 등속 접근 → 부딪힌 순간 즉시 밀기 시작(PD #1081) float remaining = Mathf.Max(0f, dist - hitDist); bool enemy = first.Faction != u.Faction; bool isFinal = enemy && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first; if (enemy) { ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격 즉시 if (!u.IsAlive) yield break; } else { o.MovedOnly = true; } if (first.IsAlive && remaining > 0.01f) { // 튕기지 않고 앞 유닛을 밀며 함께 전진 · 앞 유닛이 다른 유닛·벽에 막히면 둘 다 멈춤 · 접촉 간격 유지 float travel = Combat.FreeTravel(first, first.Position, dir, remaining, true, true, u); if (travel > 0.01f) { Face(first, dir); // 밀린 유닛은 밀린 방향을 본다(PD #1082) Vector2 firstEnd = first.Position + dir * travel; Vector2 spearEnd = contact + dir * travel; Fx.Run(Fx.MoveAlong(first.transform, first.Position, firstEnd, cfg.moveSpeed, null)); yield return Fx.MoveAlong(u.transform, contact, spearEnd, cfg.moveSpeed, null); first.Position = firstEnd; u.Position = spearEnd; o.Path.Add(spearEnd); battle.NotifyChanged(); } o.Pushed.Add(first); if (!enemy) yield return PushedAllyAttack(first, o); } if (isFinal && u.IsAlive) { u.SetOverwatch(true); o.Overwatch = true; } } // ---------- 기병: 관통 ---------- IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) { float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽만 travel = StopBeforeUnclearedAllies(u, origin, dir, travel); // 지나칠 수 없는 아군 직전에서 멈춤 var dists = new List(); var enemies = moveOnly ? new List() : Combat.EnemiesOnPath(u, origin, dir, travel, dists); Unit finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance); bool willReturn = finalHit != null && enemies.Contains(finalHit); Vector2 end = origin + dir * travel; if (!willReturn) end = BackOffFromEnemies(u, origin, dir, travel); if (enemies.Count == 0) { yield return Move(u, end, o); o.MovedOnly = true; yield break; } Face(u, dir); if (u.View != null) u.View.PlayAttack(); int idx = 0; var hit = new List(); yield return Fx.MoveAlong(u.transform, origin, end, cfg.moveSpeed, travelled => { while (idx < enemies.Count && travelled >= dists[idx] - 0.01f) { PierceHit(u, enemies[idx], dir, o); hit.Add(enemies[idx]); idx++; } }); u.Position = end; o.Path.Add(end); for (; idx < enemies.Count; idx++) { if (dists[idx] <= travel + 0.01f) { PierceHit(u, enemies[idx], dir, o); hit.Add(enemies[idx]); } } yield return Fx.Wait(0.15f); // 살아남은 근접 적의 반격 foreach (var e in hit) { if (!u.IsAlive) break; if (!e.IsAlive || !UnitTypeInfo.IsMelee(e.Type)) continue; int c = Combat.CounterDamage(cfg, e, u, rng); u.TakeDamage(c); o.CounterTaken += c; Fx.Popup(u.Position, c.ToString(), new Color(1f, 0.45f, 0.45f)); battle.NotifyChanged(); yield return Fx.Wait(0.12f); } if (willReturn && u.IsAlive) { yield return Fx.Wait(0.1f); yield return Move(u, origin, o, false); // 복귀는 적을 본 채 물러남(보는 방향 유지 · 총괄PM 해석) o.Returned = true; } } void PierceHit(Unit cav, Unit e, Vector2 dir, ActionOutcome o) { if (!e.IsAlive) return; int dmg = Combat.Damage(cfg, cav, e, false, rng, true); e.TakeDamage(dmg); o.Attacked.Add(e); o.DamageDealt += dmg; Fx.Popup(e.Position, dmg.ToString(), Color.white); // 옆으로 밀어내기 Vector2 side = new Vector2(-dir.y, dir.x); float sign = Vector2.Dot(e.Position - cav.Position, side) >= 0f ? 1f : -1f; if (e.IsAlive) { Vector2 to = battle.Board.ClampInside(e.Position + side * sign * cfg.cavalryKnockback, e.Radius); Fx.Run(Fx.MoveAlong(e.transform, e.Position, to, cfg.moveSpeed * 0.6f, null)); } battle.NotifyChanged(); } /// 아군은 관통(통과)하되 travel 안에 완전히 지나칠 수 없는 아군이 있으면 그 아군 직전까지로 줄인다. float StopBeforeUnclearedAllies(Unit u, Vector2 origin, Vector2 dir, float travel) { float t = travel; var allies = battle.AliveUnits(u.Faction); for (int pass = 0; pass < 4; pass++) { float before = t; foreach (var a in allies) { if (a == u) continue; Vector2 rel = a.Position - origin; float along = Vector2.Dot(rel, dir); float lat = Mathf.Abs(rel.x * dir.y - rel.y * dir.x); float sum = u.Radius + a.Radius; if (lat >= sum || along + sum <= 0f) continue; // 경로와 겹치지 않음 · 뒤쪽 float half = Mathf.Sqrt(sum * sum - lat * lat); float touch = along - half; float clear = along + half; if (t >= clear || t <= touch) continue; // 완전히 지나침 · 닿기 전에 멈춤 t = Mathf.Min(t, Mathf.Max(0f, touch - Combat.Epsilon)); } if (Mathf.Approximately(before, t)) break; } return t; } /// 종착점이 적과 겹치면 경로를 되짚어 겹치지 않는 지점까지 물러선다. Vector2 BackOffFromEnemies(Unit u, Vector2 origin, Vector2 dir, float travel) { float t = travel; for (int i = 0; i < 40 && t > 0f; i++) { Vector2 p = origin + dir * t; bool overlap = false; foreach (var e in battle.AliveUnits(u.Faction == Faction.Player ? Faction.Enemy : Faction.Player)) { if (Vector2.Distance(p, e.Position) < u.Radius + e.Radius - 0.01f) { overlap = true; break; } } if (!overlap) return p; t -= 0.1f; } return origin; } // ---------- 궁병 ---------- IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) { // 조준 방향으로 사거리 + 접근 허용 거리 안의 첫 적. hitDist = 그 적 가장자리에 닿기까지의 이동 거리(= 가장자리 간격). float hitDist = 0f; Unit first = moveOnly ? null : Combat.FirstEnemyOnPath(u, origin, dir, u.AttackRange + u.ArcherMoveAllow, out hitDist); if (first == null) { // 공격 가능 범위에 적이 없으면 목표점까지 이동 (PD 2026-11-04 2차 지시) yield return MoveFree(u, origin, dir, dist, o); yield break; } // 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한) float need = Mathf.Clamp(hitDist - u.AttackRange, 0f, u.ArcherMoveAllow); float freeMove = need > 0.01f ? Combat.FreeTravel(u, origin, dir, need, true) : 0f; if (freeMove > 0.01f) yield return Move(u, origin + dir * freeMove, o); if (hitDist - freeMove <= u.AttackRange + 0.05f) { bool crit = Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first; Face(u, dir); // 제자리 사격도 조준 방향을 본다 yield return Attack(u, first, true, crit, o, true); } else { o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감 } } // ---------- 공통 ---------- IEnumerator MoveFree(Unit u, Vector2 origin, Vector2 dir, float dist, ActionOutcome o) { float free = Combat.FreeTravel(u, origin, dir, dist, true, true); yield return Move(u, origin + dir * free, o); o.MovedOnly = true; } /// 보는 방향 갱신(PD #1082 · 마지막 이동 방향 · 4방향 = 앞/뒤 세트 + 좌우 반전). static void Face(Unit u, Vector2 dir) { if (u != null && u.View != null && dir.sqrMagnitude > 1e-6f) u.View.SetFacing(dir); } IEnumerator Move(Unit u, Vector2 to, ActionOutcome o, bool face = true) { if (face) Face(u, to - u.Position); yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null); u.Position = to; o.Path.Add(to); } /// 등속 접근(감속 없음) — 부딪히는 순간까지 속도를 유지해 충돌 반응이 바로 이어진다. IEnumerator MoveStraight(Unit u, Vector2 to, ActionOutcome o) { Face(u, to - u.Position); yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null, false); u.Position = to; o.Path.Add(to); } /// 부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계. void ImpactExchange(Unit a, Unit d, ActionOutcome o) { if (a.View != null) a.View.PlayAttack(); int dmg = Combat.Damage(cfg, a, d, false, rng, true); d.TakeDamage(dmg); o.Attacked.Add(d); o.DamageDealt += dmg; Fx.Popup(d.Position, dmg.ToString(), Color.white); if (d.IsAlive && a.IsAlive) { int c = Combat.CounterDamage(cfg, d, a, rng); if (d.View != null) d.View.PlayAttack(); a.TakeDamage(c); o.CounterTaken += c; Fx.Popup(a.Position, c.ToString(), new Color(1f, 0.45f, 0.45f)); } battle.NotifyChanged(); } /// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio. IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o) { bool struckDone = true; if (struck != null && struck.IsAlive && remaining * cfg.collisionPushRatio >= 0.01f) { struckDone = false; Fx.Run(Track(Push(struck, n, remaining * cfg.collisionPushRatio, striker, o), () => struckDone = true)); } float self = remaining * cfg.collisionSelfBounceRatio; if (self >= 0.01f && striker.IsAlive) { float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck); if (back > 0.01f) { Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082) Vector2 end = battle.Board.ClampInside(striker.Position - n * back, striker.Radius); yield return Fx.MoveAlong(striker.transform, striker.Position, end, cfg.moveSpeed, null); striker.Position = end; o.Path.Add(end); } } while (!struckDone) yield return null; } static IEnumerator Track(IEnumerator inner, System.Action onDone) { yield return inner; onDone(); } /// 부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다. IEnumerator Push(Unit target, Vector2 n, float force, Unit pusher, ActionOutcome o) { if (target == null || !target.IsAlive || force < 0.01f) yield break; float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher); if (slide > 0.01f) { Face(target, n); // 밀린 방향을 본다(PD #1082) Vector2 end = battle.Board.ClampInside(target.Position + n * slide, target.Radius); yield return Fx.MoveAlong(target.transform, target.Position, end, cfg.moveSpeed, null); target.Position = end; } o.Pushed.Add(target); battle.NotifyChanged(); } /// 규칙 ⑤: 밀린 아군이 상대 유닛과 닿아 있으면 그 자리에서 공격 1회(근접 · 자기 행동 소모 없음). IEnumerator PushedAllyAttack(Unit ally, ActionOutcome o) { if (ally == null || !ally.IsAlive) yield break; Unit foe = Combat.TouchingOpponent(ally); if (foe == null) yield break; yield return Fx.Wait(0.1f); Face(ally, foe.Position - ally.Position); yield return Attack(ally, foe, false, false, o, false); } /// 공격 1회. byActor = 행동 유닛의 공격(결과 요약에 집계) · false = 밀린 아군의 공격(Notes 에 기록). IEnumerator Attack(Unit a, Unit d, bool ranged, bool crit, ActionOutcome o, bool byActor) { if (a.View != null) a.View.PlayAttack(); if (ranged) yield return Fx.Arrow(a.Position, d.Position); else yield return Fx.Lunge(a, d.Position); int dmg = Combat.Damage(cfg, a, d, crit, rng, !ranged); d.TakeDamage(dmg); if (byActor) { o.Attacked.Add(d); o.DamageDealt += dmg; o.Crit |= crit; } Fx.Popup(d.Position, crit ? dmg + "!" : dmg.ToString(), crit ? new Color(1f, 0.7f, 0.2f) : Color.white); battle.NotifyChanged(); yield return Fx.Wait(0.2f); int counter = 0; if (!ranged && d.IsAlive && a.IsAlive) { counter = Combat.CounterDamage(cfg, d, a, rng); if (d.View != null) d.View.PlayAttack(); yield return Fx.Lunge(d, a.Position); a.TakeDamage(counter); if (byActor) o.CounterTaken += counter; Fx.Popup(a.Position, counter.ToString(), new Color(1f, 0.45f, 0.45f)); battle.NotifyChanged(); yield return Fx.Wait(0.2f); } if (!byActor) { o.Notes.Add("밀린 " + a.DisplayName + " → " + d.DisplayName + " 공격 " + dmg + (counter > 0 ? " · 반격 " + counter : "")); } } } }