2026-11-03 19:10:10 +00:00
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 ; // 창병: 최종 목표점 명중 → 대기(견제)
2026-11-05 04:06:32 +00:00
public bool Crit ; // 궁병·책사: 첫 피격 = 타겟 + 최종 목표점 명중 → 크리티컬
2026-11-04 06:56:02 +00:00
public bool MovedOnly ; // 행동 유닛 자신은 공격하지 않음(이동 · 밀어내기만)
2026-11-05 04:06:32 +00:00
public bool Fired ; // 궁병·책사: 투사체를 쐈다(타겟이 없으면 목표점 이동만 · 쏘지 않음)
public Unit ShotHit ; // 궁병·책사: 투사체가 처음 맞힌 유닛(아군 포함 · 책사 광역 중심) · null = 안 쏨·빗나감
2026-11-03 19:10:10 +00:00
public int DamageDealt ;
public int CounterTaken ;
public List < Unit > Attacked = new List < Unit > ( ) ;
2026-11-05 04:06:32 +00:00
/// <summary>궁병·책사 사격에 맞은 같은 편(오사 · 책사 광역 포함 · PD 2026-11-05 #1087 ⑤⑥).</summary>
public List < Unit > FriendlyHit = new List < Unit > ( ) ;
public int FriendlyDamage ;
2026-11-04 06:56:02 +00:00
/// <summary>충돌로 밀려난 유닛(아군·적군 · PD 2026-11-04 #1080).</summary>
public List < Unit > Pushed = new List < Unit > ( ) ;
/// <summary>밀린 아군이 적과 닿아 얻은 공격 등 부가 설명(HUD 문구).</summary>
public List < string > Notes = new List < string > ( ) ;
2026-11-03 19:10:10 +00:00
/// <summary>실제로 지나간 경로(시작 포함). 창병 견제 판정용.</summary>
public List < Vector2 > Path = new List < Vector2 > ( ) ;
2026-11-05 14:31:27 +00:00
// ---------- 스킬(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 ;
2026-11-05 16:20:07 +00:00
/// <summary>이 행동으로 밀리거나 넉백된 유닛의 이동 구간(청룡언월 「견제 무제한」 판정 · 기믹 지대 스윕).</summary>
2026-11-05 14:31:27 +00:00
public List < Displacement > Displaced = new List < Displacement > ( ) ;
2026-11-05 16:20:07 +00:00
/// <summary>보병 연쇄 교환 수(튕겨나는 도중 다른 적과 부딪힘 · PD 2026-11-06 #1098).</summary>
public int Chains ;
2026-11-05 14:31:27 +00:00
}
public struct Displacement
{
public Unit Unit ;
public Vector2 From ;
public Vector2 To ;
2026-11-03 19:10:10 +00:00
}
/// <summary>
2026-11-04 06:56:02 +00:00
/// 슬링샷 벡터(시작점 → 목표점)를 병종 규칙대로 이동·공격으로 해석한다 (PD 2026-11-04 #1080 충돌 규칙).
2026-11-05 16:20:07 +00:00
/// ① 보병: 경로 위 첫 유닛(아군·적군)에 부딪히면 알까기처럼 그 유닛을 남은 힘만큼 밀어내고 자신은 반대로 튕김 · 적이면 공격(반격 받음) · 목표점 명중 시 추가 이동 1회 ·
/// 적과 싸운 뒤 튕겨나는 도중 다른 적과 부딪히면 그 적과도 피해·반격 교환 + 다시 튕김(연쇄 1회 · 아군이면 밀기만 · PD 2026-11-06 #1098)
2026-11-10 08:25:47 +00:00
/// ② 기병 = 적을 관통해 이동: 유닛을 무시하고 관통 · 경로 위 적은 모두 피해(옆으로 밀림) · 아군은 지나칠 거리가 안 나오면 그 아군 직전에 멈춤 ·
/// 제자리 복귀 없음(PD 2026-11-10 「유저가 인지하기 어려움 → 제거 · 기병 특징 = 적을 관통해 이동」)
/// ③ 창병 = 적을 크게 밀치고 자신은 적게 밀림: 첫 유닛에 부딪히면 그 유닛을 남은 거리 × spearPushRatio 만큼 밀치고 자신은 그 × spearRecoilRatio 만큼 반대로 밀림 ·
/// 적이면 피해(반격 받음) · 아군이면 밀기만 · 대기(견제) 없음(PD 2026-11-10 「유저가 인지하기 어려움 → 제거」)
2026-11-04 06:56:02 +00:00
/// ④ 궁병: 사거리 = 슬링샷 최대 거리 · 조준 방향 사거리 안의 적은 제자리에서 사격 · 사거리 밖 ~ × (1+비율) 안이면 들어올 만큼만 다가가 사격 ·
2026-11-05 04:06:32 +00:00
/// 더 멀면 목표점까지 이동(쏘지 않음) · 사격 = 타겟 방향 화살 투사체 → 경로 위 첫 유닛(아군 포함)이 맞음 · 관통 없음 · 반격 없음 ·
/// 첫 피격 = 타겟 + 목표점 명중 시 크리티컬(PD 2026-11-05 #1087 ⑥) · 근접 전투는 피해 50 % · 피격 +50 %(Combat.Damage)
/// ④' 책사: 궁병과 같은 사격 + 맞은 유닛 주변 반경 안 다른 유닛 전원(아군 포함)에게 광역 피해(PD 2026-11-05 #1087 ⑤)
2026-11-04 06:56:02 +00:00
/// ⑤ 밀린 아군(행동 유닛과 같은 진영)이 상대 유닛과 닿으면 그 자리에서 공격 1회(조작마다 · 자기 행동은 소모하지 않음)
2026-11-05 14:31:27 +00:00
/// 스킬(PD #1093 B): 수치는 SkillMods 질의(밀기·튕김·관통·사거리·사선·광역·피해) · 예약 액티브는 Resolve 앞뒤에서 장착·소모(SkillSystem).
2026-11-05 16:20:07 +00:00
/// 지형 기믹(PD #1097): 충돌·투사체·미끄러짐·지대 = Gimmicks/ActionResolver.Gimmicks.cs · 가상 실행(적 AI) = Sim.Active 동안 연출 없이 즉시.
2026-11-03 19:10:10 +00:00
/// </summary>
2026-11-05 16:20:07 +00:00
public partial class ActionResolver
2026-11-03 19:10:10 +00:00
{
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 ;
2026-11-10 10:00:18 +00:00
float slingDist = Vector2 . Distance ( origin , target ) ;
Vector2 aimed = target ;
target = ReachPoint ( u , target ) ; // 늪·고지 이동 비용 = 실제 도달점(근접 병종 · PD #1097) · 조인 철벽 = 반경 안 이동 비용 +
2026-11-03 19:10:10 +00:00
Vector2 delta = target - origin ;
float dist = delta . magnitude ;
o . Path . Add ( origin ) ;
if ( dist < 0.001f | | ! u . IsAlive ) yield break ;
Vector2 dir = delta / dist ;
2026-11-05 16:20:07 +00:00
GimmickBegin ( o ) ;
2026-11-05 14:31:27 +00:00
var skills = SkillSystem . Current ;
2026-11-10 10:00:18 +00:00
if ( skills ! = null )
{
skills . BeginAction ( u , moveOnly , o , dir , slingDist ) ; // 예약 액티브 장착 + 발동 연출 · 돌진 거리 비율(만인지적·강동의 호걸·적토마)
2026-11-10 11:36:58 +00:00
if ( skills . Ctx ! = null & & CrossesSlowTerrain ( u , origin , aimed ) ) skills . Ctx . HitTerrain = true ; // 이동 감소 지형(늪·고지)을 지남(맹획 남만왕 · 조인 철벽 감속은 제외 · Fable 검토 C6)
2026-11-10 10:00:18 +00:00
}
2026-11-04 15:56:38 +00:00
// 슬링샷 돌진 중 = 병종 돌진 포즈 1프레임 고정 · 끝나면 대기 (PD 2026-11-05 #1084)
if ( u . View ! = null ) u . View . SetCharging ( true ) ;
2026-11-10 10:00:18 +00:00
// 정보 연환격(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 ;
}
2026-11-10 11:36:58 +00:00
if ( ! moveOnly & & ( SkillMods . Has ( u , SkillKeys . ShotAsArcher ) | | HasSpecialShot ( u ) ) ) // 축융 비도 · 주태 검기(보병의 특수 사격 · Fable 검토 A1)
2026-11-10 10:00:18 +00:00
{
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 ;
}
2026-11-03 19:10:10 +00:00
switch ( u . Type )
{
case UnitType . Infantry :
2026-11-04 06:56:02 +00:00
yield return InfantryRoutine ( u , origin , dir , dist , target , moveOnly , o ) ;
break ;
2026-11-03 19:10:10 +00:00
case UnitType . Spear :
2026-11-04 06:56:02 +00:00
yield return SpearRoutine ( u , origin , dir , dist , target , moveOnly , o ) ;
2026-11-03 19:10:10 +00:00
break ;
case UnitType . Cavalry :
2026-11-10 10:00:18 +00:00
yield return CavalryRoutine ( u , origin , dir , dist , target , moveOnly , o ) ;
2026-11-03 19:10:10 +00:00
break ;
case UnitType . Archer :
2026-11-05 04:06:32 +00:00
case UnitType . Strategist :
2026-11-03 19:10:10 +00:00
yield return ArcherRoutine ( u , origin , dir , dist , target , moveOnly , o ) ;
break ;
}
}
// ---------- 미리보기(드라이런) ----------
2026-11-04 06:56:02 +00:00
/// <summary>조작 중 표시용: 이 슬링샷으로 행동 유닛이 직접 공격하게 될 적 목록(밀린 아군의 공격은 예측하지 않음).</summary>
2026-11-03 19:10:10 +00:00
public List < Unit > PredictTargets ( Unit u , Vector2 target , bool moveOnly )
{
var result = new List < Unit > ( ) ;
if ( moveOnly ) return result ;
Vector2 origin = u . Position ;
2026-11-05 16:20:07 +00:00
target = ReachPoint ( u , target ) ;
2026-11-03 19:10:10 +00:00
Vector2 delta = target - origin ;
float dist = delta . magnitude ;
if ( dist < 0.001f ) return result ;
Vector2 dir = delta / dist ;
float hitDist ;
2026-11-05 14:31:27 +00:00
var skills = SkillSystem . Current ;
bool prev = skills ! = null & & skills . PreviewBegin ( u ) ;
2026-11-05 16:20:07 +00:00
GimmickLayer . PreviewMode = true ; // 아군 조준 = 숨은 함정을 모른다(PD #1097)
2026-11-05 14:31:27 +00:00
try
2026-11-03 19:10:10 +00:00
{
2026-11-05 14:31:27 +00:00
switch ( u . Type )
2026-11-03 19:10:10 +00:00
{
2026-11-05 14:31:27 +00:00
case UnitType . Infantry :
case UnitType . Spear :
2026-11-05 04:06:32 +00:00
{
2026-11-10 10:00:18 +00:00
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 ) ;
2026-11-05 14:31:27 +00:00
if ( first ! = null & & first . Faction ! = u . Faction ) result . Add ( first ) ;
break ;
}
case UnitType . Cavalry :
{
float travel = Combat . FreeTravel ( u , origin , dir , dist , false , false ) ;
2026-11-10 10:00:18 +00:00
if ( ! PierceAllies ( u ) ) travel = StopBeforeUnclearedAllies ( u , origin , dir , travel ) ;
2026-11-05 14:31:27 +00:00
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 ;
2026-11-05 04:06:32 +00:00
}
2026-11-03 19:10:10 +00:00
}
}
2026-11-05 14:31:27 +00:00
finally
{
2026-11-05 16:20:07 +00:00
GimmickLayer . PreviewMode = false ;
2026-11-05 14:31:27 +00:00
if ( skills ! = null ) skills . PreviewEnd ( u , prev ) ;
}
2026-11-03 19:10:10 +00:00
return result ;
}
2026-11-05 04:06:32 +00:00
/// <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 ;
2026-11-05 14:31:27 +00:00
var skills = SkillSystem . Current ;
bool prev = skills ! = null & & skills . PreviewBegin ( u ) ;
2026-11-05 16:20:07 +00:00
GimmickLayer . PreviewMode = true ;
2026-11-05 14:31:27 +00:00
try
{
return PlanShot ( u , u . Position , delta / dist , target ) ;
}
finally
{
2026-11-05 16:20:07 +00:00
GimmickLayer . PreviewMode = false ;
2026-11-05 14:31:27 +00:00
if ( skills ! = null ) skills . PreviewEnd ( u , prev ) ;
}
2026-11-05 04:06:32 +00:00
}
2026-11-04 06:56:02 +00:00
// ---------- 공통 질의 ----------
2026-11-03 19:10:10 +00:00
2026-11-04 06:56:02 +00:00
/// <summary>경로 위 첫 유닛. 벽이 그보다 먼저 있으면 null.</summary>
Unit FirstUnitBeforeWall ( Unit u , Vector2 origin , Vector2 dir , float dist , out float hitDist )
2026-11-03 19:10:10 +00:00
{
2026-11-04 06:56:02 +00:00
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 ;
}
2026-11-10 10:00:18 +00:00
/// <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 ;
}
2026-11-04 06:56:02 +00:00
// ---------- 보병: 알까기 충돌 ----------
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 ;
2026-11-10 10:00:18 +00:00
bool passAllies = SkillMods . Has ( u , SkillKeys . PassAllies ) ; // 조조 난세의 간웅 = 아군 통과
Unit first = passAllies ? FirstEnemyBeforeWall ( u , origin , dir , dist , out hitDist ) : FirstUnitBeforeWall ( u , origin , dir , dist , out hitDist ) ;
2026-11-05 16:20:07 +00:00
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 ;
}
2026-11-03 19:10:10 +00:00
if ( first = = null )
{
2026-11-10 10:00:18 +00:00
if ( passAllies ) yield return MoveThrough ( u , origin , dir , dist , o ) ;
else yield return MoveFree ( u , origin , dir , dist , o ) ;
2026-11-03 19:10:10 +00:00
yield break ;
}
2026-11-04 06:56:02 +00:00
Vector2 contact = origin + dir * hitDist ;
2026-11-04 08:32:07 +00:00
yield return MoveStraight ( u , contact , o ) ; // 등속으로 가서 부딪힌 순간 바로 반응(PD #1081)
2026-11-10 04:19:44 +00:00
DashEnd ( u ) ; // 슬링샷 직진 끝 = 돌진 이펙트 소멸(튕김은 반응 · PD 2026-11-10)
2026-11-04 06:56:02 +00:00
float remaining = Mathf . Max ( 0f , dist - hitDist ) ;
bool enemy = first . Faction ! = u . Faction ;
2026-11-05 14:31:27 +00:00
int impact = 0 ;
2026-11-04 06:56:02 +00:00
if ( enemy )
{
2026-11-05 14:31:27 +00:00
impact = ImpactExchange ( u , first , o ) ; // 부딪힌 순간 피해·반격을 즉시 주고받는다(연출 대기 없음)
2026-11-04 06:56:02 +00:00
if ( ! u . IsAlive ) yield break ;
}
else
2026-11-03 19:10:10 +00:00
{
o . MovedOnly = true ;
2026-11-04 06:56:02 +00:00
}
2026-11-04 08:32:07 +00:00
Vector2 n = first . Position - contact ;
n = n . sqrMagnitude > 1e-6f ? n . normalized : dir ;
2026-11-10 10:00:18 +00:00
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)
}
2026-11-05 14:31:27 +00:00
if ( ! enemy & & first . IsAlive ) yield return PushedAllyAttack ( first , o , u ) ;
2026-11-10 10:00:18 +00:00
if ( ! enemy ) PushContinue ( u , remaining ) ; // 고순: 아군을 밀친 뒤 남은 이동력으로 재행동
2026-11-10 11:36:58 +00:00
// 목표점 명중 추가 이동 = 제거(PD 2026-11-10 #1112 결정 ⑱ · 보병 특징 = 알까기·연쇄 · 재행동은 스킬로만)
2026-11-04 06:56:02 +00:00
}
2026-11-10 10:00:18 +00:00
/// <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 ( ) ;
}
2026-11-05 16:20:07 +00:00
// ---------- 창병: 밀치기 + 반작용(PD 2026-11-06 #1098 · 밀며 함께 전진 폐기) ----------
2026-11-04 06:56:02 +00:00
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 ) ;
2026-11-03 19:10:10 +00:00
yield break ;
}
2026-11-04 06:56:02 +00:00
float hitDist ;
Unit first = FirstUnitBeforeWall ( u , origin , dir , dist , out hitDist ) ;
2026-11-05 16:20:07 +00:00
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 ;
}
2026-11-04 06:56:02 +00:00
if ( first = = null )
{
yield return MoveFree ( u , origin , dir , dist , o ) ;
yield break ;
}
Vector2 contact = origin + dir * hitDist ;
2026-11-04 08:32:07 +00:00
yield return MoveStraight ( u , contact , o ) ; // 등속 접근 → 부딪힌 순간 즉시 밀기 시작(PD #1081)
2026-11-10 04:19:44 +00:00
DashEnd ( u ) ; // 슬링샷 직진 끝 = 돌진 이펙트 소멸(밀기·반작용은 반응 · PD 2026-11-10)
2026-11-04 06:56:02 +00:00
float remaining = Mathf . Max ( 0f , dist - hitDist ) ;
bool enemy = first . Faction ! = u . Faction ;
if ( enemy )
{
2026-11-04 08:32:07 +00:00
ImpactExchange ( u , first , o ) ; // 부딪힌 순간 피해·반격 즉시
2026-11-04 06:56:02 +00:00
if ( ! u . IsAlive ) yield break ;
}
else
{
o . MovedOnly = true ;
}
2026-11-10 08:25:47 +00:00
// 밀치기 거리 = 남은 거리 × 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 ) ;
2026-11-05 16:20:07 +00:00
if ( first . IsAlive & & push > 0.01f )
2026-11-04 06:56:02 +00:00
{
2026-11-05 16:20:07 +00:00
bool pushedDone = false ;
Fx . Run ( Track ( Push ( first , dir , push , u , o ) , ( ) = > pushedDone = true ) ) ;
2026-11-10 11:36:58 +00:00
float recoil = Reach ( u , u . Position , - dir , push * cfg . spearRecoilRatio * ( enemy ? SkillMods . AxeBounce ( first ) : 1f ) ) ; // 서황 대부 = 창병 반작용도 × (Fable 검토 C5)
2026-11-05 16:20:07 +00:00
if ( recoil > 0.01f & & u . IsAlive )
2026-11-04 06:56:02 +00:00
{
2026-11-05 16:20:07 +00:00
// 반작용: 벽·유닛·장애물에 닿으면 정지 · 피해 없음 · 보는 방향은 유지(밀친 쪽을 본 채 물러남)
float back = Combat . FreeTravel ( u , u . Position , - dir , recoil , true , true , first ) ;
2026-11-05 16:42:15 +00:00
NoteForced ( u , u . Position , u . Position - dir * back ) ;
2026-11-05 16:20:07 +00:00
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 ) ;
}
2026-11-04 06:56:02 +00:00
}
2026-11-05 16:20:07 +00:00
while ( ! pushedDone ) yield return null ;
battle . NotifyChanged ( ) ;
2026-11-05 14:31:27 +00:00
if ( ! enemy ) yield return PushedAllyAttack ( first , o , u ) ;
2026-11-04 06:56:02 +00:00
}
2026-11-10 10:00:18 +00:00
// 목표점 명중 → 대기(견제) = 제거(PD 2026-11-10) · 새 스킬 기획(2026-11-10 #1112)에도 견제 없음
2026-11-03 19:10:10 +00:00
}
2026-11-10 10:00:18 +00:00
// ---------- 기병: 관통(제자리 복귀 없음 · 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 )
2026-11-05 14:31:27 +00:00
{
2026-11-10 10:00:18 +00:00
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 + + )
2026-11-05 14:31:27 +00:00
{
2026-11-10 10:00:18 +00:00
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 )
2026-11-05 14:31:27 +00:00
{
2026-11-10 10:00:18 +00:00
Combat . BoardWallTravel ( u , start , d , left , out wallN ) ;
if ( wallN . sqrMagnitude > 0.5f )
{
bounce = true ;
gNormal = wallN ;
}
2026-11-05 14:31:27 +00:00
}
2026-11-10 10:00:18 +00:00
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 ) ;
2026-11-05 14:31:27 +00:00
}
2026-11-10 10:00:18 +00:00
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 )
2026-11-05 14:31:27 +00:00
{
2026-11-10 10:00:18 +00:00
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 ) ;
2026-11-05 14:31:27 +00:00
}
2026-11-03 19:10:10 +00:00
2026-11-10 10:00:18 +00:00
if ( gim ! = null & & u . IsAlive )
2026-11-05 14:31:27 +00:00
{
2026-11-10 10:00:18 +00:00
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 ;
2026-11-05 14:31:27 +00:00
}
2026-11-10 10:00:18 +00:00
if ( ! u . IsAlive | | moveOnly | | slide ) yield break ;
2026-11-03 19:10:10 +00:00
2026-11-10 10:00:18 +00:00
if ( hit . Count > 0 & & SkillMods . Has ( u , SkillKeys . DragBack , true ) ) yield return DragBack ( u , origin , dir , hit , o ) ; // 장료 요래
2026-11-03 19:10:10 +00:00
2026-11-10 10:00:18 +00:00
// 복귀: 항상(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 )
2026-11-03 19:10:10 +00:00
{
2026-11-10 10:00:18 +00:00
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 ) ;
2026-11-03 19:10:10 +00:00
}
2026-11-10 10:00:18 +00:00
yield return ReturnRoutine ( u , dest , dmgBack , sweep , o ) ;
}
2026-11-03 19:10:10 +00:00
2026-11-10 10:00:18 +00:00
/// <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 ) ;
2026-11-03 19:10:10 +00:00
int idx = 0 ;
2026-11-10 10:00:18 +00:00
yield return Fx . MoveAlong ( u . transform , from , end , cfg . moveSpeed , t = >
2026-11-03 19:10:10 +00:00
{
2026-11-10 10:00:18 +00:00
while ( idx < onPath . Count & & t > = dists [ idx ] - 0.01f )
2026-11-03 19:10:10 +00:00
{
2026-11-10 10:00:18 +00:00
var e = onPath [ idx + + ] ;
if ( dmgBack & & e . Faction ! = u . Faction ) PierceHit ( u , e , rdir , o , 1f ) ;
2026-11-03 19:10:10 +00:00
}
} ) ;
u . Position = end ;
o . Path . Add ( end ) ;
2026-11-10 10:00:18 +00:00
o . Returned = true ;
DashEnd ( u ) ;
if ( sweep )
2026-11-03 19:10:10 +00:00
{
2026-11-10 10:00:18 +00:00
// 쓸어 감: 경로 위 유닛을 도착점 앞(복귀 방향)으로 줄지어 민다
float off = u . Radius ;
foreach ( var e in onPath )
2026-11-03 19:10:10 +00:00
{
2026-11-10 10:00:18 +00:00
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 ) ;
2026-11-03 19:10:10 +00:00
}
2026-11-10 10:00:18 +00:00
if ( onPath . Count > 0 ) o . Notes . Add ( "복귀하며 쓸어 감" ) ;
2026-11-03 19:10:10 +00:00
}
2026-11-10 10:00:18 +00:00
battle . NotifyChanged ( ) ;
}
2026-11-03 19:10:10 +00:00
2026-11-10 10:00:18 +00:00
/// <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 )
2026-11-03 19:10:10 +00:00
{
2026-11-10 10:00:18 +00:00
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 ;
2026-11-05 14:31:27 +00:00
}
2026-11-10 10:00:18 +00:00
if ( any )
2026-11-05 16:20:07 +00:00
{
2026-11-10 10:00:18 +00:00
o . Notes . Add ( "끌고 옴" ) ;
yield return Fx . Wait ( 0.3f ) ;
2026-11-05 16:20:07 +00:00
}
2026-11-10 10:00:18 +00:00
battle . NotifyChanged ( ) ;
}
2026-11-05 16:20:07 +00:00
2026-11-10 10:00:18 +00:00
/// <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 + + )
2026-11-05 14:31:27 +00:00
{
2026-11-10 10:00:18 +00:00
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 )
2026-11-05 14:31:27 +00:00
{
2026-11-10 10:00:18 +00:00
travel = Mathf . Max ( 0f , gDist - Combat . Epsilon ) ;
n = gN ;
2026-11-05 14:31:27 +00:00
}
2026-11-10 10:00:18 +00:00
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 ;
2026-11-03 19:10:10 +00:00
}
2026-11-10 10:00:18 +00:00
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 ) ;
2026-11-03 19:10:10 +00:00
}
2026-11-05 14:31:27 +00:00
void PierceHit ( Unit cav , Unit e , Vector2 dir , ActionOutcome o , float dmgMult )
2026-11-03 19:10:10 +00:00
{
if ( ! e . IsAlive ) return ;
2026-11-10 10:00:18 +00:00
int dmg = SkillSystem . Deal ( e , Combat . Damage ( cfg , cav , e , false , rng , true , dmgMult ) , cav , HitKind . Attack ) ;
2026-11-03 19:10:10 +00:00
o . Attacked . Add ( e ) ;
o . DamageDealt + = dmg ;
Fx . Popup ( e . Position , dmg . ToString ( ) , Color . white ) ;
2026-11-10 10:00:18 +00:00
// 옆으로 밀어내기(거구 = 밀리지 않음)
2026-11-03 19:10:10 +00:00
Vector2 side = new Vector2 ( - dir . y , dir . x ) ;
float sign = Vector2 . Dot ( e . Position - cav . Position , side ) > = 0f ? 1f : - 1f ;
if ( e . IsAlive )
{
2026-11-10 10:00:18 +00:00
float kb = cfg . cavalryKnockback * SkillMods . KnockbackTakenMult ( e , true ) ;
2026-11-05 14:31:27 +00:00
Vector2 from = e . Position ;
2026-11-05 16:20:07 +00:00
Vector2 to = KnockTo ( e , side * sign , kb ) ; // 늪 0 · 빙판 × · 장애물·함정에서 멈춤 · 다리 → 강(PD #1097)
2026-11-03 19:10:10 +00:00
Fx . Run ( Fx . MoveAlong ( e . transform , e . Position , to , cfg . moveSpeed * 0.6f , null ) ) ;
2026-11-05 14:31:27 +00:00
o . Displaced . Add ( new Displacement { Unit = e , From = from , To = to } ) ;
2026-11-03 19:10:10 +00:00
}
battle . NotifyChanged ( ) ;
}
2026-11-04 06:56:02 +00:00
/// <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 ;
}
2026-11-10 10:00:18 +00:00
/// <summary>종착점이 적(allies = 아군도)과 겹치면 경로를 되짚어 겹치지 않는 지점까지 물러선다 · at = 그 점에서 dir 반대로 되짚음.</summary>
Vector2 BackOffFromUnits ( Unit u , Vector2 origin , Vector2 dir , float travel , bool allies , Vector2 ? at = null )
2026-11-03 19:10:10 +00:00
{
2026-11-10 10:00:18 +00:00
Vector2 p0 = at ? ? origin + dir * travel ;
float maxBack = at . HasValue ? 4f : travel ;
for ( float b = 0f ; b < = maxBack + 1e-3f ; b + = 0.1f )
2026-11-03 19:10:10 +00:00
{
2026-11-10 10:00:18 +00:00
Vector2 p = p0 - dir * b ;
2026-11-03 19:10:10 +00:00
bool overlap = false ;
2026-11-10 10:00:18 +00:00
foreach ( var e in battle . Units )
2026-11-03 19:10:10 +00:00
{
2026-11-10 10:00:18 +00:00
if ( e = = null | | e = = u | | ! e . IsAlive | | ( ! allies & & e . Faction = = u . Faction ) ) continue ;
2026-11-03 19:10:10 +00:00
if ( Vector2 . Distance ( p , e . Position ) < u . Radius + e . Radius - 0.01f )
{
overlap = true ;
break ;
}
}
if ( ! overlap ) return p ;
}
2026-11-10 10:00:18 +00:00
return at ? ? origin ;
2026-11-03 19:10:10 +00:00
}
2026-11-05 04:06:32 +00:00
// ---------- 궁병 · 책사: 투사체 사격 (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 > ( ) ;
2026-11-05 14:31:27 +00:00
/// <summary>책사 광역 반경(화공 배율 반영).</summary>
public float SplashRadius ;
/// <summary>백기습영: 쏘기 전에 목표점 쪽으로 이동하는 거리(0 = 없음).</summary>
public float Teleport ;
public Vector2 Origin ;
2026-11-05 16:20:07 +00:00
/// <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 > ( ) ;
2026-11-05 04:06:32 +00:00
}
ShotPlan PlanShot ( Unit u , Vector2 origin , Vector2 dir , Vector2 target )
{
2026-11-05 14:31:27 +00:00
var p = new ShotPlan { From = origin , To = origin , Origin = origin , SplashRadius = SkillMods . SplashRadius ( u , cfg ) } ;
float range = SkillMods . Range ( u ) ;
2026-11-05 04:06:32 +00:00
// 조준 방향으로 사거리 + 접근 허용 거리 안의 첫 적(타겟 선정 = #1080 그대로). hitDist = 그 적 가장자리에 닿기까지의 이동 거리(= 가장자리 간격).
float hitDist ;
2026-11-05 14:31:27 +00:00
p . Target = Combat . FirstEnemyOnPath ( u , origin , dir , range + u . ArcherMoveAllow , out hitDist ) ;
2026-11-05 04:06:32 +00:00
if ( p . Target = = null ) return p ;
2026-11-05 16:20:07 +00:00
// 사거리 안이면 제자리 · 밖이면 가장자리가 사거리에 들어올 만큼만 다가간다(접근 허용 거리 상한 · 늪·고지 이동 비용 반영 PD #1097)
2026-11-10 11:36:58 +00:00
float need = Mathf . Clamp ( hitDist - range , 0f , MoveReach ( u , origin , dir , u . ArcherMoveAllow ) ) ;
2026-11-05 04:06:32 +00:00
p . Approach = need > 0.01f ? Combat . FreeTravel ( u , origin , dir , need , true ) : 0f ;
p . From = origin + dir * p . Approach ;
p . To = p . From ;
2026-11-05 14:31:27 +00:00
if ( hitDist - p . Approach > range + 0.05f ) return p ; // 아군·벽에 막혀 사거리에 못 들어감
2026-11-05 16:20:07 +00:00
Aim ( u , p , dir , target , range ) ;
return p ;
}
2026-11-05 04:06:32 +00:00
2026-11-05 16:20:07 +00:00
/// <summary>
/// 사격 위치(p.From)에서 타겟 방향 투사체 · 비행 거리 = 사거리(쏜 유닛 가장자리부터) · 경로 위 첫 유닛이 맞는다(아군 포함 · 관통 없음 · 백발백중 = 아군 통과) ·
/// 막는 기믹(덤불·바위·성문·성벽) · 파괴되는 기믹(폭약 수레) · 안개 안 유닛 제외 · 책사 = 광역 계획(AimSplash).
/// </summary>
void Aim ( Unit u , ShotPlan p , Vector2 dir , Vector2 target , float range )
{
2026-11-05 04:06:32 +00:00
p . Fires = true ;
Vector2 aim = p . Target . Position - p . From ;
Vector2 adir = aim . sqrMagnitude > 1e-6f ? aim . normalized : dir ;
float travel ;
2026-11-10 10:00:18 +00:00
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 ;
}
2026-11-05 04:06:32 +00:00
p . To = p . From + adir * ( p . Hit ! = null ? travel + cfg . arrowRadius : travel ) ;
2026-11-10 10:00:18 +00:00
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 ;
2026-11-05 04:06:32 +00:00
}
2026-11-03 19:10:10 +00:00
IEnumerator ArcherRoutine ( Unit u , Vector2 origin , Vector2 dir , float dist , Vector2 target , bool moveOnly , ActionOutcome o )
{
2026-11-10 10:00:18 +00:00
if ( ! moveOnly & & HasSpecialShot ( u ) )
{
yield return SpecialShot ( u , origin , dir , target , o ) ; // 장착 스킬 특수 사격(ActionResolver.Skills.cs)
yield break ;
}
2026-11-05 04:06:32 +00:00
var p = moveOnly ? null : PlanShot ( u , origin , dir , target ) ;
2026-11-05 14:31:27 +00:00
if ( p ! = null & & p . Teleport > 0.01f )
{
yield return Move ( u , p . Origin + dir * p . Teleport , o ) ; // 백기습영: 목표점까지 이동 후 사격
if ( p . Target = = null ) o . MovedOnly = true ;
}
2026-11-05 04:06:32 +00:00
if ( p = = null | | p . Target = = null )
2026-11-03 19:10:10 +00:00
{
2026-11-05 16:20:07 +00:00
// 공격 가능 범위에 적이 없으면 목표점까지 이동 · 쏘지 않는다 (PD 2026-11-04 2차 지시 · #1087 ⑥ 유지) · 장애물 = 튕김 · 늪·고지 = 이동 비용(PD #1097)
2026-11-10 11:36:58 +00:00
if ( p = = null | | p . Teleport < = 0.01f ) yield return MoveOrHit ( u , origin , dir , MoveReach ( u , origin , dir , dist ) , o ) ;
2026-11-03 19:10:10 +00:00
yield break ;
}
2026-11-05 16:20:07 +00:00
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 ③)
}
2026-11-05 04:06:32 +00:00
if ( ! p . Fires )
{
o . MovedOnly = true ; // 아군·벽에 막혀 사거리에 못 들어감
yield break ;
}
Face ( u , p . To - p . From ) ; // 제자리 사격도 쏘는 방향을 본다
yield return Shoot ( u , p , o ) ;
}
2026-11-03 19:10:10 +00:00
2026-11-05 04:06:32 +00:00
/// <summary>
/// 투사체 1발: 첫 피격 유닛에 사격 피해(아군이면 오사 · 상성 적용 · 반격 없음 · 명중 보상 없음 · 밀지 않으므로 규칙 ⑤ 없음)
2026-11-05 14:31:27 +00:00
/// → 책사는 그 자리 반경 안 다른 유닛 전원에게 광역(대상별 피해 × 광역 비율 · 같은 편은 × 아군 비율 · 크리 없음 · 화공·팔문 간파 반영).
2026-11-05 04:06:32 +00:00
/// </summary>
IEnumerator Shoot ( Unit u , ShotPlan p , ActionOutcome o )
{
bool strategist = u . Type = = UnitType . Strategist ;
2026-11-05 15:22:54 +00:00
if ( strategist ) BattleEvents . Raise ( BattleEvents . StrategistShots , u ) ; // 보물 조건 효과(PD #1093 C)
2026-11-05 04:06:32 +00:00
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 ) ;
2026-11-05 16:20:07 +00:00
if ( p . Hit = = null & & p . HitGimmick = = null )
2026-11-03 19:10:10 +00:00
{
2026-11-05 04:06:32 +00:00
yield return Fx . Wait ( 0.1f ) ; // 빗나감(벽 · 비행 끝)
yield break ;
2026-11-03 19:10:10 +00:00
}
2026-11-05 04:06:32 +00:00
2026-11-05 16:20:07 +00:00
Vector2 center = p . HasSplash ? p . SplashCenter : p . To ;
2026-11-10 11:36:58 +00:00
float healAll = SkillMods . Sum ( u , SkillKeys . SplashHeal , true ) ; // 손건 화친 = 피해 없이 회복만(PD #1112 결정 ⑧)
2026-11-10 10:00:18 +00:00
if ( p . Hit ! = null )
{
2026-11-10 11:36:58 +00:00
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 ) )
2026-11-10 10:00:18 +00:00
{
// 아군 명중 = 회복·버프(순욱·소교·간옹·미축) · 감녕 = 아군 곁으로 이동 후 재공격 — 피해 없음
}
else ShotDamage ( u , p . Hit , Combat . Damage ( cfg , u , p . Hit , p . Crit , rng , false ) , p . Crit , o ) ;
}
2026-11-05 16:20:07 +00:00
else yield return ArrowHitsGimmick ( u , p , o ) ; // 덤불·성문 막힘 · 수레 파괴(PD #1097)
2026-11-10 10:00:18 +00:00
yield return ExtraArrows ( u , p , o ) ; // 하후연 삼연시(멀티샷) · 감녕 도탄
2026-11-05 16:20:07 +00:00
if ( strategist & & p . HasSplash )
2026-11-03 19:10:10 +00:00
{
2026-11-05 16:20:07 +00:00
yield return SplashGimmicks ( u , p , o ) ; // 덤불 제거 · 수레 파괴 · 화공 불(PD #1097)
2026-11-05 14:31:27 +00:00
Fx . Burst ( center , p . SplashRadius , Fx . SplashColor ) ;
2026-11-10 11:36:58 +00:00
if ( healAll > 0f )
2026-11-10 10:00:18 +00:00
{
2026-11-10 11:36:58 +00:00
// 손건 화친: 광역 = 피해 대신 회복(적 포함 · 명중 대상은 위에서 회복)
int amt = Mathf . RoundToInt ( SkillSystem . AtkOf ( u ) * healAll ) ;
2026-11-10 10:00:18 +00:00
var sys = SkillSystem . Current ;
foreach ( var s in p . Splash )
if ( s . IsAlive & & sys ! = null ) sys . Heal ( s , amt , u ) ;
}
else
2026-11-05 04:06:32 +00:00
{
2026-11-10 10:00:18 +00:00
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 ) ; // 이각 약탈
2026-11-05 04:06:32 +00:00
}
}
battle . NotifyChanged ( ) ;
yield return Fx . Wait ( 0.2f ) ;
}
2026-11-10 10:00:18 +00:00
/// <summary>사격 피해 적용 + 집계(상대 = Attacked · 같은 편 = FriendlyHit 오사). 반환 = 실제 피해.</summary>
int ShotDamage ( Unit a , Unit d , int dmg , bool crit , ActionOutcome o , HitKind kind = HitKind . Attack )
2026-11-05 04:06:32 +00:00
{
2026-11-10 10:00:18 +00:00
crit | = Combat . LastCrit ;
dmg = SkillSystem . Deal ( d , dmg , a , kind ) ;
2026-11-05 04:06:32 +00:00
if ( d . Faction = = a . Faction )
{
o . FriendlyHit . Add ( d ) ;
o . FriendlyDamage + = dmg ;
Fx . Popup ( d . Position , dmg + " 오사" , Fx . FriendlyFireColor ) ;
2026-11-10 10:00:18 +00:00
return dmg ;
2026-11-03 19:10:10 +00:00
}
2026-11-05 04:06:32 +00:00
o . Attacked . Add ( d ) ;
o . DamageDealt + = dmg ;
o . Crit | = crit ;
2026-11-05 15:22:54 +00:00
if ( crit & & a . Type = = UnitType . Archer ) BattleEvents . Raise ( BattleEvents . ArcherCrits , a ) ; // 보물 조건 효과(PD #1093 C)
2026-11-05 04:06:32 +00:00
Fx . Popup ( d . Position , crit ? dmg + "!" : dmg . ToString ( ) , crit ? new Color ( 1f , 0.7f , 0.2f ) : Color . white ) ;
2026-11-10 10:00:18 +00:00
return dmg ;
2026-11-03 19:10:10 +00:00
}
// ---------- 공통 ----------
2026-11-04 06:56:02 +00:00
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 ;
}
2026-11-10 04:19:44 +00:00
/// <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 ) ;
}
2026-11-04 08:58:30 +00:00
/// <summary>보는 방향 갱신(PD #1082 · 마지막 이동 방향 · 4방향 = 앞/뒤 세트 + 좌우 반전).</summary>
static void Face ( Unit u , Vector2 dir )
2026-11-03 19:10:10 +00:00
{
2026-11-04 08:58:30 +00:00
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 ) ;
2026-11-03 19:10:10 +00:00
yield return Fx . MoveAlong ( u . transform , u . Position , to , cfg . moveSpeed , null ) ;
u . Position = to ;
o . Path . Add ( to ) ;
}
2026-11-04 08:32:07 +00:00
/// <summary>등속 접근(감속 없음) — 부딪히는 순간까지 속도를 유지해 충돌 반응이 바로 이어진다.</summary>
IEnumerator MoveStraight ( Unit u , Vector2 to , ActionOutcome o )
{
2026-11-04 08:58:30 +00:00
Face ( u , to - u . Position ) ;
2026-11-04 08:32:07 +00:00
yield return Fx . MoveAlong ( u . transform , u . Position , to , cfg . moveSpeed , null , false ) ;
u . Position = to ;
o . Path . Add ( to ) ;
}
2026-11-05 14:31:27 +00:00
/// <summary>부딪힌 순간의 피해 교환(연출 대기 없음): 공격 피해 → 상대가 살아 있으면 반격 피해. 결과는 행동 유닛 요약에 집계. 반환 = 준 피해.</summary>
int ImpactExchange ( Unit a , Unit d , ActionOutcome o )
2026-11-04 08:32:07 +00:00
{
if ( a . View ! = null ) a . View . PlayAttack ( ) ;
2026-11-10 10:00:18 +00:00
int raw = Combat . Damage ( cfg , a , d , false , rng , true ) ;
bool crit = Combat . LastCrit ;
int dmg = SkillSystem . Deal ( d , raw , a , HitKind . Attack ) ;
2026-11-04 08:32:07 +00:00
o . Attacked . Add ( d ) ;
o . DamageDealt + = dmg ;
2026-11-10 10:00:18 +00:00
o . Crit | = crit ;
Fx . Popup ( d . Position , crit ? dmg + "!" : dmg . ToString ( ) , crit ? new Color ( 1f , 0.7f , 0.2f ) : Color . white ) ;
2026-11-04 08:32:07 +00:00
if ( d . IsAlive & & a . IsAlive )
{
2026-11-10 10:00:18 +00:00
int c = Combat . CounterDamage ( cfg , d , a , rng , true ) ; // 부딪혀 밀리는 쪽의 반격(받는 반격 % · 반격 피해 %)
2026-11-05 14:31:27 +00:00
if ( c > 0 )
{
if ( d . View ! = null ) d . View . PlayAttack ( ) ;
2026-11-10 10:00:18 +00:00
c = SkillSystem . Deal ( a , c , d , HitKind . Counter ) ;
2026-11-05 14:31:27 +00:00
o . CounterTaken + = c ;
Fx . Popup ( a . Position , c . ToString ( ) , new Color ( 1f , 0.45f , 0.45f ) ) ;
}
2026-11-04 08:32:07 +00:00
}
battle . NotifyChanged ( ) ;
2026-11-05 14:31:27 +00:00
return dmg ;
2026-11-04 08:32:07 +00:00
}
2026-11-05 14:31:27 +00:00
/// <summary>
/// 알까기 양쪽 튕김(동시 시작): 부딪힌 유닛 = n 방향 · 남은 힘 × collisionPushRatio × 밀기 배율(청룡도 지기·호치·함진영) × 대상 밀림 배율(거구)
/// / 행동 유닛 = − n 방향 · 남은 힘 × collisionSelfBounceRatio × 자기 튕김 배율(인덕·호치 = 0). impactDmg = 함진영 충돌 추가 피해 기준.
2026-11-05 16:20:07 +00:00
/// chain = 적과 싸운 뒤의 튕김(PD #1098): 튕겨나는 길에 다른 적이 있으면 그 적과 피해·반격 교환 → 그 적 기준으로 다시 튕김(연쇄 1회) · 아군이면 밀기만.
2026-11-05 14:31:27 +00:00
/// </summary>
2026-11-05 16:20:07 +00:00
IEnumerator Bounce ( Unit striker , Unit struck , Vector2 n , float remaining , ActionOutcome o , int impactDmg , bool chain = false )
2026-11-04 08:32:07 +00:00
{
bool struckDone = true ;
2026-11-05 14:31:27 +00:00
float force = struck ! = null ? remaining * cfg . collisionPushRatio * SkillMods . PushMult ( striker , true ) * SkillMods . KnockbackTakenMult ( struck , true ) : 0f ;
if ( struck ! = null & & struck . IsAlive & & force > = 0.01f )
2026-11-04 08:32:07 +00:00
{
struckDone = false ;
2026-11-10 10:00:18 +00:00
Fx . Run ( Track ( Push ( struck , n , force , striker , o ) , ( ) = > struckDone = true ) ) ;
2026-11-04 08:32:07 +00:00
}
2026-11-10 10:00:18 +00:00
// 자기 튕김 × 자기 배율(허저 × 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 ) ) ;
2026-11-05 16:20:07 +00:00
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 ;
}
}
2026-11-04 08:32:07 +00:00
if ( self > = 0.01f & & striker . IsAlive )
{
float back = Combat . FreeTravel ( striker , striker . Position , - n , self , true , true , struck ) ;
2026-11-05 16:42:15 +00:00
NoteForced ( striker , striker . Position , striker . Position - n * back ) ;
2026-11-04 08:32:07 +00:00
if ( back > 0.01f )
{
2026-11-04 08:58:30 +00:00
Face ( striker , - n ) ; // 튕겨 나온 방향을 본다(PD #1082)
2026-11-04 08:32:07 +00:00
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 ( ) ;
}
2026-11-05 14:31:27 +00:00
/// <summary>부딪힌 유닛을 n 방향으로 force 만큼 밀어낸다(다른 유닛·벽에 닿으면 멈춤). 밀어 주는 유닛(pusher)은 막는 것으로 보지 않는다. onEnd(true) = 다 못 가고 막힘.</summary>
IEnumerator Push ( Unit target , Vector2 n , float force , Unit pusher , ActionOutcome o , System . Action < bool > onEnd = null )
2026-11-04 06:56:02 +00:00
{
if ( target = = null | | ! target . IsAlive | | force < 0.01f ) yield break ;
2026-11-05 15:22:54 +00:00
if ( pusher ! = null & & pusher . Faction ! = target . Faction ) BattleEvents . Raise ( BattleEvents . Pushes , pusher ) ; // 보물 조건 효과(PD #1093 C)
2026-11-05 16:20:07 +00:00
force = Reach ( target , target . Position , n , force ) ; // 늪 = 밀리는 거리도 이동 비용(PD #1097)
2026-11-04 06:56:02 +00:00
float slide = Combat . FreeTravel ( target , target . Position , n , force , true , true , pusher ) ;
2026-11-05 16:42:15 +00:00
NoteForced ( target , target . Position , target . Position + n * slide ) ; // 강제 이동 기록 → 강가·다리에서 밀려 강물에 들면 빠짐
2026-11-05 14:31:27 +00:00
float moved = 0f ;
2026-11-04 06:56:02 +00:00
if ( slide > 0.01f )
{
2026-11-04 08:58:30 +00:00
Face ( target , n ) ; // 밀린 방향을 본다(PD #1082)
2026-11-05 14:31:27 +00:00
Vector2 start = target . Position ;
2026-11-04 06:56:02 +00:00
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 ;
2026-11-05 14:31:27 +00:00
moved = Vector2 . Distance ( start , end ) ;
o . Displaced . Add ( new Displacement { Unit = target , From = start , To = end } ) ;
2026-11-04 06:56:02 +00:00
}
o . Pushed . Add ( target ) ;
battle . NotifyChanged ( ) ;
2026-11-05 14:31:27 +00:00
if ( onEnd ! = null ) onEnd ( moved < force - Combat . Epsilon * 2f ) ;
2026-11-04 06:56:02 +00:00
}
2026-11-05 14:31:27 +00:00
/// <summary>
/// 규칙 ⑤: 밀린 아군이 상대 유닛과 닿아 있으면 그 자리에서 공격(근접 · 자기 행동 소모 없음) — 기본 1회 ·
/// 민 유닛의 군령(pushed_ally_attack_count = n 회) · 대장군(pushed_ally_attack_on_push = +n 회).
/// </summary>
IEnumerator PushedAllyAttack ( Unit ally , ActionOutcome o , Unit pusher )
2026-11-04 06:56:02 +00:00
{
if ( ally = = null | | ! ally . IsAlive ) yield break ;
2026-11-05 14:31:27 +00:00
if ( Combat . TouchingOpponent ( ally ) = = null ) yield break ;
2026-11-10 10:00:18 +00:00
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 ) ;
2026-11-04 06:56:02 +00:00
}
2026-11-10 10:00:18 +00:00
/// <summary>
/// 밀린 유닛의 근접 공격 1회(반격 받음 · Notes 에 기록) — 장합 교변: 민 장합의 공격력 × p1 을 더함 · 장합 자신이 밀려 친 공격 = × p1 ·
/// 고순 함진영: 민 고순의 「명중 기절」이 이 공격에도(간접 명중). 원거리 병종도 근접 패널티를 받는다.
/// </summary>
IEnumerator PushedAttack ( Unit a , Unit d , ActionOutcome o , Unit pusher = null )
2026-11-03 19:10:10 +00:00
{
2026-11-04 04:05:46 +00:00
if ( a . View ! = null ) a . View . PlayAttack ( ) ;
2026-11-05 04:06:32 +00:00
yield return Fx . Lunge ( a , d . Position ) ;
2026-11-03 19:10:10 +00:00
2026-11-10 10:00:18 +00:00
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 ) ;
2026-11-05 04:06:32 +00:00
Fx . Popup ( d . Position , dmg . ToString ( ) , Color . white ) ;
2026-11-03 19:10:10 +00:00
battle . NotifyChanged ( ) ;
yield return Fx . Wait ( 0.2f ) ;
2026-11-04 06:56:02 +00:00
int counter = 0 ;
2026-11-05 04:06:32 +00:00
if ( d . IsAlive & & a . IsAlive )
2026-11-03 19:10:10 +00:00
{
2026-11-05 14:31:27 +00:00
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 ) ;
2026-11-10 10:00:18 +00:00
counter = SkillSystem . Deal ( a , counter , d , HitKind . Counter ) ;
2026-11-05 14:31:27 +00:00
Fx . Popup ( a . Position , counter . ToString ( ) , new Color ( 1f , 0.45f , 0.45f ) ) ;
battle . NotifyChanged ( ) ;
yield return Fx . Wait ( 0.2f ) ;
}
2026-11-03 19:10:10 +00:00
}
2026-11-04 06:56:02 +00:00
2026-11-05 04:06:32 +00:00
o . Notes . Add ( "밀린 " + a . DisplayName + " → " + d . DisplayName + " 공격 " + dmg + ( counter > 0 ? " · 반격 " + counter : "" ) ) ;
2026-11-03 19:10:10 +00:00
}
}
}