ThreeKingdomsSlingshot/Assets/Scripts/Runtime/ActionResolver.cs

1238 lines
66 KiB
C#
Raw Permalink 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>();
// ---------- 스킬(PD #1093 B) ----------
/// <summary>이 행동에 걸린 예약 액티브(next_action) · null = 없음.</summary>
public SkillDef SkillUsed;
/// <summary>추가 이동 횟수(기본 1 · 인덕 extra_move_count) · 추가 이동에서 공격 가능(extra_move_attack).</summary>
public int BonusMoves = 1;
public bool BonusAttack;
/// <summary>이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정 · 기믹 지대 스윕).</summary>
public List<Displacement> Displaced = new List<Displacement>();
/// <summary>보병 연쇄 교환 수(튕겨나는 도중 다른 적과 부딪힘 · PD 2026-11-06 #1098).</summary>
public int Chains;
}
public struct Displacement
{
public Unit Unit;
public Vector2 From;
public Vector2 To;
}
/// <summary>
/// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (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 동안 연출 없이 즉시.
/// </summary>
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); // 출혈·중독 · 지대 · 재행동 · 예약 소모 · 쿨
}
/// <summary>병종 루틴 선택 — 장착 스킬이 바꾸는 것: 동탁 폭정(난동) · 손견·안량 관통(보·창도 기병처럼) · 축융 비도(창병이 궁병처럼 사격).</summary>
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;
}
}
// ---------- 미리보기(드라이런) ----------
/// <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;
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<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;
}
}
}
finally
{
GimmickLayer.PreviewMode = false;
if (skills != null) skills.PreviewEnd(u, prev);
}
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;
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);
}
}
// ---------- 공통 질의 ----------
/// <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;
}
/// <summary>조조 난세의 간웅(pass_allies): 아군은 통과 · 경로 위 첫 적(벽이 먼저면 null).</summary>
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;
}
/// <summary>관통이 아군도 지나는가(손견 강동의 호랑이 · 손책 소패왕 = pierce_through p1 1).</summary>
static bool PierceAllies(Unit u)
{
foreach (var h in SkillMods.Get(u, SkillKeys.PierceThrough))
if (h.E.P >= 0.5f) return true;
return false;
}
/// <summary>아군 통과 이동(유닛 무시 · 벽·장애물만) → 끝이 유닛과 겹치면 겹치지 않는 곳까지 되짚음.</summary>
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 결정 ⑱ · 보병 특징 = 알까기·연쇄 · 재행동은 스킬로만)
}
/// <summary>고순 함진 돌파(push_continue): 아군·지형 오브젝트를 밀친 뒤 남은 이동 거리만큼 재행동 1회(슬링 상한 = 남은 거리).</summary>
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;
}
/// <summary>위연: 명중 대상은 행동 유닛의 출발 위치로 · 행동 유닛은 대상 자리로(동시에).</summary>
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) ----------
/// <summary>
/// 관통 돌격(기병 · 스킬로 관통하는 보병·창병): 유닛 무시(아군은 지나칠 수 없으면 직전 정지 · 손견·손책 = 아군도 통과) · 경로 위 적 피해·옆 밀기 ·
/// 손책 소패왕 = 전장 벽에서 핀볼처럼 튕겨 남은 거리 소진 · 반격 · 장애물 → 장료 요래(끌어오기) → 복귀(조운·손책·여포·문추·공손찬·순우경 · 하후돈 = 탭 지점·쓸어 감).
/// </summary>
/// <param name="slideExclude">빙판 미끄러짐 재실행(앞서 관통한 적 제외 · 끌어오기·복귀 없음) · null = 일반 행동.</param>
IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, List<Unit> 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<Unit>();
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<float>();
var enemies = moveOnly ? new List<Unit>() : 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<Unit>();
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);
}
/// <summary>복귀 이동: 유닛 무시 · 복귀 중 피해(조운·손책 소패왕) · 쓸어 감(하후돈 = 경로 위 유닛을 끝까지 함께 밀고 감 · 아군 포함).</summary>
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<float>();
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();
}
/// <summary>장료 요래: 관통으로 피해를 입힌 적을 출발 위치로 끌고 옴(가까운 적부터 출발점 · 뒤는 진행 방향으로 줄지음).</summary>
IEnumerator DragBack(Unit u, Vector2 origin, Vector2 dir, List<Unit> hit, ActionOutcome o)
{
var list = new List<Unit>();
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();
}
/// <summary>동탁 폭정: p1 초 동안 이동 속도로 직진 · 전장 가장자리에서 핀볼처럼 튕김 · 닿은 유닛(아군 포함)을 옆으로 p2 강하게 밀침 · 적 = 돌진 피해(반격 받음) · 자신은 튕기지 않음.</summary>
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<Unit>();
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<float>();
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();
}
/// <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>종착점이 적(allies = 아군도)과 겹치면 경로를 되짚어 겹치지 않는 지점까지 물러선다 · at = 그 점에서 dir 반대로 되짚음.</summary>
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 ⑤⑥) ----------
/// <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>();
/// <summary>책사 광역 반경(화공 배율 반영).</summary>
public float SplashRadius;
/// <summary>백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).</summary>
public float Teleport;
public Vector2 Origin;
/// <summary>투사체를 막거나 맞은 기믹(덤불·바위·성문·성벽 = 막힘 · 폭약 수레 = 파괴 · PD #1097) · 유닛을 맞혔으면 null.</summary>
public GimmickInstance HitGimmick;
/// <summary>책사 광역이 터진다(중심 = 첫 피격 유닛 · 유닛 없이 덤불·수레에 막혔으면 그 자리).</summary>
public bool HasSplash;
public Vector2 SplashCenter;
/// <summary>책사 광역에 들어 지워질 기믹(덤불 제거 · 수레 파괴).</summary>
public readonly List<GimmickInstance> ClearGimmicks = new List<GimmickInstance>();
}
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;
}
/// <summary>
/// 사격 위치(p.From)에서 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과) ·
/// 막는 기믹(덤불·바위·성문·성벽) · 파괴되는 기믹(폭약 수레) · 안개 안 유닛 제외 · 책사 = 광역 계획(AimSplash).
/// </summary>
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); // 장로 = 직선 투사체(광역 없음)
}
/// <summary>화살이 지나는 진영(황충 노익장·손권 강동의 주인 = 아군 통과 · p2 1 = 장애물도 통과).</summary>
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);
}
/// <summary>
/// 투사체 1발: 첫 피격 유닛에 사격 피해(아군이면 오사 · 상성 적용 · 반격 없음 · 명중 보상 없음 · 밀지 않으므로 규칙 ⑤ 없음)
/// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음 · 화공·팔문 간파 반영).
/// </summary>
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);
}
/// <summary>사격 피해 적용 + 집계(상대 = Attacked · 같은 편 = FriendlyHit 오사). 반환 = 실제 피해.</summary>
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;
}
/// <summary>슬링샷 직진 구간 끝 · 새 이동 구간 시작 = 돌진 이펙트(skill_vfx.csv travel) 소멸 · 다시 재생(PD 2026-11-10).</summary>
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);
}
/// <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>
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;
}
/// <summary>
/// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구)
/// / 행동 유닛 = −n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준.
/// chain = 적과 싸운 뒤의 튕김(PD #1098): 튕겨나는 길에 다른 적이 있으면 그 적과 피해·반격 교환 → 그 적 기준으로 다시 튕김(연쇄 1회) · 아군이면 밀기만.
/// </summary>
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();
}
/// <summary>부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다. onEnd(true) = 다 못 가고 막힘.</summary>
IEnumerator Push(Unit target, Vector2 n, float force, Unit pusher, ActionOutcome o, System.Action<bool> 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);
}
/// <summary>
/// 규칙 ⑤: 밀린 아군이 상대 유닛과 닿아 있으면 그 자리에서 공격(근접 · 자기 행동 소모 없음) — 기본 1회 ·
/// 민 유닛의 군령(pushed_ally_attack_count = n 회) · 대장군(pushed_ally_attack_on_push = +n 회).
/// </summary>
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);
}
/// <summary>
/// 밀린 유닛의 근접 공격 1회(반격 받음 · Notes 에 기록) — 장합 교변: 민 장합의 공격력 × p1 을 더함 · 장합 자신이 밀려 친 공격 = × p1 ·
/// 고순 함진영: 민 고순의 「명중 기절」이 이 공격에도(간접 명중). 원거리 병종도 근접 패널티를 받는다.
/// </summary>
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 : ""));
}
}
}