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 : "")); } } }