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(); /// 실제로 지나간 경로(시작 포함). 창병 견제 판정용. public List Path = new List(); } /// /// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다. /// ① 보병: 경로 위 첫 적까지 이동 후 공격 · 목표점 명중 시 추가 이동 1회 /// ② 기병: 경로 위 적을 모두 관통 피해 · 목표점까지 이동 · 목표점 명중 시 제자리 복귀 /// ③ 창병: 보병과 동일 · 목표점 명중 시 대기 → 반경 안에서 움직이는 적에게 피해 /// ④ 궁병: 조준 방향 슬링샷 거리 + 사거리(슬링샷 최대 × archerRangeRatio) 안에 적이 있으면 /// 적 가장자리가 사거리에 들어올 때까지만 이동 후 사격(반격 없음) · 목표점 명중 시 크리티컬 · /// 적이 없으면 목표점까지 이동만(PD 2026-11-04 2차 지시 · 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: case UnitType.Spear: yield return MeleeRoutine(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 = Combat.FirstEnemyOnPath(u, origin, dir, dist, out hitDist); if (first != null && Combat.FreeTravel(u, origin, dir, hitDist, false) >= hitDist - 0.05f) result.Add(first); break; } case UnitType.Cavalry: { float travel = Combat.FreeTravel(u, origin, dir, dist, false); var dists = new List(); result.AddRange(Combat.EnemiesOnPath(u, origin, dir, travel, dists)); break; } case UnitType.Archer: { var first = Combat.FirstEnemyOnPath(u, origin, dir, dist + u.AttackRange, out hitDist); if (first != null) result.Add(first); break; } } return result; } // ---------- 보병 · 창병 ---------- IEnumerator MeleeRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o) { float hitDist = 0f; Unit first = moveOnly ? null : Combat.FirstEnemyOnPath(u, origin, dir, dist, out hitDist); if (first == null) { float free = Combat.FreeTravel(u, origin, dir, dist, true); yield return Move(u, origin + dir * free, o); o.MovedOnly = true; yield break; } float freeToEnemy = Combat.FreeTravel(u, origin, dir, hitDist, false); if (freeToEnemy < hitDist - 0.05f) { // 아군·벽이 먼저 막는다 → 거기까지만 이동 yield return Move(u, origin + dir * freeToEnemy, o); o.MovedOnly = true; yield break; } yield return Move(u, origin + dir * hitDist, o); Unit finalHit = Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance); bool isFinal = finalHit == first; yield return Attack(u, first, false, false, o); if (!u.IsAlive) yield break; if (u.Type == UnitType.Infantry && isFinal) o.BonusMove = true; if (u.Type == UnitType.Spear && isFinal) { 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); 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; } 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); 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); 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(); } /// 종착점이 적과 겹치면 경로를 되짚어 겹치지 않는 지점까지 물러선다. 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, dist + u.AttackRange, out hitDist); if (first == null) { // 적이 없으면 목표점까지 이동(PD 2026-11-04 2차: 「슬링샷 범위 내 적이 없을 경우 해당 위치까지 이동」) float free = Combat.FreeTravel(u, origin, dir, dist, true); yield return Move(u, origin + dir * free, o); o.MovedOnly = true; yield break; } // 적 가장자리까지 남은 간격이 사거리 안에 들어올 때까지만 이동(슬링샷 거리 상한) float need = Mathf.Clamp(hitDist - u.AttackRange, 0f, dist); float freeMove = Combat.FreeTravel(u, origin, dir, need, true); 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; yield return Attack(u, first, true, crit, o); } else { o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감 } } // ---------- 공통 ---------- IEnumerator Move(Unit u, Vector2 to, ActionOutcome o) { yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null); u.Position = to; o.Path.Add(to); } IEnumerator Attack(Unit a, Unit d, bool ranged, bool crit, ActionOutcome o) { 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); d.TakeDamage(dmg); 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); if (!ranged && d.IsAlive && a.IsAlive) { int c = Combat.CounterDamage(cfg, d, a, rng); if (d.View != null) d.View.PlayAttack(); yield return Fx.Lunge(d, a.Position); a.TakeDamage(c); o.CounterTaken += c; Fx.Popup(a.Position, c.ToString(), new Color(1f, 0.45f, 0.45f)); battle.NotifyChanged(); yield return Fx.Wait(0.2f); } } } }