2026-10-18 15:09:05 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLSkillChain.cs — 조건 발동 스킬: 무기 기술 표 trigger 칸(예: hit:Skill_ShieldAttack)의 스킬을 그 조건 때 쿨 없이 시전
/ /
// PD 원문(#890 · 2026-10-17): 「한손검 전설 스킬은 별도의 쿨타임 없이 "방패 치기 명중 시" 발동 되도록 해줘.」
/ /
// ■ 원리 — 타격 확정 훅(CombatEvents.HitConfirmed)에서 내 PC 가 쏜 스킬(피해 정보 Beater_pd)이 명중하면, 낀 무기의 표 행 중
// trigger = "hit:<그 스킬 eEffect>" 이고 등급이 해금 단계 이상인 행을 찾아 delay 초 뒤 시전한다(같은 프레임 재진입 회피).
// 원본 Use_Skill 은 스킬 동작 중이면 실패한다 → cancelTrigger 1 이면 앞 스킬의 남은 동작을 Play_Idle 로 끊고 바로 잇는다.
// 시전 = 시험 모드(쿨 무시 · MP 0 복사본 · 사거리 = 표 range_m) — 그 행은 자동 시전 슬롯에 안 들어간다(WLWeaponSkillSlots).
// ■ 값 = Resources/WL/WLSkillChain.json (C45) · 끄기 = enabled 0(구독 0) · 원본 수정 0줄
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic ;
using System.Reflection ;
using UnityEngine ;
using WL.Combat.Core ;
using WL.Equip ;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLSkillChainJson
{
public int enabled = 0 ;
public float delay = 0.12f ; // 명중 → 시전까지(s)
public int cancelTrigger = 1 ; // 1 = 앞 스킬 남은 동작을 끊고 잇는다 · 0 = 끝날 때까지 기다린다
public float maxWait = 1f ; // cancelTrigger 0 일 때 최대 대기(s)
public float minRange = 0f ; // 이어 쓰는 스킬의 시전 거리 하한(m) — 명중 직후라 대상이 밀려나도 끊기지 않게
public string note = "" ;
}
public static class WLSkillChain
{
public const string ResourcesPath = "WL/WLSkillChain" ;
public static int Triggered , Cast , Failed ;
public static string LastLog = "" , LastFail = "" ;
static WLSkillChainJson s_cfg ;
static WLSkillChainRunner s_runner ;
static PCActor s_pc ;
static WLWeaponSkillRow s_row ;
static float s_at , s_deadline ;
static bool s_pending ;
static readonly Dictionary < int , SkillListTableData > s_data = new Dictionary < int , SkillListTableData > ( ) ;
static WLSkillChainJson Cfg
{
get
{
if ( s_cfg ! = null ) return s_cfg ;
s_cfg = new WLSkillChainJson ( ) ;
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_pending = false ; s_data . Clear ( ) ;
if ( Cfg . enabled = = 0 ) { CombatEvents . HitConfirmed . Remove ( OnHit ) ; return ; }
CombatEvents . HitConfirmed . Add ( OnHit ) ;
}
static void OnHit ( in HitConfirmedEvent e )
{
try
{
if ( s_pending ) return ; // 이미 예약됨(같은 시전의 다단 히트)
var sk = e . projectile ! = null ? e . projectile . m_SkillListTableData : null ;
var pc = e . attacker as PCActor ;
if ( sk = = null | | pc = = null | | ! pc . IsMainPC ( ) ) return ;
var w = WLInventory . EquippedInSlot ( eFREquipmentType . Weapon ) ;
if ( w = = null | | w . itemJson = = null ) return ;
var rows = WLWeaponSkillTable . Rows ( w . itemJson . GroupNo ) ;
if ( rows = = null ) return ;
string trig = "hit:" + sk . e_Skill ;
WLGradeTier gt ; int plus ;
int tier = WLEquipGrades . TryMap ( w . itemJson , out gt , out plus ) ? gt . tier : 1 ;
2026-10-21 04:05:18 +00:00
if ( WL . SkillTest . WLTestScene . AllAwakened ) tier = Mathf . Max ( tier , WLEquipGrades . MaxTier ) ; // #958 스킬 테스트 씬 = 모든 무기 각성 상태
2026-10-18 15:09:05 +00:00
foreach ( var r in rows )
{
if ( ! r . IsTriggered | | r . trigger ! = trig | | tier < r . unlockTier | | r . skillId = = sk . n_SkillID ) continue ;
var c = Cfg ;
s_pc = pc ; s_row = r ; s_at = Time . time + c . delay ; s_deadline = s_at + c . maxWait ; s_pending = true ;
EnsureRunner ( ) ;
Triggered + + ;
LastLog = "예약 " + r . skillName + " ← " + sk . e_Skill + " 명중 #" + Triggered ;
break ;
}
}
catch ( System . Exception ex ) { Failed + + ; LastLog = "명중 처리 실패 — " + ex . Message ; }
}
static void EnsureRunner ( )
{
if ( ! DSUtil . CheckNull ( s_runner ) ) return ;
var go = new GameObject ( "[WL890] SkillChainRunner" ) ;
go . hideFlags = HideFlags . HideAndDontSave ;
s_runner = go . AddComponent < WLSkillChainRunner > ( ) ;
}
internal static void Tick ( )
{
if ( ! s_pending | | Time . time < s_at ) return ;
var pc = s_pc ; var r = s_row ;
if ( DSUtil . CheckNull ( pc ) | | pc . IsDead ( ) | | r = = null ) { s_pending = false ; return ; }
var c = Cfg ;
// #908 이동 중(조이스틱 1 · 구르기)이면 잇지 않는다 — 이어 붙인 스킬이 이동 입력에 바로 스킵(WLSkillCancel)되어 첫 프레임만 번쩍였다(실측 2 프레임)
if ( pc . JoystickStatus = = 1 | | pc . Get_CurAnim ( ) = = eAnim . Dodge | | pc . Get_CurAnim ( ) = = eAnim . Run ) { s_pending = false ; Failed + + ; LastLog = "이동 중 — 연계 안 함 " + r . skillName ; return ; }
if ( pc . Get_CurAnim ( ) > = eAnim . Skill )
{
if ( c . cancelTrigger ! = 0 ) pc . Play_Idle ( ) ; // 앞 스킬(방패 치기)의 남은 동작을 끊고 잇는다
else if ( Time . time < s_deadline ) return ;
}
s_pending = false ;
try
{
var data = Prep ( pc , r ) ;
if ( data = = null ) { Failed + + ; LastLog = "스킬 데이터 없음 " + r . skillId ; return ; }
bool ok = pc . Use_Skill ( data , true , Mathf . Clamp ( r . slot - 1 , 0 , 5 ) , true ) ;
if ( ok ) { Cast + + ; LastLog = "발동 " + r . skillName + " #" + Cast ; }
else
{
Failed + + ;
var tg = pc . Get_Target ( ) ;
LastFail = "발동 안 됨 " + r . skillName + " · 동작 " + pc . Get_CurAnim ( ) + " · 대상 " + ( DSUtil . CheckNull ( tg ) ? "없음" : Vector3 . Distance ( tg . Get_position ( ) , pc . Get_position ( ) ) . ToString ( "F2" ) + " m / 사거리 " + data . f_SkillRange ) + " · CC " + pc . IsCC ( ) ;
LastLog = LastFail ;
}
}
catch ( System . Exception ex ) { Failed + + ; LastLog = "발동 실패 — " + ex . Message ; }
}
static MethodInfo s_clone ; static FieldInfo s_fId ;
/// <summary>시험 시전용 복사본(원본 표 데이터 무수정) — MP 0 · 사거리 = 표 range_m · 직업 칸 = PC 직업.</summary>
static SkillListTableData Prep ( PCActor pc , WLWeaponSkillRow r )
{
SkillListTableData d ;
if ( s_data . TryGetValue ( r . skillId , out d ) ) return d ;
var src = table_skilllist . Ins ! = null ? table_skilllist . Ins . Get_Data_orNull ( r . skillId ) : null ;
if ( src = = null ) return null ;
if ( s_clone = = null ) s_clone = typeof ( object ) . GetMethod ( "MemberwiseClone" , BindingFlags . Instance | BindingFlags . NonPublic ) ;
if ( s_fId = = null ) s_fId = typeof ( Actor ) . GetField ( "m_ID" , BindingFlags . Instance | BindingFlags . NonPublic | BindingFlags . Public ) ;
d = ( SkillListTableData ) s_clone . Invoke ( src , null ) ;
d . n_MP = 0 ;
if ( r . rangeM > 0f ) d . f_SkillRange = r . rangeM ;
d . f_SkillRange = Mathf . Max ( d . f_SkillRange , Cfg . minRange ) ;
if ( s_fId ! = null ) d . n_SkillClassID = ( int ) s_fId . GetValue ( pc ) ;
s_data [ r . skillId ] = d ;
return d ;
}
}
/// <summary>WLSkillChain 의 시간 축. 숨김 GameObject 1개 · 예약이 있을 때만 일한다.</summary>
internal sealed class WLSkillChainRunner : MonoBehaviour
{
void Update ( ) { WLSkillChain . Tick ( ) ; }
}
}