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 bool Fired; // 궁병·책사: 투사체를 쐈다(타겟이 없으면 목표점 이동만 · 쏘지 않음)
public Unit ShotHit; // 궁병·책사: 투사체가 처음 맞힌 유닛(아군 포함 · 책사 광역 중심) · null = 안 쏨·빗나감
public int DamageDealt;
public int CounterTaken;
public List Attacked = new List();
/// 궁병·책사 사격에 맞은 같은 편(오사 · 책사 광역 포함 · PD 2026-11-05 #1087 ⑤⑥).
public List FriendlyHit = new List();
public int FriendlyDamage;
/// 충돌로 밀려난 유닛(아군·적군 · PD 2026-11-04 #1080).
public List Pushed = new List();
/// 밀린 아군이 적과 닿아 얻은 공격 등 부가 설명(HUD 문구).
public List Notes = new List();
/// 실제로 지나간 경로(시작 포함). 창병 견제 판정용.
public List Path = new List();
// ---------- 스킬(PD #1093 B) ----------
/// 이 행동에 걸린 예약 액티브(next_action) · null = 없음.
public SkillDef SkillUsed;
/// 추가 이동 횟수(기본 1 · 인덕 extra_move_count) · 추가 이동에서 공격 가능(extra_move_attack).
public int BonusMoves = 1;
public bool BonusAttack;
/// 이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정 · 기믹 지대 스윕).
public List Displaced = new List();
/// 보병 연쇄 교환 수(튕겨나는 도중 다른 적과 부딪힘 · PD 2026-11-06 #1098).
public int Chains;
}
public struct Displacement
{
public Unit Unit;
public Vector2 From;
public Vector2 To;
}
///
/// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙).
/// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 반대로 튕김 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회 ·
/// 적과 싸운 뒤 튕겨나는 도중 다른 적과 부딪히면 그 적과도 피해·반격 교환 + 다시 튕김(연쇄 1회 · 아군이면 밀기만 · PD 2026-11-06 #1098)
/// ② 기병 = 적을 관통해 이동: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 ·
/// 제자리 복귀 없음(PD 2026-11-10 「유저가 인지하기 어려움 → 제거 · 기병 특징 = 적을 관통해 이동」)
/// ③ 창병 = 적을 크게 밀치고 자신은 적게 밀림: 첫 유닛에 부딪히면 그 유닛을 남은 거리 × spearPushRatio 만큼 밀치고 자신은 그 × spearRecoilRatio 만큼 반대로 밀림 ·
/// 적이면 피해(반격 받음) · 아군이면 밀기만 · 대기(견제) 없음(PD 2026-11-10 「유저가 인지하기 어려움 → 제거」)
/// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 ·
/// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 ·
/// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage)
/// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤)
/// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음)
/// 스킬(PD #1093 B): 수치는 SkillMods 질의(밀기·튕김·관통·사거리·사선·광역·피해) · 예약 액티브는 Resolve 앞뒤에서 장착·소모(SkillSystem).
/// 지형 기믹(PD #1097): 충돌·투사체·미끄러짐·지대 = Gimmicks/ActionResolver.Gimmicks.cs · 가상 실행(적 AI) = Sim.Active 동안 연출 없이 즉시.
///
public partial 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;
float slingDist = Vector2.Distance(origin, target);
Vector2 aimed = target;
target = ReachPoint(u, target); // 늪·고지 이동 비용 = 실제 도달점(근접 병종 · PD #1097) · 조인 철벽 = 반경 안 이동 비용 +
Vector2 delta = target - origin;
float dist = delta.magnitude;
o.Path.Add(origin);
if (dist < 0.001f || !u.IsAlive) yield break;
Vector2 dir = delta / dist;
GimmickBegin(o);
var skills = SkillSystem.Current;
if (skills != null)
{
skills.BeginAction(u, moveOnly, o, dir, slingDist); // 예약 액티브 장착 + 발동 연출 · 돌진 거리 비율(만인지적·강동의 호걸·적토마)
if (skills.Ctx != null && CrossesSlowTerrain(u, origin, aimed)) skills.Ctx.HitTerrain = true; // 이동 감소 지형(늪·고지)을 지남(맹획 남만왕 · 조인 철벽 감속은 제외 · Fable 검토 C6)
}
// 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084)
if (u.View != null) u.View.SetCharging(true);
// 정보 연환격(repeat_action) = 같은 거리·방향으로 한 번 더(멈춘 자리에서)
int runs = !moveOnly && SkillMods.Has(u, SkillKeys.RepeatAction) ? 2 : 1;
for (int k = 0; k < runs && u.IsAlive; k++)
{
if (k > 0)
{
yield return Fx.Wait(0.15f);
origin = u.Position;
target = battle.Board.ClampInside(origin + delta, u.Radius);
Vector2 d2 = target - origin;
dist = d2.magnitude;
if (dist < 0.05f) break;
dir = d2 / dist;
DashBegin(u, dir);
o.Notes.Add("한 번 더");
}
yield return RoutineFor(u, origin, dir, dist, target, moveOnly, o);
}
yield return GimmickAfter(u, moveOnly, o, !(UnitTypeInfo.IsRanged(u.Type) && o.Fired)); // 빙판 · 지대 스윕 · 강(PD #1097)
if (u.View != null) u.View.SetCharging(false);
if (skills != null) yield return skills.EndAction(u, o); // 출혈·중독 · 지대 · 재행동 · 예약 소모 · 쿨
}
/// 병종 루틴 선택 — 장착 스킬이 바꾸는 것: 동탁 폭정(난동) · 손견·안량 관통(보·창도 기병처럼) · 축융 비도(창병이 궁병처럼 사격).
IEnumerator RoutineFor(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
if (!moveOnly && SkillMods.Has(u, SkillKeys.Rampage))
{
yield return RampageRoutine(u, origin, dir, o);
yield break;
}
if (!moveOnly && (SkillMods.Has(u, SkillKeys.ShotAsArcher) || HasSpecialShot(u))) // 축융 비도 · 주태 검기(보병의 특수 사격 · Fable 검토 A1)
{
yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o);
yield break;
}
if (!moveOnly && !UnitTypeInfo.IsRanged(u.Type) && u.Type != UnitType.Cavalry && SkillMods.Has(u, SkillKeys.PierceThrough))
{
yield return CavalryRoutine(u, origin, dir, dist, target, moveOnly, o);
yield break;
}
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;
}
}
// ---------- 미리보기(드라이런) ----------
/// 조작 중 표시용: 이 슬링샷으로 행동 유닛이 직접 공격하게 될 적 목록(밀린 아군의 공격은 예측하지 않음).
public List PredictTargets(Unit u, Vector2 target, bool moveOnly)
{
var result = new List();
if (moveOnly) return result;
Vector2 origin = u.Position;
target = ReachPoint(u, target);
Vector2 delta = target - origin;
float dist = delta.magnitude;
if (dist < 0.001f) return result;
Vector2 dir = delta / dist;
float hitDist;
var skills = SkillSystem.Current;
bool prev = skills != null && skills.PreviewBegin(u);
GimmickLayer.PreviewMode = true; // 아군 조준 = 숨은 함정을 모른다(PD #1097)
try
{
switch (u.Type)
{
case UnitType.Infantry:
case UnitType.Spear:
{
if (SkillMods.Has(u, SkillKeys.PierceThrough) || SkillMods.Has(u, SkillKeys.Rampage)) goto case UnitType.Cavalry;
var first = SkillMods.Has(u, SkillKeys.PassAllies) ? FirstEnemyBeforeWall(u, origin, dir, dist, out hitDist) : 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);
if (!PierceAllies(u)) travel = StopBeforeUnclearedAllies(u, origin, dir, travel);
var dists = new List();
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;
}
}
}
finally
{
GimmickLayer.PreviewMode = false;
if (skills != null) skills.PreviewEnd(u, prev);
}
return result;
}
/// 조작 중 표시용: 궁병·책사의 사격 계획(화살 경로선 · 첫 피격 유닛 · 책사 광역). 원거리 병종이 아니거나 이동만이면 null.
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;
var skills = SkillSystem.Current;
bool prev = skills != null && skills.PreviewBegin(u);
GimmickLayer.PreviewMode = true;
try
{
return PlanShot(u, u.Position, delta / dist, target);
}
finally
{
GimmickLayer.PreviewMode = false;
if (skills != null) skills.PreviewEnd(u, prev);
}
}
// ---------- 공통 질의 ----------
/// 경로 위 첫 유닛. 벽이 그보다 먼저 있으면 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;
}
/// 조조 난세의 간웅(pass_allies): 아군은 통과 · 경로 위 첫 적(벽이 먼저면 null).
Unit FirstEnemyBeforeWall(Unit u, Vector2 origin, Vector2 dir, float dist, out float hitDist)
{
Unit first = Combat.FirstEnemyOnPath(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;
}
/// 관통이 아군도 지나는가(손견 강동의 호랑이 · 손책 소패왕 = pierce_through p1 1).
static bool PierceAllies(Unit u)
{
foreach (var h in SkillMods.Get(u, SkillKeys.PierceThrough))
if (h.E.P >= 0.5f) return true;
return false;
}
/// 아군 통과 이동(유닛 무시 · 벽·장애물만) → 끝이 유닛과 겹치면 겹치지 않는 곳까지 되짚음.
IEnumerator MoveThrough(Unit u, Vector2 origin, Vector2 dir, float dist, ActionOutcome o)
{
float free = Combat.FreeTravel(u, origin, dir, dist, true, false);
Vector2 end = BackOffFromUnits(u, origin, dir, free, true);
yield return Move(u, end, o);
o.MovedOnly = true;
}
// ---------- 보병: 알까기 충돌 ----------
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;
bool passAllies = SkillMods.Has(u, SkillKeys.PassAllies); // 조조 난세의 간웅 = 아군 통과
Unit first = passAllies ? FirstEnemyBeforeWall(u, origin, dir, dist, out hitDist) : FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
float gDist;
Vector2 gNormal;
var gim = BlockAhead(u, origin, dir, dist, first != null ? hitDist : dist, out gDist, out gNormal);
if (gim != null)
{
yield return HitGimmick(u, gim, origin, dir, dist, gDist, gNormal, o); // 장애물 기믹이 먼저(PD #1097)
yield break;
}
if (first == null)
{
if (passAllies) yield return MoveThrough(u, origin, dir, dist, o);
else yield return MoveFree(u, origin, dir, dist, o);
yield break;
}
Vector2 contact = origin + dir * hitDist;
yield return MoveStraight(u, contact, o); // 등속으로 가서 부딪힌 순간 바로 반응(PD #1081)
DashEnd(u); // 슬링샷 직진 끝 = 돌진 이펙트 소멸(튕김은 반응 · PD 2026-11-10)
float remaining = Mathf.Max(0f, dist - hitDist);
bool enemy = first.Faction != u.Faction;
int impact = 0;
if (enemy)
{
impact = ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격을 즉시 주고받는다(연출 대기 없음)
if (!u.IsAlive) yield break;
}
else
{
o.MovedOnly = true;
}
Vector2 n = first.Position - contact;
n = n.sqrMagnitude > 1e-6f ? n.normalized : dir;
if (SkillMods.Has(u, SkillKeys.SwapOnHit) && first.IsAlive)
{
yield return SwapWith(u, first, origin, o); // 위연 자오곡 기책: 명중 대상 ↔ 출발 위치(튕김 없음)
}
else if (enemy && first.IsAlive && SkillMods.Has(u, SkillKeys.AimPush))
{
// 조조 난세의 간웅: 자신은 밀리지 않고 탭한 방향으로 명중 적을 밀침(적·자동 = 슬링 방향)
Vector2 aimDir = n;
var sk = SkillSystem.Current;
if (sk != null)
yield return sk.WaitTap(u, SkillSystem.TapKind.Aim, first.Position, 0f, o.SkillUsed != null ? o.SkillUsed.NameKo + " — 밀칠 방향을 탭" : "밀칠 방향을 탭", first.Position + n,
w => { var d = w - first.Position; if (d.sqrMagnitude > 1e-4f) aimDir = d.normalized; });
float force = Mathf.Max(remaining, cfg.minDragDistance) * cfg.collisionPushRatio * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true);
if (first.IsAlive) yield return Push(first, aimDir, force, u, o);
}
else
{
// 알까기: 부딪힌 순간 양쪽이 동시에 튕긴다 — 상대 = 접촉 방향 × 남은 힘 × collisionPushRatio · 자신 = 반대 방향 × 남은 힘 × collisionSelfBounceRatio
yield return Bounce(u, first, n, remaining, o, enemy ? impact : 0, enemy); // 적과 싸운 뒤 튕김 = 연쇄 1회(PD #1098)
}
if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o, u);
if (!enemy) PushContinue(u, remaining); // 고순: 아군을 밀친 뒤 남은 이동력으로 재행동
// 목표점 명중 추가 이동 = 제거(PD 2026-11-10 #1112 결정 ⑱ · 보병 특징 = 알까기·연쇄 · 재행동은 스킬로만)
}
/// 고순 함진 돌파(push_continue): 아군·지형 오브젝트를 밀친 뒤 남은 이동 거리만큼 재행동 1회(슬링 상한 = 남은 거리).
void PushContinue(Unit u, float remaining)
{
var sk = SkillSystem.Current;
var s = sk != null ? sk.Of(u) : null;
if (s == null || !u.IsAlive || remaining < cfg.minDragDistance || !SkillMods.Has(u, SkillKeys.PushContinue, true)) return;
s.ReactCredits++;
s.ReactLimit = remaining;
}
/// 위연: 명중 대상은 행동 유닛의 출발 위치로 · 행동 유닛은 대상 자리로(동시에).
IEnumerator SwapWith(Unit u, Unit t, Vector2 origin, ActionOutcome o)
{
Vector2 tFrom = t.Position;
Vector2 tTo = battle.Board.ClampInside(origin, t.Radius);
Vector2 uTo = battle.Board.ClampInside(tFrom, u.Radius);
bool done = false;
Fx.Run(Track(Fx.MoveAlong(t.transform, tFrom, tTo, cfg.moveSpeed * 1.5f, null), () => done = true));
Face(u, uTo - u.Position);
yield return Fx.MoveAlong(u.transform, u.Position, uTo, cfg.moveSpeed * 1.5f, null);
while (!done) yield return null;
t.Position = tTo;
u.Position = uTo;
o.Path.Add(uTo);
o.Displaced.Add(new Displacement { Unit = t, From = tFrom, To = tTo });
o.Notes.Add(t.DisplayName + " 자리 교환");
battle.NotifyChanged();
}
// ---------- 창병: 밀치기 + 반작용(PD 2026-11-06 #1098 · 밀며 함께 전진 폐기) ----------
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);
float gDist;
Vector2 gNormal;
var gim = BlockAhead(u, origin, dir, dist, first != null ? hitDist : dist, out gDist, out gNormal);
if (gim != null)
{
yield return HitGimmick(u, gim, origin, dir, dist, gDist, gNormal, o); // 장애물 기믹이 먼저(PD #1097)
yield break;
}
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)
DashEnd(u); // 슬링샷 직진 끝 = 돌진 이펙트 소멸(밀기·반작용은 반응 · PD 2026-11-10)
float remaining = Mathf.Max(0f, dist - hitDist);
bool enemy = first.Faction != u.Faction;
if (enemy)
{
ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격 즉시
if (!u.IsAlive) yield break;
}
else
{
o.MovedOnly = true;
}
// 밀치기 거리 = 남은 거리 × spearPushRatio(보병보다 크게 · PD 2026-11-10) × 철척사모(push_mult) × 대상 거구(knockback_taken_pct) ·
// 상대만 밀리고 창병은 그 × spearRecoilRatio 만큼 반대로(반작용 · 보병 자기 튕김보다 작게 · PD #1098 · 2026-11-10)
float push = remaining * cfg.spearPushRatio * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true);
if (first.IsAlive && push > 0.01f)
{
bool pushedDone = false;
Fx.Run(Track(Push(first, dir, push, u, o), () => pushedDone = true));
float recoil = Reach(u, u.Position, -dir, push * cfg.spearRecoilRatio * (enemy ? SkillMods.AxeBounce(first) : 1f)); // 서황 대부 = 창병 반작용도 ×(Fable 검토 C5)
if (recoil > 0.01f && u.IsAlive)
{
// 반작용: 벽·유닛·장애물에 닿으면 정지 · 피해 없음 · 보는 방향은 유지(밀친 쪽을 본 채 물러남)
float back = Combat.FreeTravel(u, u.Position, -dir, recoil, true, true, first);
NoteForced(u, u.Position, u.Position - dir * back);
if (back > 0.01f)
{
Vector2 start = u.Position;
Vector2 end = battle.Board.ClampInside(start - dir * back, u.Radius);
yield return Fx.MoveAlong(u.transform, start, end, cfg.moveSpeed, null);
u.Position = end;
o.Path.Add(end);
}
}
while (!pushedDone) yield return null;
battle.NotifyChanged();
if (!enemy) yield return PushedAllyAttack(first, o, u);
}
// 목표점 명중 → 대기(견제) = 제거(PD 2026-11-10) · 새 스킬 기획(2026-11-10 #1112)에도 견제 없음
}
// ---------- 기병: 관통(제자리 복귀 없음 · PD 2026-11-10) ----------
///
/// 관통 돌격(기병 · 스킬로 관통하는 보병·창병): 유닛 무시(아군은 지나칠 수 없으면 직전 정지 · 손견·손책 = 아군도 통과) · 경로 위 적 피해·옆 밀기 ·
/// 손책 소패왕 = 전장 벽에서 핀볼처럼 튕겨 남은 거리 소진 · 반격 · 장애물 → 장료 요래(끌어오기) → 복귀(조운·손책·여포·문추·공손찬·순우경 · 하후돈 = 탭 지점·쓸어 감).
///
/// 빙판 미끄러짐 재실행(앞서 관통한 적 제외 · 끌어오기·복귀 없음) · null = 일반 행동.
IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, List slideExclude = null)
{
bool slide = slideExclude != null;
bool passAll = !moveOnly && PierceAllies(u);
bool pinball = !moveOnly && !slide && SkillMods.Has(u, SkillKeys.WallBounce, true);
var hit = new List();
Vector2 start = origin;
Vector2 d = dir;
float left = dist;
GimmickInstance gim = null;
Vector2 gNormal = -dir;
float gimLeft = 0f;
Face(u, dir);
for (int seg = 0; seg < 6 && left > 0.05f && u.IsAlive; seg++)
{
float travel = Combat.FreeTravel(u, start, d, left, false, false); // 유닛은 무시 · 벽·장애물 기믹만
if (!passAll) travel = StopBeforeUnclearedAllies(u, start, d, travel); // 지나칠 수 없는 아군 직전에서 멈춤
float gDist;
gim = BlockAhead(u, start, d, left, travel + 0.05f, out gDist, out gNormal); // 장애물 = 관통 불가 → 거기서 정지(PD #1097)
var dists = new List();
var enemies = moveOnly ? new List() : Combat.EnemiesOnPath(u, start, d, travel, dists);
if (slide)
for (int i = enemies.Count - 1; i >= 0; i--)
if (slideExclude.Contains(enemies[i]))
{
enemies.RemoveAt(i);
dists.RemoveAt(i);
}
Vector2 wallN;
bool bounce = false;
if (pinball && gim == null && travel < left - 0.1f)
{
Combat.BoardWallTravel(u, start, d, left, out wallN);
if (wallN.sqrMagnitude > 0.5f)
{
bounce = true;
gNormal = wallN;
}
}
Vector2 end = bounce ? start + d * travel : BackOffFromUnits(u, start, d, travel, passAll);
if (enemies.Count > 0 && u.View != null) u.View.PlayAttack();
int idx = 0;
var segDir = d;
yield return Fx.MoveAlong(u.transform, start, end, cfg.moveSpeed, travelled =>
{
while (idx < enemies.Count && travelled >= dists[idx] - 0.01f)
{
PierceHit(u, enemies[idx], segDir, o, 1f);
hit.Add(enemies[idx]);
idx++;
}
});
u.Position = end;
o.Path.Add(end);
for (; idx < enemies.Count; idx++)
{
if (dists[idx] > travel + 0.01f) continue;
PierceHit(u, enemies[idx], segDir, o, 1f);
hit.Add(enemies[idx]);
}
gimLeft = Mathf.Max(0f, left - travel);
left -= travel;
if (!bounce) break;
d = Vector2.Reflect(d, gNormal).normalized; // 소패왕: 전장 가장자리에서 튕겨 남은 거리를 마저 달림
start = end;
o.Notes.Add("튕김");
DashBegin(u, d);
Face(u, d);
}
DashEnd(u); // 관통 돌격 끝 = 돌진 이펙트 소멸(PD 2026-11-10)
if (hit.Count == 0) o.MovedOnly = true;
else yield return Fx.Wait(0.15f);
// 살아남은 근접 적의 반격(받는 반격 % · −100 % = 없음 → Combat.CounterDamage 가 0)
var countered = new HashSet();
foreach (var e in hit)
{
if (!u.IsAlive) break;
if (!e.IsAlive || !UnitTypeInfo.IsMelee(e.Type) || !countered.Add(e)) continue;
int c = Combat.CounterDamage(cfg, e, u, rng, true);
if (c <= 0) continue;
c = SkillSystem.Deal(u, c, e, HitKind.Counter);
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 (gim != null && u.IsAlive)
{
var sk0 = SkillSystem.Current;
if (sk0 != null && sk0.Ctx != null && sk0.Ctx.Actor == u) sk0.Ctx.HitTerrain = true;
yield return CavalryHitsGimmick(u, gim, gimLeft, gNormal, o);
yield break;
}
if (!u.IsAlive || moveOnly || slide) yield break;
if (hit.Count > 0 && SkillMods.Has(u, SkillKeys.DragBack, true)) yield return DragBack(u, origin, dir, hit, o); // 장료 요래
// 복귀: 항상(return_always) · 피해를 입혔을 때(return_on_hit) · 하후돈 맹장 = 거리와 무관하게 탭한 지점으로
var ra = SkillMods.First(u, SkillKeys.ReturnAlways, true);
var rh = hit.Count > 0 ? SkillMods.First(u, SkillKeys.ReturnOnHit, true) : null;
bool aim = SkillMods.Has(u, SkillKeys.ReturnAim, true);
if (ra == null && rh == null && !aim) yield break;
bool dmgBack = (ra != null && ra.P >= 0.5f) || (rh != null && rh.P >= 0.5f);
bool sweep = (ra != null && ra.Get(1) >= 0.5f) || (rh != null && rh.Get(1) >= 0.5f);
Vector2 dest = origin;
if (aim)
{
var sk = SkillSystem.Current;
if (sk != null)
yield return sk.WaitTap(u, SkillSystem.TapKind.Return, u.Position, SkillMods.SlingMax(u), (o.SkillUsed != null ? o.SkillUsed.NameKo : "복귀") + " — 복귀할 곳을 탭", origin, w => dest = w);
}
yield return ReturnRoutine(u, dest, dmgBack, sweep, o);
}
/// 복귀 이동: 유닛 무시 · 복귀 중 피해(조운·손책 소패왕) · 쓸어 감(하후돈 = 경로 위 유닛을 끝까지 함께 밀고 감 · 아군 포함).
IEnumerator ReturnRoutine(Unit u, Vector2 dest, bool dmgBack, bool sweep, ActionOutcome o)
{
yield return Fx.Wait(0.1f);
dest = battle.Board.ClampInside(dest, u.Radius);
Vector2 from = u.Position;
Vector2 rd = dest - from;
float len = rd.magnitude;
if (len < 0.05f) yield break;
Vector2 rdir = rd / len;
float travel = Combat.FreeTravel(u, from, rdir, len, false, false); // 벽·장애물에서 멈춤
var dists = new List();
var onPath = Combat.UnitsOnPath(u, from, rdir, travel, dists);
Vector2 end = sweep ? from + rdir * travel : BackOffFromUnits(u, from, rdir, travel, true);
DashBegin(u, rdir);
Face(u, rdir);
int idx = 0;
yield return Fx.MoveAlong(u.transform, from, end, cfg.moveSpeed, t =>
{
while (idx < onPath.Count && t >= dists[idx] - 0.01f)
{
var e = onPath[idx++];
if (dmgBack && e.Faction != u.Faction) PierceHit(u, e, rdir, o, 1f);
}
});
u.Position = end;
o.Path.Add(end);
o.Returned = true;
DashEnd(u);
if (sweep)
{
// 쓸어 감: 경로 위 유닛을 도착점 앞(복귀 방향)으로 줄지어 민다
float off = u.Radius;
foreach (var e in onPath)
{
if (e == null || !e.IsAlive) continue;
Vector2 eFrom = e.Position;
off += e.Radius + 0.05f;
Vector2 eTo = battle.Board.ClampInside(end + rdir * off, e.Radius);
off += e.Radius;
Fx.Run(Fx.MoveAlong(e.transform, eFrom, eTo, cfg.moveSpeed, null));
e.Position = eTo;
o.Displaced.Add(new Displacement { Unit = e, From = eFrom, To = eTo });
if (!o.Pushed.Contains(e)) o.Pushed.Add(e);
}
if (onPath.Count > 0) o.Notes.Add("복귀하며 쓸어 감");
}
battle.NotifyChanged();
}
/// 장료 요래: 관통으로 피해를 입힌 적을 출발 위치로 끌고 옴(가까운 적부터 출발점 · 뒤는 진행 방향으로 줄지음).
IEnumerator DragBack(Unit u, Vector2 origin, Vector2 dir, List hit, ActionOutcome o)
{
var list = new List();
foreach (var e in hit)
if (e != null && e.IsAlive && !list.Contains(e)) list.Add(e);
list.Sort((a, b) => Vector2.Distance(origin, a.Position).CompareTo(Vector2.Distance(origin, b.Position)));
float off = 0f;
bool any = false;
foreach (var e in list)
{
Vector2 from = e.Position;
Vector2 to = battle.Board.ClampInside(origin + dir * off, e.Radius);
off += e.Radius * 2f + 0.1f;
Fx.Run(Fx.MoveAlong(e.transform, from, to, cfg.moveSpeed * 1.2f, null));
e.Position = to;
o.Displaced.Add(new Displacement { Unit = e, From = from, To = to });
any = true;
}
if (any)
{
o.Notes.Add("끌고 옴");
yield return Fx.Wait(0.3f);
}
battle.NotifyChanged();
}
/// 동탁 폭정: p1 초 동안 이동 속도로 직진 · 전장 가장자리에서 핀볼처럼 튕김 · 닿은 유닛(아군 포함)을 옆으로 p2 강하게 밀침 · 적 = 돌진 피해(반격 받음) · 자신은 튕기지 않음.
IEnumerator RampageRoutine(Unit u, Vector2 origin, Vector2 dir, ActionOutcome o)
{
var e0 = SkillMods.First(u, SkillKeys.Rampage, true);
float total = cfg.moveSpeed * (e0 != null ? e0.P : 1f);
float force = e0 != null ? e0.Get(1, 1f) : 1f;
var touched = new HashSet();
Vector2 start = origin;
Vector2 d = dir;
float left = total;
Face(u, d);
if (u.View != null) u.View.PlayAttack();
for (int seg = 0; seg < 8 && left > 0.05f && u.IsAlive; seg++)
{
Vector2 n;
float travel = Combat.BoardWallTravel(u, start, d, left, out n);
float gDist;
Vector2 gN;
var g = BlockAhead(u, start, d, left, travel + 0.05f, out gDist, out gN);
if (g != null)
{
travel = Mathf.Max(0f, gDist - Combat.Epsilon);
n = gN;
}
var dists = new List();
var units = Combat.UnitsOnPath(u, start, d, travel, dists);
Vector2 end = start + d * travel;
int idx = 0;
var segDir = d;
yield return Fx.MoveAlong(u.transform, start, end, cfg.moveSpeed, t =>
{
while (idx < units.Count && t >= dists[idx] - 0.01f)
{
var x = units[idx++];
if (!x.IsAlive || !touched.Add(x)) continue;
RampageHit(u, x, segDir, force, o);
}
});
u.Position = end;
o.Path.Add(end);
left -= travel;
if (n.sqrMagnitude < 0.5f || left <= 0.05f) break;
if (g != null)
{
var sk = SkillSystem.Current;
if (sk != null && sk.Ctx != null) sk.Ctx.HitTerrain = true;
}
d = Vector2.Reflect(d, n).normalized;
start = end;
o.Notes.Add("튕김");
DashBegin(u, d);
Face(u, d);
}
DashEnd(u);
u.Position = BackOffFromUnits(u, origin, d, 0f, true, u.Position);
if (touched.Count == 0) o.MovedOnly = true;
battle.NotifyChanged();
}
void RampageHit(Unit u, Unit x, Vector2 dir, float force, ActionOutcome o)
{
if (x.Faction != u.Faction)
{
ImpactExchange(u, x, o);
if (!u.IsAlive) return;
}
if (!x.IsAlive) return;
Vector2 side = new Vector2(-dir.y, dir.x);
float sign = Vector2.Dot(x.Position - u.Position, side) >= 0f ? 1f : -1f;
Vector2 from = x.Position;
Vector2 to = KnockTo(x, side * sign, force * SkillMods.KnockbackTakenMult(x, true));
Fx.Run(Fx.MoveAlong(x.transform, from, to, cfg.moveSpeed, null));
x.Position = to;
o.Displaced.Add(new Displacement { Unit = x, From = from, To = to });
if (!o.Pushed.Contains(x)) o.Pushed.Add(x);
}
void PierceHit(Unit cav, Unit e, Vector2 dir, ActionOutcome o, float dmgMult)
{
if (!e.IsAlive) return;
int dmg = SkillSystem.Deal(e, Combat.Damage(cfg, cav, e, false, rng, true, dmgMult), cav, HitKind.Attack);
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)
{
float kb = cfg.cavalryKnockback * SkillMods.KnockbackTakenMult(e, true);
Vector2 from = e.Position;
Vector2 to = KnockTo(e, side * sign, kb); // 늪 0 · 빙판 × · 장애물·함정에서 멈춤 · 다리 → 강(PD #1097)
Fx.Run(Fx.MoveAlong(e.transform, e.Position, to, cfg.moveSpeed * 0.6f, null));
o.Displaced.Add(new Displacement { Unit = e, From = from, To = to });
}
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;
}
/// 종착점이 적(allies = 아군도)과 겹치면 경로를 되짚어 겹치지 않는 지점까지 물러선다 · at = 그 점에서 dir 반대로 되짚음.
Vector2 BackOffFromUnits(Unit u, Vector2 origin, Vector2 dir, float travel, bool allies, Vector2? at = null)
{
Vector2 p0 = at ?? origin + dir * travel;
float maxBack = at.HasValue ? 4f : travel;
for (float b = 0f; b <= maxBack + 1e-3f; b += 0.1f)
{
Vector2 p = p0 - dir * b;
bool overlap = false;
foreach (var e in battle.Units)
{
if (e == null || e == u || !e.IsAlive || (!allies && e.Faction == u.Faction)) continue;
if (Vector2.Distance(p, e.Position) < u.Radius + e.Radius - 0.01f)
{
overlap = true;
break;
}
}
if (!overlap) return p;
}
return at ?? origin;
}
// ---------- 궁병 · 책사: 투사체 사격 (PD 2026-11-05 #1087 ⑤⑥) ----------
/// 궁병·책사 사격 계획 — 조준 미리보기(PreviewShot)와 실제 행동(ArcherRoutine)이 같은 계산을 쓴다.
public class ShotPlan
{
/// 복도 탐색으로 고른 적(조준 방향 사거리 + 접근 허용 안의 첫 적) · null = 쏘지 않고 목표점까지 이동.
public Unit Target;
/// 사격 전에 다가가는 거리(사거리 안 = 0).
public float Approach;
/// 사거리에 들어와 쏜다(false = 아군·벽에 막혀 사거리에 못 들어감 → 이동만).
public bool Fires;
public Vector2 From;
/// 투사체가 멈춘 지점(첫 피격 유닛에 닿은 곳 · 빗나가면 비행 끝).
public Vector2 To;
/// 투사체가 처음 맞히는 유닛(아군 포함 · 관통 없음) = 책사 광역의 중심.
public Unit Hit;
/// 첫 피격 = 타겟이고 최종 목표점 명중 → 크리티컬.
public bool Crit;
/// 책사: 중심 주변 광역 대상(아군 포함 · 중심·쏜 책사 제외 · 가까운 순).
public readonly List Splash = new List();
/// 책사 광역 반경(화공 배율 반영).
public float SplashRadius;
/// 백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).
public float Teleport;
public Vector2 Origin;
/// 투사체를 막거나 맞은 기믹(덤불·바위·성문·성벽 = 막힘 · 폭약 수레 = 파괴 · PD #1097) · 유닛을 맞혔으면 null.
public GimmickInstance HitGimmick;
/// 책사 광역이 터진다(중심 = 첫 피격 유닛 · 유닛 없이 덤불·수레에 막혔으면 그 자리).
public bool HasSplash;
public Vector2 SplashCenter;
/// 책사 광역에 들어 지워질 기믹(덤불 제거 · 수레 파괴).
public readonly List ClearGimmicks = new List();
}
ShotPlan PlanShot(Unit u, Vector2 origin, Vector2 dir, Vector2 target)
{
var p = new ShotPlan { From = origin, To = origin, Origin = origin, SplashRadius = SkillMods.SplashRadius(u, cfg) };
float range = SkillMods.Range(u);
// 조준 방향으로 사거리 + 접근 허용 거리 안의 첫 적(타겟 선정 = #1080 그대로). hitDist = 그 적 가장자리에 닿기까지의 이동 거리(= 가장자리 간격).
float hitDist;
p.Target = Combat.FirstEnemyOnPath(u, origin, dir, range + u.ArcherMoveAllow, out hitDist);
if (p.Target == null) return p;
// 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한 · 늪·고지 이동 비용 반영 PD #1097)
float need = Mathf.Clamp(hitDist - range, 0f, MoveReach(u, origin, dir, 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 > range + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감
Aim(u, p, dir, target, range);
return p;
}
///
/// 사격 위치(p.From)에서 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과) ·
/// 막는 기믹(덤불·바위·성문·성벽) · 파괴되는 기믹(폭약 수레) · 안개 안 유닛 제외 · 책사 = 광역 계획(AimSplash).
///
void Aim(Unit u, ShotPlan p, Vector2 dir, Vector2 target, float range)
{
p.Fires = true;
Vector2 aim = p.Target.Position - p.From;
Vector2 adir = aim.sqrMagnitude > 1e-6f ? aim.normalized : dir;
float travel;
bool passObstacles;
Faction? pass = ArrowPass(u, out passObstacles);
Combat.ArrowIgnoreGimmicks = passObstacles;
try
{
p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, range + u.Radius, cfg.arrowRadius, pass, out travel, out p.HitGimmick);
}
finally
{
Combat.ArrowIgnoreGimmicks = false;
}
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 (u.Type == UnitType.Strategist && !SkillMods.Has(u, SkillKeys.ShotSingle)) AimSplash(u, p); // 장로 = 직선 투사체(광역 없음)
}
/// 화살이 지나는 진영(황충 노익장·손권 강동의 주인 = 아군 통과 · p2 1 = 장애물도 통과).
static Faction? ArrowPass(Unit u, out bool obstacles)
{
obstacles = false;
bool any = false;
foreach (var h in SkillMods.Get(u, SkillKeys.ArrowPassAllies))
{
any = true;
if (h.E.Get(1) >= 0.5f) obstacles = true;
}
return any ? u.Faction : (Faction?)null;
}
IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
if (!moveOnly && HasSpecialShot(u))
{
yield return SpecialShot(u, origin, dir, target, o); // 장착 스킬 특수 사격(ActionResolver.Skills.cs)
yield break;
}
var p = moveOnly ? null : PlanShot(u, origin, dir, target);
if (p != null && p.Teleport > 0.01f)
{
yield return Move(u, p.Origin + dir * p.Teleport, o); // 백기습영: 목표점까지 이동 후 사격
if (p.Target == null) o.MovedOnly = true;
}
if (p == null || p.Target == null)
{
// 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지) · 장애물 = 튕김 · 늪·고지 = 이동 비용(PD #1097)
if (p == null || p.Teleport <= 0.01f) yield return MoveOrHit(u, origin, dir, MoveReach(u, origin, dir, dist), o);
yield break;
}
if (p.Approach > 0.01f)
{
yield return Move(u, p.From, o);
if (p.Fires) yield return SlideBeforeShot(u, dir, p, target, o); // 빙판 = 사격 전 미끄러짐 · 사거리 이탈 = 취소(PD #1097 §9 ③)
}
if (!p.Fires)
{
o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감
yield break;
}
Face(u, p.To - p.From); // 제자리 사격도 쏘는 방향을 본다
yield return Shoot(u, p, o);
}
///
/// 투사체 1발: 첫 피격 유닛에 사격 피해(아군이면 오사 · 상성 적용 · 반격 없음 · 명중 보상 없음 · 밀지 않으므로 규칙 ⑤ 없음)
/// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음 · 화공·팔문 간파 반영).
///
IEnumerator Shoot(Unit u, ShotPlan p, ActionOutcome o)
{
bool strategist = u.Type == UnitType.Strategist;
if (strategist) BattleEvents.Raise(BattleEvents.StrategistShots, u); // 보물 조건 효과(PD #1093 C)
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 && p.HitGimmick == null)
{
yield return Fx.Wait(0.1f); // 빗나감(벽 · 비행 끝)
yield break;
}
Vector2 center = p.HasSplash ? p.SplashCenter : p.To;
float healAll = SkillMods.Sum(u, SkillKeys.SplashHeal, true); // 손건 화친 = 피해 없이 회복만(PD #1112 결정 ⑧)
if (p.Hit != null)
{
if (healAll > 0f)
{
var hs = SkillSystem.Current;
if (hs != null) hs.Heal(p.Hit, Mathf.RoundToInt(SkillSystem.AtkOf(u) * healAll), u);
}
else if (p.Hit.Faction == u.Faction && AllyHitSpecial(u, p.Hit, p.From, o))
{
// 아군 명중 = 회복·버프(순욱·소교·간옹·미축) · 감녕 = 아군 곁으로 이동 후 재공격 — 피해 없음
}
else ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o);
}
else yield return ArrowHitsGimmick(u, p, o); // 덤불·성문 막힘 · 수레 파괴(PD #1097)
yield return ExtraArrows(u, p, o); // 하후연 삼연시(멀티샷) · 감녕 도탄
if (strategist && p.HasSplash)
{
yield return SplashGimmicks(u, p, o); // 덤불 제거 · 수레 파괴 · 화공 불(PD #1097)
Fx.Burst(center, p.SplashRadius, Fx.SplashColor);
if (healAll > 0f)
{
// 손건 화친: 광역 = 피해 대신 회복(적 포함 · 명중 대상은 위에서 회복)
int amt = Mathf.RoundToInt(SkillSystem.AtkOf(u) * healAll);
var sys = SkillSystem.Current;
foreach (var s in p.Splash)
if (s.IsAlive && sys != null) sys.Heal(s, amt, u);
}
else
{
float splashRatio = SkillMods.SplashRatio(u, cfg, true);
float allyRatio = SkillMods.SplashAllyRatio(u, cfg, true);
int splashTotal = 0;
foreach (var s in p.Splash)
{
if (!s.IsAlive) continue;
if (s.Faction == u.Faction && AllyHitSpecial(u, s, p.From, o, true)) continue; // 보급·경국지색 = 광역에 든 아군도 회복
float ratio = splashRatio * (s.Faction == u.Faction ? allyRatio : 1f);
int dmg = Combat.SplashDamage(cfg, u, s, ratio, rng);
if (dmg <= 0) continue;
int got = ShotDamage(u, s, dmg, false, o);
if (s.Faction != u.Faction) splashTotal += got;
}
SplashHealLowest(u, splashTotal); // 이각 약탈
}
}
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
}
/// 사격 피해 적용 + 집계(상대 = Attacked · 같은 편 = FriendlyHit 오사). 반환 = 실제 피해.
int ShotDamage(Unit a, Unit d, int dmg, bool crit, ActionOutcome o, HitKind kind = HitKind.Attack)
{
crit |= Combat.LastCrit;
dmg = SkillSystem.Deal(d, dmg, a, kind);
if (d.Faction == a.Faction)
{
o.FriendlyHit.Add(d);
o.FriendlyDamage += dmg;
Fx.Popup(d.Position, dmg + " 오사", Fx.FriendlyFireColor);
return dmg;
}
o.Attacked.Add(d);
o.DamageDealt += dmg;
o.Crit |= crit;
if (crit && a.Type == UnitType.Archer) BattleEvents.Raise(BattleEvents.ArcherCrits, a); // 보물 조건 효과(PD #1093 C)
Fx.Popup(d.Position, crit ? dmg + "!" : dmg.ToString(), crit ? new Color(1f, 0.7f, 0.2f) : Color.white);
return dmg;
}
// ---------- 공통 ----------
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;
}
/// 슬링샷 직진 구간 끝 · 새 이동 구간 시작 = 돌진 이펙트(skill_vfx.csv travel) 소멸 · 다시 재생(PD 2026-11-10).
static void DashEnd(Unit u)
{
var s = SkillSystem.Current;
if (s != null) s.DashEnd(u);
}
static void DashBegin(Unit u, Vector2 dir)
{
var s = SkillSystem.Current;
if (s != null) s.DashBegin(u, dir);
}
/// 보는 방향 갱신(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);
}
/// 부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계. 반환 = 준 피해.
int ImpactExchange(Unit a, Unit d, ActionOutcome o)
{
if (a.View != null) a.View.PlayAttack();
int raw = Combat.Damage(cfg, a, d, false, rng, true);
bool crit = Combat.LastCrit;
int dmg = SkillSystem.Deal(d, raw, a, HitKind.Attack);
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);
if (d.IsAlive && a.IsAlive)
{
int c = Combat.CounterDamage(cfg, d, a, rng, true); // 부딪혀 밀리는 쪽의 반격(받는 반격 % · 반격 피해 %)
if (c > 0)
{
if (d.View != null) d.View.PlayAttack();
c = SkillSystem.Deal(a, c, d, HitKind.Counter);
o.CounterTaken += c;
Fx.Popup(a.Position, c.ToString(), new Color(1f, 0.45f, 0.45f));
}
}
battle.NotifyChanged();
return dmg;
}
///
/// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구)
/// / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준.
/// chain = 적과 싸운 뒤의 튕김(PD #1098): 튕겨나는 길에 다른 적이 있으면 그 적과 피해·반격 교환 → 그 적 기준으로 다시 튕김(연쇄 1회) · 아군이면 밀기만.
///
IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o, int impactDmg, bool chain = false)
{
bool struckDone = true;
float force = struck != null ? remaining * cfg.collisionPushRatio * SkillMods.PushMult(striker, true) * SkillMods.KnockbackTakenMult(struck, true) : 0f;
if (struck != null && struck.IsAlive && force >= 0.01f)
{
struckDone = false;
Fx.Run(Track(Push(struck, n, force, striker, o), () => struckDone = true));
}
// 자기 튕김 × 자기 배율(허저 ×2 · 전위·맹획 0) × 서황 대부(도끼가 도는 서황을 친 유닛 = ×p3)
float self = Reach(striker, striker.Position, -n, remaining * cfg.collisionSelfBounceRatio * SkillMods.SelfBounceMult(striker, true) * (striker.Faction != (struck != null ? struck.Faction : striker.Faction) ? SkillMods.AxeBounce(struck) : 1f));
if (self >= 0.01f && striker.IsAlive && chain)
{
// 연쇄(PD #1098): 튕겨나는 길의 첫 유닛(벽·장애물보다 앞) — 적 = 피해·반격 교환 후 다시 튕김 · 아군 = 밀기만(밀린 아군은 규칙 ⑤)
float h2;
Unit second = Combat.FirstUnitOnPathExcept(striker, striker.Position, -n, self, struck, out h2);
if (second != null && h2 <= Combat.FreeTravel(striker, striker.Position, -n, self, false, false) + Combat.Epsilon)
{
Vector2 c2 = striker.Position - n * h2;
yield return MoveStraight(striker, c2, o);
float rem2 = Mathf.Max(0f, self - h2);
Vector2 n2 = second.Position - c2;
n2 = n2.sqrMagnitude > 1e-6f ? n2.normalized : -n;
if (second.Faction != striker.Faction)
{
o.Chains++;
o.Notes.Add("연쇄 → " + second.DisplayName);
int imp2 = ImpactExchange(striker, second, o);
if (striker.IsAlive) yield return Bounce(striker, second, n2, rem2, o, imp2, false);
}
else
{
float f2 = rem2 * cfg.collisionPushRatio * SkillMods.KnockbackTakenMult(second, true);
if (f2 >= 0.01f) yield return Push(second, n2, f2, striker, o);
if (second.IsAlive) yield return PushedAllyAttack(second, o, striker);
}
while (!struckDone) yield return null;
yield break;
}
}
if (self >= 0.01f && striker.IsAlive)
{
float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck);
NoteForced(striker, striker.Position, striker.Position - n * back);
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)은 막는 것으로 보지 않는다. onEnd(true) = 다 못 가고 막힘.
IEnumerator Push(Unit target, Vector2 n, float force, Unit pusher, ActionOutcome o, System.Action onEnd = null)
{
if (target == null || !target.IsAlive || force < 0.01f) yield break;
if (pusher != null && pusher.Faction != target.Faction) BattleEvents.Raise(BattleEvents.Pushes, pusher); // 보물 조건 효과(PD #1093 C)
force = Reach(target, target.Position, n, force); // 늪 = 밀리는 거리도 이동 비용(PD #1097)
float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher);
NoteForced(target, target.Position, target.Position + n * slide); // 강제 이동 기록 → 강가·다리에서 밀려 강물에 들면 빠짐
float moved = 0f;
if (slide > 0.01f)
{
Face(target, n); // 밀린 방향을 본다(PD #1082)
Vector2 start = target.Position;
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;
moved = Vector2.Distance(start, end);
o.Displaced.Add(new Displacement { Unit = target, From = start, To = end });
}
o.Pushed.Add(target);
battle.NotifyChanged();
if (onEnd != null) onEnd(moved < force - Combat.Epsilon * 2f);
}
///
/// 규칙 ⑤: 밀린 아군이 상대 유닛과 닿아 있으면 그 자리에서 공격(근접 · 자기 행동 소모 없음) — 기본 1회 ·
/// 민 유닛의 군령(pushed_ally_attack_count = n 회) · 대장군(pushed_ally_attack_on_push = +n 회).
///
IEnumerator PushedAllyAttack(Unit ally, ActionOutcome o, Unit pusher)
{
if (ally == null || !ally.IsAlive) yield break;
if (Combat.TouchingOpponent(ally) == null) yield break;
Unit foe = Combat.TouchingOpponent(ally);
if (!ally.IsAlive || foe == null) yield break;
yield return Fx.Wait(0.1f);
Face(ally, foe.Position - ally.Position);
yield return PushedAttack(ally, foe, o, pusher);
}
///
/// 밀린 유닛의 근접 공격 1회(반격 받음 · Notes 에 기록) — 장합 교변: 민 장합의 공격력 × p1 을 더함 · 장합 자신이 밀려 친 공격 = × p1 ·
/// 고순 함진영: 민 고순의 「명중 기절」이 이 공격에도(간접 명중). 원거리 병종도 근접 패널티를 받는다.
///
IEnumerator PushedAttack(Unit a, Unit d, ActionOutcome o, Unit pusher = null)
{
if (a.View != null) a.View.PlayAttack();
yield return Fx.Lunge(a, d.Position);
float scale = SkillMods.Product(a, SkillKeys.SelfPushedMult, true);
if (pusher != null && pusher != a && pusher.Faction == a.Faction && a.Atk > 0)
{
float add = SkillMods.Sum(pusher, SkillKeys.PushedAllyAtkAdd, true);
if (add > 0f) scale *= 1f + add * SkillSystem.AtkOf(pusher) / Mathf.Max(1f, SkillSystem.AtkOf(a));
}
int dmg = SkillSystem.Deal(d, Combat.Damage(cfg, a, d, false, rng, true, scale), a, HitKind.Attack);
var sys = SkillSystem.Current;
if (sys != null && pusher != null && pusher != a && pusher.Faction == a.Faction) sys.ApplyHitStatuses(pusher, d, false, HitKind.Indirect);
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, false);
if (counter > 0)
{
if (d.View != null) d.View.PlayAttack();
yield return Fx.Lunge(d, a.Position);
counter = SkillSystem.Deal(a, counter, d, HitKind.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 : ""));
}
}
}