2026-10-18 15:09:05 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLSkillCancel.cs — 스킬 중 이동 · 구르기 = 스킵(그 스킬 이펙트 즉시 끔) · 점프 스킬 = 이동 입력 무시 · 이동 키를 누른 채 쓴 스킬은 발동까지 유지 (#908 · 2026-10-17)
/ /
// PD 원문: 「검 각성스킬 사용 중 이동하거나 구르기를 할 경우, 이펙트가 즉시 스킵되며 재생되지 않아야 … (애니메이션이 연속 재생되는 스킬의 경우,
// 특별한 지시가 없는 한 기본적으로 스킵이 가능하도록 수정해줘)」 · 「(점프로 날아가는 스킬이 발동중에는 플레이어의 이동조작을 무시해야 해)」 ·
// 「사정거리 밖에서 Q를 눌러 방패 치기를 발동하려고 할 때 대쉬만하고 방패치기가 발동되지 않는 현상」
// ■ 실측 원인(#908) — 이동 키를 누른 채 Q 를 누르면 다음 프레임의 이동 입력(PCActor.Run_byJoystick → 달리기)이 막 시작한 스킬을 끊었다
// (방패 치기 = 대쉬 1 프레임 뒤 달리기 · 해머 강력한 일격 = 점프 1 프레임 뒤 달리기 · 둘 다 발동 0).
// ■ 처리
// ① 스킬 시작(CombatEvents.SkillCast) 때 이동 키가 눌려 있었으면 그 입력은 첫 발동(SkillFired)까지 무시 — 손을 뗐다 다시 누르면 새 입력(= 스킵).
// ② lockMove 스킬(점프로 날아가는 스킬)은 스킬 동작이 끝날 때까지 이동 · 구르기 입력을 무시.
// ③ 그 밖에 스킬 동작 중 이동(달리기)이나 구르기로 스킬 상태를 벗어나면 = 스킵: 그 스킬의 원본 투사체(InGameInfo 스킬 풀 · 이 PC 가 쏜 켜진 것)를 끄고
// 스킬 연출(SkillSpectacle 차지 · 본체)을 끈다. noSkip 스킬은 제외.
// 무시한 동안 들어온 PCActor.Set_AutoBraking(누름 false · 뗌 true)은 미뤘다가 잠금이 풀릴 때 마지막 값을 적용한다.
// ④ #912 HoldMove — 파츠 스킬이 정한 시간 동안 이동 입력 무시(해머 각성 「내려친 상태로 잠시 멈추게」 · 구르기는 허용 = 스킵) · 그동안의 뗌은 EndHoldMove 에서 적용.
// ⑤ #914 endWithMotion — 스킬 동작이 입력 없이 끝나면(대기 등) 그 스킬 이펙트(투사체 · 연출)도 바로 끈다(PD 「검 각성 … 연속 공격 모션을 끝나는 시점에 맞춰서 이펙트도 소멸」).
// ■ 원본 훅 = PCActor.Run_byJoystick · Set_AutoBraking 첫 줄 각 1줄 · 값 = Resources/WL/WLSkillCancel.json (C45) · 끄기 = enabled 0
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections ;
using System.Reflection ;
using UnityEngine ;
using WL.Combat.Core ;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLSkillCancelJson
{
public int enabled = 1 ;
public int skipOnMove = 1 ; // 1 = 스킬 동작 중 이동 · 구르기 = 스킵(기본)
public string [ ] lockMove = new string [ 0 ] ; // 발동 중 이동 · 구르기 입력을 무시하는 스킬(eEffect 이름)
public string [ ] noSkip = new string [ 0 ] ; // 이동 · 구르기로 끊겨도 이펙트를 남기는 스킬
public string [ ] holdMove = new string [ 0 ] ; // #911 발동 뒤 공격 이펙트(이 PC 의 원본 스킬 투사체)가 끝날 때까지 이동 입력 무시 · 구르기는 허용(= 스킵)
public string [ ] endWithMotion = new string [ 0 ] ; // #914 스킬 동작이 입력 없이 끝나면(대기로) 그 스킬 이펙트도 바로 끈다 — 남은 이펙트 때문에 서 있지 않게
2026-10-18 17:27:34 +00:00
public string [ ] lockUntilFire = new string [ 0 ] ; // #942 시전 뒤 첫 발동(SkillFired)까지 이동 · 구르기 입력 무시(holdMaxSeconds 상한) — 발동 뒤엔 자유(투사체는 noSkip 으로 유지) · PD 「플레이어가 이동해도 발동 된 스킬이 끊겨선 안돼」(실측: 이동이 발동 이벤트 전에 시전 동작을 끊어 투사체가 아예 안 나왔다)
2026-10-18 15:09:05 +00:00
public int holdUntilFire = 1 ; // 1 = 이동 키를 누른 채 쓴 스킬은 첫 발동까지 그 입력을 무시
public float holdMaxSeconds = 1.5f ; // ① 의 상한(발동 이벤트가 없는 스킬 대비)
public string note = "" ;
}
public static class WLSkillCancel
{
public const string ResourcesPath = "WL/WLSkillCancel" ;
public static int Casts , Skips , SkippedObjects , MoveBlocked , DodgeBlocked , BrakeDeferred , BrakeApplied , Errors , MotionEnds ;
public static string LastLog = "" ;
static WLSkillCancelJson s_cfg ;
static PCActor s_pc ;
static string s_effect = "" ;
static float s_castAt ;
2026-10-18 17:27:34 +00:00
static bool s_active , s_fired , s_heldAtCast , s_lock , s_hold , s_lockFire ; // s_lockFire = #942 발동까지 잠금
2026-10-18 15:09:05 +00:00
static int s_pendingBrake = - 1 ; // -1 없음 · 0 = Set_AutoBraking(false) · 1 = Set_AutoBraking(true)
static GameObject s_runner ;
static FieldInfo s_fDicSkill ;
static PCActor s_extPc ; // #912 HoldMove 대상 · 끝 시각 · 그동안 미룬 뗌
static float s_extUntil ;
static bool s_extPending ;
internal static WLSkillCancelJson Cfg
{
get
{
if ( s_cfg ! = null ) return s_cfg ;
s_cfg = new WLSkillCancelJson ( ) ;
var ta = Resources . Load < TextAsset > ( ResourcesPath ) ;
if ( ta = = null ) { LastLog = "설정 없음 " + ResourcesPath ; return s_cfg ; }
try { JsonUtility . FromJsonOverwrite ( ta . text , s_cfg ) ; } catch ( System . Exception ex ) { LastLog = "설정 파싱 실패 — " + ex . Message ; }
return s_cfg ;
}
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot ( )
{
s_cfg = null ; s_active = false ; s_pc = null ; s_pendingBrake = - 1 ; s_extPc = null ; s_extPending = false ;
CombatEvents . SkillCast . Remove ( OnCast ) ; CombatEvents . SkillFired . Remove ( OnFired ) ;
if ( Cfg . enabled = = 0 ) return ;
CombatEvents . SkillCast . Add ( OnCast ) ; CombatEvents . SkillFired . Add ( OnFired ) ;
if ( s_runner = = null )
{
s_runner = new GameObject ( "[WL908] SkillCancel" ) ;
s_runner . hideFlags = HideFlags . HideAndDontSave ;
Object . DontDestroyOnLoad ( s_runner ) ;
s_runner . AddComponent < WLSkillCancelRunner > ( ) ;
}
}
static bool Has ( string [ ] list , string s )
{
if ( list = = null | | string . IsNullOrEmpty ( s ) ) return false ;
for ( int i = 0 ; i < list . Length ; i + + ) if ( list [ i ] = = s ) return true ;
return false ;
}
static void OnCast ( in SkillCastEvent e )
{
var pc = e . actor as PCActor ;
if ( pc = = null | | ! pc . IsMainPC ( ) ) return ;
var c = Cfg ;
s_pc = pc ; s_effect = e . skill ! = null ? e . skill . e_Skill . ToString ( ) : "" ;
s_castAt = Time . time ; s_active = true ; s_fired = false ;
s_heldAtCast = pc . JoystickStatus = = 1 ;
s_lock = Has ( c . lockMove , s_effect ) ;
s_hold = Has ( c . holdMove , s_effect ) ;
2026-10-18 17:27:34 +00:00
s_lockFire = Has ( c . lockUntilFire , s_effect ) ;
2026-10-18 15:09:05 +00:00
Casts + + ;
2026-10-18 17:27:34 +00:00
LastLog = "시작 " + s_effect + ( s_lock ? " · 이동 잠금" : "" ) + ( s_lockFire ? " · 발동까지 잠금" : "" ) + ( s_heldAtCast ? " · 이동 키 누른 채" : "" ) ;
2026-10-18 15:09:05 +00:00
}
static void OnFired ( in SkillFiredEvent e ) { if ( s_active & & ReferenceEquals ( e . actor , s_pc ) ) s_fired = true ; }
2026-10-20 12:36:12 +00:00
/// <summary>#952-④ 직업(무기) 강제 전환처럼 스킬이 끝 이벤트 없이 끊겼을 때 — 이 PC 의 시전 잠금·미룬 브레이크를 버린다(남으면 이동 입력을 계속 무시한다).</summary>
public static void ResetFor ( PCActor pc )
{
if ( pc = = null ) return ;
if ( ReferenceEquals ( pc , s_pc ) ) { s_active = false ; s_lock = s_hold = s_lockFire = s_heldAtCast = false ; s_pendingBrake = - 1 ; }
if ( ReferenceEquals ( pc , s_extPc ) ) { s_extPc = null ; s_extPending = false ; }
LastLog = "초기화(직업 전환) " + s_effect ;
}
2026-10-18 15:09:05 +00:00
/// <summary>#912 seconds 동안 이 PC 의 이동 입력을 무시한다(스킬 상태일 때만 · 구르기는 그대로 = 스킵).</summary>
public static void HoldMove ( PCActor pc , float seconds )
{
s_extPc = pc ; s_extUntil = Time . time + Mathf . Max ( 0f , seconds ) ; s_extPending = false ;
}
/// <summary>#912 멈춤 끝 — apply 면 그동안 미룬 뗌(Set_AutoBraking(true) = 대기 · 브레이크 원복)을 지금 적용 · 끊김이면 버린다.</summary>
public static void EndHoldMove ( PCActor pc , bool apply )
{
if ( s_extPc = = null | | ! ReferenceEquals ( pc , s_extPc ) ) return ;
s_extPc = null ; bool pending = s_extPending ; s_extPending = false ;
if ( apply & & pending & & ! DSUtil . CheckNull ( pc ) & & ! pc . IsDead ( ) ) { pc . Set_AutoBraking ( true ) ; BrakeApplied + + ; }
}
static bool ExtBlocking ( PCActor pc )
{
return s_extPc ! = null & & ReferenceEquals ( pc , s_extPc ) & & Time . time < s_extUntil & & ! pc . IsDead ( ) & & pc . Get_CurAnim ( ) = = eAnim . Skill ;
}
static bool Blocking ( PCActor pc )
{
if ( ExtBlocking ( pc ) ) return true ;
if ( s_hold & & ReferenceEquals ( pc , s_pc ) )
{
// #911 이펙트가 남아 있는 동안 — 스킬 동작이 끝나 대기로 돌아왔어도(이펙트가 더 길다) 이동 입력 무시 · 구르기로 끊긴 뒤엔 풀림
var cur = pc . Get_CurAnim ( ) ;
if ( cur = = eAnim . Dodge | | pc . IsDead ( ) ) { s_hold = false ; return false ; }
if ( ( s_active & & cur = = eAnim . Skill ) | | EffectActive ( pc , s_effect ) ) return true ;
s_hold = false ;
}
if ( ! s_active | | ! ReferenceEquals ( pc , s_pc ) | | pc . Get_CurAnim ( ) ! = eAnim . Skill ) return false ;
if ( s_lock ) return true ;
var c = Cfg ;
2026-10-18 17:27:34 +00:00
if ( s_lockFire & & ! s_fired & & Time . time - s_castAt < c . holdMaxSeconds ) return true ; // #942 발동 전엔 새 이동 입력도 무시
2026-10-18 15:09:05 +00:00
return c . holdUntilFire ! = 0 & & s_heldAtCast & & ! s_fired & & Time . time - s_castAt < c . holdMaxSeconds ;
}
/// <summary>PCActor.Run_byJoystick 첫 줄 훅 — true = 이 이동 입력을 무시한다.</summary>
public static bool BlockMove ( PCActor pc )
{
try { if ( Blocking ( pc ) ) { MoveBlocked + + ; return true ; } }
catch ( System . Exception ex ) { Errors + + ; LastLog = "이동 확인 실패 — " + ex . Message ; }
return false ;
}
/// <summary>WLDoubleTapDodge 훅 — 이동 잠금 스킬(점프) 중엔 구르기도 무시.</summary>
public static bool BlockDodge ( PCActor pc )
{
2026-10-18 17:27:34 +00:00
if ( pc = = null | | ! s_active | | ! ReferenceEquals ( pc , s_pc ) | | pc . Get_CurAnim ( ) ! = eAnim . Skill ) return false ;
if ( ! s_lock & & ! ( s_lockFire & & ! s_fired & & Time . time - s_castAt < Cfg . holdMaxSeconds ) ) return false ; // #942 발동 전 구르기도 무시
2026-10-18 15:09:05 +00:00
DodgeBlocked + + ; return true ;
}
/// <summary>PCActor.Set_AutoBraking 첫 줄 훅 — 입력을 무시하는 동안의 누름 · 뗌은 미뤘다가 잠금이 풀릴 때 적용(true = 원본은 지금 아무것도 안 함).</summary>
public static bool DeferBrake ( PCActor pc , bool active )
{
try
{
if ( ExtBlocking ( pc ) )
{
// #912 멈춤 중 — 누름(구르기 시작 포함)은 그대로 · 뗌(= Play_Idle)만 멈춤이 끝날 때(EndHoldMove) 적용
if ( ! active ) { s_extPending = false ; return false ; }
s_extPending = true ; BrakeDeferred + + ; return true ;
}
if ( ! Blocking ( pc ) ) return false ;
// #911 이펙트 끝까지 이동 잠금(holdMove)만 걸린 경우 누름(false)은 그대로 둔다 — 원본 구르기(Play_Dodge)가 시작할 때 부르는 Set_AutoBraking(false)까지 미루면
// 구르기가 곧바로 대기로 끊겼다(실측). 뗌(true = Play_Idle)만 미뤄 스킬 동작이 중간에 끊기지 않게 한다.
if ( ! active & & s_hold & & ! s_lock & & ! ( Cfg . holdUntilFire ! = 0 & & s_heldAtCast & & ! s_fired ) ) return false ;
s_pendingBrake = active ? 1 : 0 ;
if ( active ) s_heldAtCast = false ; // 손을 뗐다 → 다음 누름은 새 입력
BrakeDeferred + + ;
return true ;
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "미루기 실패 — " + ex . Message ; return false ; }
}
static void ApplyBrake ( PCActor pc )
{
int b = s_pendingBrake ; s_pendingBrake = - 1 ;
if ( b < 0 | | DSUtil . CheckNull ( pc ) | | pc . IsDead ( ) ) return ;
pc . Set_AutoBraking ( b = = 1 ) ;
BrakeApplied + + ;
}
internal static void Tick ( )
{
var pc = s_pc ;
if ( ! s_active & & s_hold & & ! DSUtil . CheckNull ( pc ) & & ! Blocking ( pc ) & & s_pendingBrake > = 0 ) ApplyBrake ( pc ) ; // #911 이펙트가 끝나 이동 잠금이 풀리면 미룬 누름 · 뗌 적용
if ( ! s_active | | DSUtil . CheckNull ( pc ) ) return ;
try
{
if ( pc . IsDead ( ) ) { s_active = false ; s_pendingBrake = - 1 ; return ; }
var cur = pc . Get_CurAnim ( ) ;
if ( cur = = eAnim . Skill )
{
// ① 이 끝났는데(발동 · 상한) 이동 키가 아직 눌려 있으면 미룬 누름을 지금 적용 — 이어지는 이동 입력이 평소 규칙(스킵)대로 동작
if ( s_pendingBrake = = 0 & & ! s_lock & & ! Blocking ( pc ) ) ApplyBrake ( pc ) ;
return ;
}
s_active = false ;
// 스킬 상태에서 바로 달리기 · 구르기 = 입력으로 끊김(자연 종료는 Event_Idle → 대기). 원본 구르기는 같은 프레임에 달리기로 넘어가 Dodge 를 못 볼 때가 있다(실측)
bool byInput = cur = = eAnim . Run | | cur = = eAnim . Dodge ;
var c = Cfg ;
if ( byInput & & c . skipOnMove ! = 0 & & ! Has ( c . noSkip , s_effect ) ) Skip ( pc , s_effect , cur = = eAnim . Dodge ? "구르기" : "이동" ) ;
else if ( ! byInput & & Has ( c . endWithMotion , s_effect ) ) { MotionEnds + + ; Skip ( pc , s_effect , "동작 끝" ) ; } // #914 동작 끝 = 이펙트 끝(이동 잠금 holdMove 도 함께 풀림)
if ( s_pendingBrake > = 0 & & ! Blocking ( pc ) ) ApplyBrake ( pc ) ;
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "틱 실패 — " + ex . Message ; s_active = false ; }
}
/// <summary>#911 이 PC 가 쏜 그 스킬의 원본 투사체(InGameInfo 스킬 풀)가 아직 켜져 있나.</summary>
static bool EffectActive ( PCActor pc , string effect )
{
eEffect ef ;
if ( InGameInfo . Ins = = null | | ! System . Enum . TryParse ( effect , out ef ) ) return false ;
if ( s_fDicSkill = = null ) s_fDicSkill = typeof ( InGameInfo ) . GetField ( "dic_Skill" , BindingFlags . Instance | BindingFlags . NonPublic | BindingFlags . Public ) ;
var dic = s_fDicSkill ! = null ? s_fDicSkill . GetValue ( InGameInfo . Ins ) as IDictionary : null ;
var list = dic ! = null & & dic . Contains ( ef ) ? dic [ ef ] as IList : null ;
if ( list = = null ) return false ;
foreach ( var o in list )
{
var go = o as GameObject ;
if ( go = = null | | ! go . activeInHierarchy ) continue ;
var pb = go . GetComponent < ProjectileBase > ( ) ; var data = pb ! = null ? pb . Get_ProjectTileData ( ) : null ;
if ( data ! = null & & ReferenceEquals ( data . shooter , pc ) ) return true ;
}
return false ;
}
/// <summary>스킵 — 그 스킬의 원본 투사체(이 PC 가 쏜 켜진 것)와 스킬 연출(차지 · 본체)을 끈다.</summary>
static void Skip ( PCActor pc , string effect , string why )
{
int n = 0 ;
eEffect ef ;
if ( InGameInfo . Ins ! = null & & System . Enum . TryParse ( effect , out ef ) )
{
if ( s_fDicSkill = = null ) s_fDicSkill = typeof ( InGameInfo ) . GetField ( "dic_Skill" , BindingFlags . Instance | BindingFlags . NonPublic | BindingFlags . Public ) ;
var dic = s_fDicSkill ! = null ? s_fDicSkill . GetValue ( InGameInfo . Ins ) as IDictionary : null ;
var list = dic ! = null & & dic . Contains ( ef ) ? dic [ ef ] as IList : null ;
if ( list ! = null )
foreach ( var o in list )
{
var go = o as GameObject ;
if ( go = = null | | ! go . activeInHierarchy ) continue ;
var pb = go . GetComponent < ProjectileBase > ( ) ;
var data = pb ! = null ? pb . Get_ProjectTileData ( ) : null ;
if ( data = = null | | ! ReferenceEquals ( data . shooter , pc ) ) continue ;
WLSlashFlurry . Off ( pb ) ; n + + ;
}
}
n + = WL . Combat . Reaction . SkillSpectacle . CancelOwner ( pc ) ;
Skips + + ; SkippedObjects + = n ;
LastLog = "스킵 #" + Skips + " " + effect + " · " + why + " · 끈 것 " + n ;
}
}
internal sealed class WLSkillCancelRunner : MonoBehaviour
{
void LateUpdate ( ) { WLSkillCancel . Tick ( ) ; }
}
}