ThreeKingdomsSlingshot/Assets/Scripts/Runtime/ActionResolver.cs

834 lines
42 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>슬링샷 한 번의 결과 요약.</summary>
public class ActionOutcome
{
public bool BonusMove; // 보병: 최종 목표점 명중 → 추가 이동
public bool Returned; // 기병: 최종 목표점 명중 → 제자리 복귀
public bool Overwatch; // 창병: 최종 목표점 명중 → 대기(견제)
public bool Crit; // 궁병·책사: 첫 피격 = 타겟 + 최종 목표점 명중 → 크리티컬
public bool MovedOnly; // 행동 유닛 자신은 공격하지 않음(이동 · 밀어내기만)
public bool Fired; // 궁병·책사: 투사체를 쐈다(타겟이 없으면 목표점 이동만 · 쏘지 않음)
public Unit ShotHit; // 궁병·책사: 투사체가 처음 맞힌 유닛(아군 포함 · 책사 광역 중심) · null = 안 쏨·빗나감
public int DamageDealt;
public int CounterTaken;
public List<Unit> Attacked = new List<Unit>();
/// <summary>궁병·책사 사격에 맞은 같은 편(오사 · 책사 광역 포함 · PD 2026-11-05 #1087 ⑤⑥).</summary>
public List<Unit> FriendlyHit = new List<Unit>();
public int FriendlyDamage;
/// <summary>충돌로 밀려난 유닛(아군·적군 · PD 2026-11-04 #1080).</summary>
public List<Unit> Pushed = new List<Unit>();
/// <summary>밀린 아군이 적과 닿아 얻은 공격 등 부가 설명(HUD 문구).</summary>
public List<string> Notes = new List<string>();
/// <summary>실제로 지나간 경로(시작 포함). 창병 견제 판정용.</summary>
public List<Vector2> Path = new List<Vector2>();
// ---------- 스킬(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>();
}
public struct Displacement
{
public Unit Unit;
public Vector2 From;
public Vector2 To;
}
/// <summary>
/// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙).
/// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 멈춤 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회
/// ② 기병: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 · 목표점 명중 시 제자리 복귀
/// ③ 창병: 첫 유닛에 부딪히면 튕기지 않고 남은 거리만큼 밀며 함께 전진 · 적이면 피해(반격 받음) · 아군이면 밀기만 · 목표점 명중 시 대기(견제)
/// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ ×(1+비율) 안이면 들어올 만큼만 다가가 사격 ·
/// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 ·
/// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage)
/// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤)
/// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음)
/// 스킬(PD #1093 B): 수치는 SkillMods 질의(밀기·튕김·관통·사거리·사선·광역·피해) · 예약 액티브는 Resolve 앞뒤에서 장착·소모(SkillSystem).
/// </summary>
public class ActionResolver
{
readonly BalanceConfig cfg;
readonly BattleManager battle;
readonly System.Random rng;
public ActionResolver(BalanceConfig cfg, BattleManager battle, System.Random rng)
{
this.cfg = cfg;
this.battle = battle;
this.rng = rng;
}
public IEnumerator Resolve(Unit u, Vector2 target, bool moveOnly, ActionOutcome o)
{
Vector2 origin = u.Position;
Vector2 delta = target - origin;
float dist = delta.magnitude;
o.Path.Add(origin);
if (dist < 0.001f || !u.IsAlive) yield break;
Vector2 dir = delta / dist;
var skills = SkillSystem.Current;
if (skills != null) skills.BeginAction(u, moveOnly, o); // 예약 액티브 장착 + 발동 연출(PD #1093 B)
// 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084)
if (u.View != null) u.View.SetCharging(true);
switch (u.Type)
{
case UnitType.Infantry:
yield return InfantryRoutine(u, origin, dir, dist, target, moveOnly, o);
break;
case UnitType.Spear:
yield return SpearRoutine(u, origin, dir, dist, target, moveOnly, o);
break;
case UnitType.Cavalry:
yield return CavalryRoutine(u, origin, dir, dist, target, moveOnly, o, true);
break;
case UnitType.Archer:
case UnitType.Strategist:
yield return ArcherRoutine(u, origin, dir, dist, target, moveOnly, o);
break;
}
if (u.View != null) u.View.SetCharging(false);
if (skills != null) skills.EndAction(u, o); // 예약 소모 · 쿨 · 견제 강화 이월
}
// ---------- 미리보기(드라이런) ----------
/// <summary>조작 중 표시용: 이 슬링샷으로 행동 유닛이 직접 공격하게 될 적 목록(밀린 아군의 공격은 예측하지 않음).</summary>
public List<Unit> PredictTargets(Unit u, Vector2 target, bool moveOnly)
{
var result = new List<Unit>();
if (moveOnly) return result;
Vector2 origin = u.Position;
Vector2 delta = target - origin;
float dist = delta.magnitude;
if (dist < 0.001f) return result;
Vector2 dir = delta / dist;
float hitDist;
var skills = SkillSystem.Current;
bool prev = skills != null && skills.PreviewBegin(u);
try
{
switch (u.Type)
{
case UnitType.Infantry:
case UnitType.Spear:
{
var first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
if (first != null && first.Faction != u.Faction) result.Add(first);
break;
}
case UnitType.Cavalry:
{
float travel = Combat.FreeTravel(u, origin, dir, dist, false, false);
travel = StopBeforeUnclearedAllies(u, origin, dir, travel);
var dists = new List<float>();
result.AddRange(Combat.EnemiesOnPath(u, origin, dir, travel, dists));
break;
}
case UnitType.Archer:
case UnitType.Strategist:
{
var p = PlanShot(u, origin, dir, target);
if (!p.Fires) break;
if (p.Hit != null && p.Hit.Faction != u.Faction) result.Add(p.Hit);
foreach (var s in p.Splash)
{
if (s.Faction != u.Faction) result.Add(s);
}
break;
}
}
}
finally
{
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);
try
{
return PlanShot(u, u.Position, delta / dist, target);
}
finally
{
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;
}
// ---------- 보병: 알까기 충돌 ----------
IEnumerator InfantryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
if (moveOnly)
{
yield return MoveFree(u, origin, dir, dist, o);
yield break;
}
float hitDist;
Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
if (first == null)
{
yield return MoveFree(u, origin, dir, dist, o);
yield break;
}
Vector2 contact = origin + dir * hitDist;
yield return MoveStraight(u, contact, o); // 등속으로 가서 부딪힌 순간 바로 반응(PD #1081)
float remaining = Mathf.Max(0f, dist - hitDist);
bool enemy = first.Faction != u.Faction;
bool isFinal = enemy && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first;
int impact = 0;
if (enemy)
{
impact = ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격을 즉시 주고받는다(연출 대기 없음)
if (!u.IsAlive) yield break;
}
else
{
o.MovedOnly = true;
}
// 알까기: 부딪힌 순간 양쪽이 동시에 튕긴다 — 상대 = 접촉 방향 × 남은 힘 × collisionPushRatio · 자신 = 반대 방향 × 남은 힘 × collisionSelfBounceRatio
Vector2 n = first.Position - contact;
n = n.sqrMagnitude > 1e-6f ? n.normalized : dir;
yield return Bounce(u, first, n, remaining, o, enemy ? impact : 0);
if (!enemy && first.IsAlive) yield return PushedAllyAttack(first, o, u);
if (isFinal && u.IsAlive)
{
o.BonusMove = true;
o.BonusMoves = Mathf.Max(1, SkillMods.Count(u, SkillKeys.ExtraMoveCount, 1)); // 인덕: 추가 이동 n 회
o.BonusAttack = SkillMods.Has(u, SkillKeys.ExtraMoveAttack); // 호치·강동의 호랑이: 추가 이동에서 공격
}
}
// ---------- 창병: 밀며 함께 전진 ----------
IEnumerator SpearRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
if (moveOnly)
{
yield return MoveFree(u, origin, dir, dist, o);
yield break;
}
float hitDist;
Unit first = FirstUnitBeforeWall(u, origin, dir, dist, out hitDist);
if (first == null)
{
yield return MoveFree(u, origin, dir, dist, o);
yield break;
}
Vector2 contact = origin + dir * hitDist;
yield return MoveStraight(u, contact, o); // 등속 접근 → 부딪힌 순간 즉시 밀기 시작(PD #1081)
float remaining = Mathf.Max(0f, dist - hitDist);
bool enemy = first.Faction != u.Faction;
bool isFinal = enemy && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == first;
if (enemy)
{
ImpactExchange(u, first, o); // 부딪힌 순간 피해·반격 즉시
if (!u.IsAlive) yield break;
}
else
{
o.MovedOnly = true;
}
// 밀며 전진 거리 = 남은 거리 × 철척사모(push_mult) × 대상 거구(knockback_taken_pct)
float advance = remaining * SkillMods.PushMult(u, true) * SkillMods.KnockbackTakenMult(first, true);
if (first.IsAlive && advance > 0.01f)
{
// 튕기지 않고 앞 유닛을 밀며 함께 전진 · 앞 유닛이 다른 유닛·벽에 막히면 둘 다 멈춤 · 접촉 간격 유지
float travel = Combat.FreeTravel(first, first.Position, dir, advance, true, true, u);
if (travel > 0.01f)
{
Face(first, dir); // 밀린 유닛은 밀린 방향을 본다(PD #1082)
Vector2 firstStart = first.Position;
Vector2 firstEnd = first.Position + dir * travel;
Vector2 spearEnd = contact + dir * travel;
Fx.Run(Fx.MoveAlong(first.transform, first.Position, firstEnd, cfg.moveSpeed, null));
yield return Fx.MoveAlong(u.transform, contact, spearEnd, cfg.moveSpeed, null);
first.Position = firstEnd;
u.Position = spearEnd;
o.Path.Add(spearEnd);
o.Displaced.Add(new Displacement { Unit = first, From = firstStart, To = firstEnd });
battle.NotifyChanged();
}
o.Pushed.Add(first);
if (!enemy) yield return PushedAllyAttack(first, o, u);
}
if (enemy && u.IsAlive) SpearHitMore(u, first, o);
if (isFinal && u.IsAlive)
{
u.SetOverwatch(true);
o.Overwatch = true;
}
}
/// <summary>삼첨도(spear_hit_two): 밀며 전진 끝에 창병·밀린 적과 닿은 다른 적까지 합쳐 최대 n 기에 동시 피해(반격 없음).</summary>
void SpearHitMore(Unit u, Unit first, ActionOutcome o)
{
int n = SkillMods.Count(u, SkillKeys.SpearHitTwo, 1);
if (n <= 1) return;
Physics2D.SyncTransforms();
var extra = new List<Unit>();
foreach (var near in new[] { u, first })
{
if (near == null || !near.IsAlive) continue;
foreach (var c in Physics2D.OverlapCircleAll(near.Position, near.Radius + Combat.ContactTolerance + 0.5f))
{
var e = c.GetComponent<Unit>();
if (e == null || e == first || e == u || !e.IsAlive || e.Faction == u.Faction || extra.Contains(e)) continue;
float gap = Vector2.Distance(near.Position, e.Position) - near.Radius - e.Radius;
if (gap <= Combat.ContactTolerance) extra.Add(e);
}
}
extra.Sort((a, b) => Vector2.Distance(u.Position, a.Position).CompareTo(Vector2.Distance(u.Position, b.Position)));
for (int i = 0; i < extra.Count && i < n - 1; i++)
{
var e = extra[i];
int dmg = SkillSystem.Deal(e, Combat.Damage(cfg, u, e, false, rng, true));
o.Attacked.Add(e);
o.DamageDealt += dmg;
Fx.Popup(e.Position, dmg.ToString(), Color.white);
}
battle.NotifyChanged();
}
// ---------- 기병: 관통 ----------
/// <param name="first">false = 위진소요의 추가 관통(다시 반복·복귀하지 않음 · exclude = 앞서 관통한 적 제외).</param>
IEnumerator CavalryRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o, bool first, List<Unit> exclude = null)
{
float travel = Combat.FreeTravel(u, origin, dir, dist, false, false); // 유닛은 무시 · 벽만
travel = StopBeforeUnclearedAllies(u, origin, dir, travel); // 지나칠 수 없는 아군 직전에서 멈춤
var dists = new List<float>();
var enemies = moveOnly ? new List<Unit>() : Combat.EnemiesOnPath(u, origin, dir, travel, dists);
if (exclude != null)
{
for (int i = enemies.Count - 1; i >= 0; i--)
{
if (!exclude.Contains(enemies[i])) continue;
enemies.RemoveAt(i);
dists.RemoveAt(i);
}
}
Unit finalHit = moveOnly ? null : Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance);
bool hitFinal = first && finalHit != null && enemies.Contains(finalHit);
// 단기구주: 빗나가도(관통한 적이 있으면) 출발점 복귀 · 위진소요: 명중 시 복귀 대신 같은 방향 한 번 더 관통
int repeat = hitFinal ? SkillMods.Count(u, SkillKeys.PierceRepeat, 0) : 0;
bool willReturn = first && repeat <= 0 && (hitFinal || (enemies.Count > 0 && SkillMods.Has(u, SkillKeys.PierceReturnOnMiss)));
Vector2 end = origin + dir * travel;
if (!willReturn && repeat <= 0) end = BackOffFromEnemies(u, origin, dir, travel);
if (enemies.Count == 0)
{
yield return Move(u, end, o);
o.MovedOnly = true;
yield break;
}
float dmgMult = SkillMods.PierceDmgMult(u, enemies.Count, true); // 만인지적(2기 이상) · 발시담정 · 천하무쌍
Face(u, dir);
if (u.View != null) u.View.PlayAttack();
int idx = 0;
var hit = new List<Unit>();
yield return Fx.MoveAlong(u.transform, origin, end, cfg.moveSpeed, travelled =>
{
while (idx < enemies.Count && travelled >= dists[idx] - 0.01f)
{
PierceHit(u, enemies[idx], dir, o, dmgMult);
hit.Add(enemies[idx]);
idx++;
}
});
u.Position = end;
o.Path.Add(end);
for (; idx < enemies.Count; idx++)
{
if (dists[idx] <= travel + 0.01f)
{
PierceHit(u, enemies[idx], dir, o, dmgMult);
hit.Add(enemies[idx]);
}
}
yield return Fx.Wait(0.15f);
// 살아남은 근접 적의 반격(단기구주·발시담정·천하무쌍 = 반격 없음)
if (!SkillMods.Has(u, SkillKeys.PierceNoCounter))
{
foreach (var e in hit)
{
if (!u.IsAlive) break;
if (!e.IsAlive || !UnitTypeInfo.IsMelee(e.Type)) continue;
int c = Combat.CounterDamage(cfg, e, u, rng, true);
if (c <= 0) continue;
c = SkillSystem.Deal(u, c);
o.CounterTaken += c;
Fx.Popup(u.Position, c.ToString(), new Color(1f, 0.45f, 0.45f));
battle.NotifyChanged();
yield return Fx.Wait(0.12f);
}
}
if (repeat > 0 && u.IsAlive)
{
// 위진소요: 목표점에서 같은 방향으로 같은 거리만큼 한 번 더 관통(앞서 관통한 적 제외 · 그 뒤 복귀 없음)
for (int i = 0; i < repeat && u.IsAlive; i++)
{
yield return Fx.Wait(0.1f);
Vector2 from = u.Position;
yield return CavalryRoutine(u, from, dir, dist, from + dir * dist, false, o, false, hit);
}
yield break;
}
if (willReturn && u.IsAlive)
{
yield return Fx.Wait(0.1f);
yield return Move(u, origin, o, false); // 복귀는 적을 본 채 물러남(보는 방향 유지 · 총괄PM 해석)
o.Returned = true;
}
}
void PierceHit(Unit cav, Unit e, Vector2 dir, ActionOutcome o, float dmgMult)
{
if (!e.IsAlive) return;
int dmg = SkillSystem.Deal(e, Combat.Damage(cfg, cav, e, false, rng, true, dmgMult));
o.Attacked.Add(e);
o.DamageDealt += dmg;
Fx.Popup(e.Position, dmg.ToString(), Color.white);
var skills = SkillSystem.Current;
var cs = skills != null ? skills.Of(cav) : null;
if (cs != null) cs.PierceHits++; // 팔백 돌격(전투 중 누적)
// 옆으로 밀어내기(상산의 창·연진 돌격·백마의종 = 넉백 배율 · 거구 = 밀리는 거리 −)
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.PierceKnockbackMult(cav, true) * SkillMods.KnockbackTakenMult(e, true);
Vector2 from = e.Position;
Vector2 to = battle.Board.ClampInside(e.Position + side * sign * kb, e.Radius);
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>종착점이 적과 겹치면 경로를 되짚어 겹치지 않는 지점까지 물러선다.</summary>
Vector2 BackOffFromEnemies(Unit u, Vector2 origin, Vector2 dir, float travel)
{
float t = travel;
for (int i = 0; i < 40 && t > 0f; i++)
{
Vector2 p = origin + dir * t;
bool overlap = false;
foreach (var e in battle.AliveUnits(u.Faction == Faction.Player ? Faction.Enemy : Faction.Player))
{
if (Vector2.Distance(p, e.Position) < u.Radius + e.Radius - 0.01f)
{
overlap = true;
break;
}
}
if (!overlap) return p;
t -= 0.1f;
}
return origin;
}
// ---------- 궁병 · 책사: 투사체 사격 (PD 2026-11-05 #1087 ⑤⑥) ----------
/// <summary>궁병·책사 사격 계획 — 조준 미리보기(PreviewShot)와 실제 행동(ArcherRoutine)이 같은 계산을 쓴다.</summary>
public class ShotPlan
{
/// <summary>복도 탐색으로 고른 적(조준 방향 사거리 + 접근 허용 안의 첫 적) · null = 쏘지 않고 목표점까지 이동.</summary>
public Unit Target;
/// <summary>사격 전에 다가가는 거리(사거리 안 = 0).</summary>
public float Approach;
/// <summary>사거리에 들어와 쏜다(false = 아군·벽에 막혀 사거리에 못 들어감 → 이동만).</summary>
public bool Fires;
public Vector2 From;
/// <summary>투사체가 멈춘 지점(첫 피격 유닛에 닿은 곳 · 빗나가면 비행 끝).</summary>
public Vector2 To;
/// <summary>투사체가 처음 맞히는 유닛(아군 포함 · 관통 없음) = 책사 광역의 중심.</summary>
public Unit Hit;
/// <summary>첫 피격 = 타겟이고 최종 목표점 명중 → 크리티컬.</summary>
public bool Crit;
/// <summary>책사: 중심 주변 광역 대상(아군 포함 · 중심·쏜 책사 제외 · 가까운 순).</summary>
public readonly List<Unit> Splash = new List<Unit>();
/// <summary>책사 광역 반경(화공 배율 반영).</summary>
public float SplashRadius;
/// <summary>백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).</summary>
public float Teleport;
public Vector2 Origin;
}
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) };
// 백기습영(teleport · 다음 행동): 사거리와 무관하게 목표점까지 이동한 뒤 그 자리에서 사격
if (SkillMods.Has(u, SkillKeys.Teleport))
{
p.Teleport = Combat.FreeTravel(u, origin, dir, Vector2.Distance(origin, target), true, true);
origin += dir * p.Teleport;
p.From = p.To = origin;
}
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;
// 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한)
float need = Mathf.Clamp(hitDist - range, 0f, u.ArcherMoveAllow);
p.Approach = need > 0.01f ? Combat.FreeTravel(u, origin, dir, need, true) : 0f;
p.From = origin + dir * p.Approach;
p.To = p.From;
if (hitDist - p.Approach > range + 0.05f) return p; // 아군·벽에 막혀 사거리에 못 들어감
// 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과)
p.Fires = true;
Vector2 aim = p.Target.Position - p.From;
Vector2 adir = aim.sqrMagnitude > 1e-6f ? aim.normalized : dir;
float travel;
Faction? pass = SkillMods.Has(u, SkillKeys.ArrowPassAllies) ? u.Faction : (Faction?)null;
p.Hit = Combat.FirstUnitOnArrow(u, p.From, adir, range + u.Radius, cfg.arrowRadius, pass, out travel);
p.To = p.From + adir * (p.Hit != null ? travel + cfg.arrowRadius : travel);
if (SkillMods.Has(u, SkillKeys.ArrowCritAlways)) p.Crit = p.Hit != null && p.Hit.Faction != u.Faction; // 백발백중·백기습영: 명중 여부와 무관하게 크리
else p.Crit = p.Hit == p.Target && Combat.EnemyAtPoint(u, target, cfg.finalHitTolerance) == p.Target;
if (p.Hit != null && u.Type == UnitType.Strategist)
{
p.Splash.AddRange(Combat.UnitsInCircle(p.Hit.Position, p.SplashRadius, p.Hit, u));
}
return p;
}
IEnumerator ArcherRoutine(Unit u, Vector2 origin, Vector2 dir, float dist, Vector2 target, bool moveOnly, ActionOutcome o)
{
var p = moveOnly ? null : PlanShot(u, origin, dir, target);
if (p != null && p.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 ⑥ 유지)
if (p == null || p.Teleport <= 0.01f) yield return MoveFree(u, origin, dir, dist, o);
yield break;
}
if (p.Approach > 0.01f) yield return Move(u, p.From, o);
if (!p.Fires)
{
o.MovedOnly = true; // 아군·벽에 막혀 사거리에 못 들어감
yield break;
}
Face(u, p.To - p.From); // 제자리 사격도 쏘는 방향을 본다
yield return Shoot(u, p, o);
}
/// <summary>
/// 투사체 1발: 첫 피격 유닛에 사격 피해(아군이면 오사 · 상성 적용 · 반격 없음 · 명중 보상 없음 · 밀지 않으므로 규칙 ⑤ 없음)
/// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음 · 화공·팔문 간파 반영).
/// </summary>
IEnumerator Shoot(Unit u, ShotPlan p, ActionOutcome o)
{
bool strategist = u.Type == UnitType.Strategist;
o.Fired = true;
o.ShotHit = p.Hit;
if (u.View != null) u.View.PlayAttack();
yield return Fx.Arrow(p.From, p.To, strategist ? Fx.StrategistShotColor : Fx.ArrowColor);
if (p.Hit == null)
{
yield return Fx.Wait(0.1f); // 빗나감(벽 · 비행 끝)
yield break;
}
Vector2 center = p.Hit.Position;
ShotDamage(u, p.Hit, Combat.Damage(cfg, u, p.Hit, p.Crit, rng, false), p.Crit, o);
if (strategist)
{
Fx.Burst(center, p.SplashRadius, Fx.SplashColor);
float splashRatio = SkillMods.SplashRatio(u, cfg, true);
float allyRatio = SkillMods.SplashAllyRatio(u, cfg, true);
foreach (var s in p.Splash)
{
if (!s.IsAlive) continue;
float ratio = splashRatio * (s.Faction == u.Faction ? allyRatio : 1f);
int dmg = Combat.SplashDamage(cfg, u, s, ratio, rng);
if (dmg > 0) ShotDamage(u, s, dmg, false, o);
}
}
battle.NotifyChanged();
yield return Fx.Wait(0.2f);
}
/// <summary>사격 피해 적용 + 집계(상대 = Attacked · 같은 편 = FriendlyHit 오사).</summary>
void ShotDamage(Unit a, Unit d, int dmg, bool crit, ActionOutcome o)
{
dmg = SkillSystem.Deal(d, dmg);
if (d.Faction == a.Faction)
{
o.FriendlyHit.Add(d);
o.FriendlyDamage += dmg;
Fx.Popup(d.Position, dmg + " 오사", Fx.FriendlyFireColor);
return;
}
o.Attacked.Add(d);
o.DamageDealt += dmg;
o.Crit |= crit;
Fx.Popup(d.Position, crit ? dmg + "!" : dmg.ToString(), crit ? new Color(1f, 0.7f, 0.2f) : Color.white);
}
// ---------- 공통 ----------
IEnumerator MoveFree(Unit u, Vector2 origin, Vector2 dir, float dist, ActionOutcome o)
{
float free = Combat.FreeTravel(u, origin, dir, dist, true, true);
yield return Move(u, origin + dir * free, o);
o.MovedOnly = true;
}
/// <summary>보는 방향 갱신(PD #1082 · 마지막 이동 방향 · 4방향 = 앞/뒤 세트 + 좌우 반전).</summary>
static void Face(Unit u, Vector2 dir)
{
if (u != null && u.View != null && dir.sqrMagnitude > 1e-6f) u.View.SetFacing(dir);
}
IEnumerator Move(Unit u, Vector2 to, ActionOutcome o, bool face = true)
{
if (face) Face(u, to - u.Position);
yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null);
u.Position = to;
o.Path.Add(to);
}
/// <summary>등속 접근(감속 없음) — 부딪히는 순간까지 속도를 유지해 충돌 반응이 바로 이어진다.</summary>
IEnumerator MoveStraight(Unit u, Vector2 to, ActionOutcome o)
{
Face(u, to - u.Position);
yield return Fx.MoveAlong(u.transform, u.Position, to, cfg.moveSpeed, null, false);
u.Position = to;
o.Path.Add(to);
}
/// <summary>부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계. 반환 = 준 피해.</summary>
int ImpactExchange(Unit a, Unit d, ActionOutcome o)
{
if (a.View != null) a.View.PlayAttack();
int dmg = SkillSystem.Deal(d, Combat.Damage(cfg, a, d, false, rng, true));
o.Attacked.Add(d);
o.DamageDealt += dmg;
Fx.Popup(d.Position, dmg.ToString(), Color.white);
if (d.IsAlive && a.IsAlive)
{
int c = Combat.CounterDamage(cfg, d, a, rng, true); // 부딪혀 밀리는 쪽의 반격(진중 조건)
if (c > 0)
{
if (d.View != null) d.View.PlayAttack();
c = SkillSystem.Deal(a, c);
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 = 함진영 충돌 추가 피해 기준.
/// </summary>
IEnumerator Bounce(Unit striker, Unit struck, Vector2 n, float remaining, ActionOutcome o, int impactDmg)
{
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;
float bonus = impactDmg > 0 ? SkillMods.PushCollisionBonus(striker) : 0f;
System.Action<bool> onEnd = null;
if (bonus > 0f)
{
onEnd = blocked =>
{
if (!blocked || !struck.IsAlive) return;
int extra = SkillSystem.Deal(struck, Mathf.Max(1, Mathf.RoundToInt(impactDmg * bonus)));
o.DamageDealt += extra;
Fx.Popup(struck.Position, extra + " 충돌", new Color(1f, 0.75f, 0.3f));
};
}
Fx.Run(Track(Push(struck, n, force, striker, o, onEnd), () => struckDone = true));
}
float self = remaining * cfg.collisionSelfBounceRatio * SkillMods.SelfBounceMult(striker, true);
if (self >= 0.01f && striker.IsAlive)
{
float back = Combat.FreeTravel(striker, striker.Position, -n, self, true, true, struck);
if (back > 0.01f)
{
Face(striker, -n); // 튕겨 나온 방향을 본다(PD #1082)
Vector2 end = battle.Board.ClampInside(striker.Position - n * back, striker.Radius);
yield return Fx.MoveAlong(striker.transform, striker.Position, end, cfg.moveSpeed, null);
striker.Position = end;
o.Path.Add(end);
}
}
while (!struckDone) yield return null;
}
static IEnumerator Track(IEnumerator inner, System.Action onDone)
{
yield return inner;
onDone();
}
/// <summary>부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다. 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;
float slide = Combat.FreeTravel(target, target.Position, n, force, true, true, pusher);
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;
int times = 1;
if (pusher != null)
{
times = Mathf.Max(1, SkillMods.Count(pusher, SkillKeys.PushedAllyAttackCount, 1));
times += Mathf.Max(0, SkillMods.Count(pusher, SkillKeys.PushedAllyAttackOnPush, 0, true));
}
for (int i = 0; i < times; i++)
{
if (!ally.IsAlive) yield break;
Unit foe = Combat.TouchingOpponent(ally);
if (foe == null) yield break;
yield return Fx.Wait(0.1f);
Face(ally, foe.Position - ally.Position);
yield return PushedAttack(ally, foe, o);
}
}
/// <summary>밀린 아군의 근접 공격 1회(반격 받음 · 결과 요약에는 집계하지 않고 Notes 에 기록 · 도원의 맹약 = 피해 +%). 원거리 병종도 근접 패널티를 받는다.</summary>
IEnumerator PushedAttack(Unit a, Unit d, ActionOutcome o)
{
if (a.View != null) a.View.PlayAttack();
yield return Fx.Lunge(a, d.Position);
float scale = 1f + SkillMods.Sum(a, SkillKeys.PushedAllyAttackPct, true);
int dmg = SkillSystem.Deal(d, Combat.Damage(cfg, a, d, false, rng, true, scale));
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);
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 : ""));
}
}
}