1238 lines
66 KiB
C#
1238 lines
66 KiB
C#
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 : ""));
|
||
}
|
||
}
|
||
}
|