ThreeKingdomsSlingshot/Assets/Scripts/Runtime/ActionResolver.cs

657 lines
31 KiB
C#
Raw 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;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>슬링샷 한 번의 결과 요약.</summary>
public class ActionOutcome
{
public bool BonusMove; // 보병: 최종 목표점 명중 → 추가 이동
public bool Returned; // 기병: 최종 목표점 명중 → 제자리 복귀
public bool Overwatch; // 창병: 최종 목표점 명중 → 대기(견제)
public bool Crit; // 궁병·책사: 첫 피격 = 타겟 + 최종 목표점 명중 → 크리티컬
public bool MovedOnly; // 행동 유닛 자신은 공격하지 않음(이동 · 밀어내기만)
public bool Fired; // 궁병·책사: 투사체를 쐈다(타겟이 없으면 목표점 이동만 · 쏘지 않음)
public Unit ShotHit; // 궁병·책사: 투사체가 처음 맞힌 유닛(아군 포함 · 책사 광역 중심) · null = 안 쏨·빗나감
public int DamageDealt;
public int CounterTaken;
public List<Unit> Attacked = new List<Unit>();
/// <summary>궁병·책사 사격에 맞은 같은 편(오사 · 책사 광역 포함 · PD 2026-11-05 #1087 ⑤⑥).</summary>
public List<Unit> FriendlyHit = new List<Unit>();
public int FriendlyDamage;
/// <summary>충돌로 밀려난 유닛(아군·적군 · PD 2026-11-04 #1080).</summary>
public List<Unit> Pushed = new List<Unit>();
/// <summary>밀린 아군이 적과 닿아 얻은 공격 등 부가 설명(HUD 문구).</summary>
public List<string> Notes = new List<string>();
/// <summary>실제로 지나간 경로(시작 포함). 창병 견제 판정용.</summary>
public List<Vector2> Path = new List<Vector2>();
}
/// <summary>
/// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙).
/// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 멈춤 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회
/// ② 기병: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 · 목표점 명중 시 제자리 복귀
/// ③ 창병: 첫 유닛에 부딪히면 튕기지 않고 남은 거리만큼 밀며 함께 전진 · 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제)
/// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 ·
/// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 ·
/// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage)
/// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤)
/// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음)
/// </summary>
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;
// 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084)
if (u.View != null) u.View.SetCharging(true);
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:
case UnitType.Strategist:
yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o);
break;
}
if (u.View != null) u.View.SetCharging(false);
}
// ---------- 미리보기(드라이런) ----------
/// <summary>조작 중 표시용: 이 슬링샷으로 행동 유닛이 직접 공격하게 될 적 목록(밀린 아군의 공격은 예측하지 않음).</summary>
public List<Unit> PredictTargets(Unit u, Vector2 target, bool moveOnly)
{
var result = new List<Unit>();
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<float>();
result.AddRange(Combat.EnemiesOnPath(u, origin, dir, travel, dists));
break;
}
case UnitType.Archer:
case UnitType.Strategist:
{
var p = PlanShot(u, origin, dir, target);
if (!p.Fires) break;
if (p.Hit != null && p.Hit.Faction != u.Faction) result.Add(p.Hit);
foreach (var s in p.Splash)
{
if (s.Faction != u.Faction) result.Add(s);
}
break;
}
}
return result;
}
/// <summary>조작 중 표시용: 궁병·책사의 사격 계획(화살 경로선 · 첫 피격 유닛 · 책사 광역). 원거리 병종이 아니거나 이동만이면 null.</summary>
public ShotPlan PreviewShot(Unit u, Vector2 target, bool moveOnly)
{
if (moveOnly || u == null || !UnitTypeInfo.IsRanged(u.Type)) return null;
Vector2 delta = target - u.Position;
float dist = delta.magnitude;
if (dist < 0.001f) return null;
return PlanShot(u, u.Position, delta / dist, target);
}
// ---------- 공통 질의 ----------
/// <summary>경로 위 첫 유닛. 벽이 그보다 먼저 있으면 null.</summary>
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<float>();
var enemies = moveOnly ? new List<Unit>() : 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<Unit>();
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();
}
/// <summary>아군은 관통(통과)하되 travel 안에 완전히 지나칠 수 없는 아군이 있으면 그 아군 직전까지로 줄인다.</summary>
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;
}
/// <summary>종착점이 적과 겹치면 경로를 되짚어 겹치지 않는 지점까지 물러선다.</summary>
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;
}
// ---------- 궁병 · 책사: 투사체 사격 (PD 2026-11-05 #1087 ⑤⑥) ----------
/// <summary>궁병·책사 사격 계획 — 조준 미리보기(PreviewShot)와 실제 행동(ArcherRoutine)이 같은 계산을 쓴다.</summary>
public class ShotPlan
{
/// <summary>복도 탐색으로 고른 적(조준 방향 사거리 + 접근 허용 안의 첫 적) · null = 쏘지 않고 목표점까지 이동.</summary>
public Unit Target;
/// <summary>사격 전에 다가가는 거리(사거리 안 = 0).</summary>
public float Approach;
/// <summary>사거리에 들어와 쏜다(false = 아군·벽에 막혀 사거리에 못 들어감 → 이동만).</summary>
public bool Fires;
public Vector2 From;
/// <summary>투사체가 멈춘 지점(첫 피격 유닛에 닿은 곳 · 빗나가면 비행 끝).</summary>
public Vector2 To;
/// <summary>투사체가 처음 맞히는 유닛(아군 포함 · 관통 없음) = 책사 광역의 중심.</summary>
public Unit Hit;
/// <summary>첫 피격 = 타겟이고 최종 목표점 명중 → 크리티컬.</summary>
public bool Crit;
/// <summary>책사: 중심 주변 광역 대상(아군 포함 · 중심·쏜 책사 제외 · 가까운 순).</summary>
public readonly List<Unit> Splash = new List<Unit>();
}
ShotPlan PlanShot(Unit u, Vector2 origin, Vector2 dir, Vector2 target)
{
var p = new ShotPlan { From = origin, To = origin };
// 조준 방향으로 사거리 + 접근 허용 거리 안의 첫 적(타겟 선정 = #1080 그대로). hitDist = 그 적 가장자리에 닿기까지의 이동 거리(= 가장자리 간격).
float hitDist;
p.Target = Combat.FirstEnemyOnPath(u, origin, dir, u.AttackRange + u.ArcherMoveAllow, out hitDist);
if (p.Target == null) return p;
// 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한)
float need = Mathf.Clamp(hitDist - u.AttackRange, 0f, u.ArcherMoveAllow);
p.Approach = need > 0.01f ? Combat.FreeTravel(u, origin, dir, need, true) : 0f;
p.From = origin + dir * p.Approach;
p.To = p.From;
if (hitDist - p.Approach > u.AttackRange + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감
// 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음)
p.Fires = true;
Vector2 aim = p.Target.Position - p.From;
Vector2 adir = aim.sqrMagnitude > 1e-6f ? aim.normalized : dir;
float travel;
p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, u.AttackRange + u.Radius, cfg.arrowRadius, out travel);
p.To = p.From + adir * (p.Hit != null ? travel + cfg.arrowRadius : travel);
p.Crit = p.Hit == p.Target && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == p.Target;
if (p.Hit != null && u.Type == UnitType.Strategist)
{
p.Splash.AddRange(Combat.UnitsInCircle(p.Hit.Position, cfg.strategistSplashRadius, p.Hit, u));
}
return p;
}
IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
var p = moveOnly ? null : PlanShot(u, origin, dir, target);
if (p == null || p.Target == null)
{
// 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지)
yield return MoveFree(u, origin, dir, dist, o);
yield break;
}
if (p.Approach > 0.01f) yield return Move(u, p.From, o);
if (!p.Fires)
{
o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감
yield break;
}
Face(u, p.To - p.From); // 제자리 사격도 쏘는 방향을 본다
yield return Shoot(u, p, o);
}
/// <summary>
/// 투사체 1발: 첫 피격 유닛에 사격 피해(아군이면 오사 · 상성 적용 · 반격 없음 · 명중 보상 없음 · 밀지 않으므로 규칙 ⑤ 없음)
/// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음).
/// </summary>
IEnumerator Shoot(Unit u, ShotPlan p, ActionOutcome o)
{
bool strategist = u.Type == UnitType.Strategist;
o.Fired = true;
o.ShotHit = p.Hit;
if (u.View != null) u.View.PlayAttack();
yield return Fx.Arrow(p.From, p.To, strategist ? Fx.StrategistShotColor : Fx.ArrowColor);
if (p.Hit == null)
{
yield return Fx.Wait(0.1f); // 빗나감(벽 · 비행 끝)
yield break;
}
Vector2 center = p.Hit.Position;
ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o);
if (strategist)
{
Fx.Burst(center, cfg.strategistSplashRadius, Fx.SplashColor);
foreach (var s in p.Splash)
{
if (!s.IsAlive) continue;
float ratio = cfg.strategistSplashRatio * (s.Faction == u.Faction ? cfg.strategistAllyDamageRatio : 1f);
int dmg = Combat.SplashDamage(cfg, u, s, ratio, rng);
if (dmg > 0) ShotDamage(u, s, dmg, false, o);
}
}
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
}
/// <summary>사격 피해 적용 + 집계(상대 = Attacked · 같은 편 = FriendlyHit 오사).</summary>
void ShotDamage(Unit a, Unit d, int dmg, bool crit, ActionOutcome o)
{
d.TakeDamage(dmg);
if (d.Faction == a.Faction)
{
o.FriendlyHit.Add(d);
o.FriendlyDamage += dmg;
Fx.Popup(d.Position, dmg + " 오사", Fx.FriendlyFireColor);
return;
}
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);
}
// ---------- 공통 ----------
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;
}
/// <summary>보는 방향 갱신(PD #1082 · 마지막 이동 방향 · 4방향 = 앞/뒤 세트 + 좌우 반전).</summary>
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);
}
/// <summary>등속 접근(감속 없음) — 부딪히는 순간까지 속도를 유지해 충돌 반응이 바로 이어진다.</summary>
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);
}
/// <summary>부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계.</summary>
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();
}
/// <summary>알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio.</summary>
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();
}
/// <summary>부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다.</summary>
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();
}
/// <summary>규칙 ⑤: 밀린 아군이 상대 유닛과 닿아 있으면 그 자리에서 공격 1회(근접 · 자기 행동 소모 없음).</summary>
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 PushedAttack(ally, foe, o);
}
/// <summary>밀린 아군의 근접 공격 1회(반격 받음 · 결과 요약에는 집계하지 않고 Notes 에 기록). 원거리 병종도 근접 패널티를 받는다.</summary>
IEnumerator PushedAttack(Unit a, Unit d, ActionOutcome o)
{
if (a.View != null) a.View.PlayAttack();
yield return Fx.Lunge(a, d.Position);
int dmg = Combat.Damage(cfg, a, d, false, rng, true);
d.TakeDamage(dmg);
Fx.Popup(d.Position, dmg.ToString(), Color.white);
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
int counter = 0;
if (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);
Fx.Popup(a.Position, counter.ToString(), new Color(1f, 0.45f, 0.45f));
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
}
o.Notes.Add("밀린 " + a.DisplayName + " → " + d.DisplayName + " 공격 " + dmg + (counter > 0 ? " · 반격 " + counter : ""));
}
}
}