2026-10-18 15:09:05 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLWeaponSkillSlots.cs — 장착 무기의 고유 스킬을 전투 슬롯에 넣어 자동 발동시킨다 (#825 · 2026-10-09 · 기획 v2 §11·§12·§17 구현 순서 3)
/ /
// 힘민지 경로 그대로(원본 0줄): PCActor.Update(:78-104) 가 arr_magicData[i] 의 쿨이 차면 Use_Skill 을 부른다.
// - 슬롯 0 = 주력 · 슬롯 1 = 각성(전설 이상 · 등급표 awaken_open) · 슬롯 2~5 = 비움(서버 프리셋 스킬이 섞여 나가지 않게)
// - 직업 게이트(Use_Skill:700) = WLWeaponClassApplier 가 무기 직업으로 바꾼 뒤에만 넣는다(불일치면 대기)
// · #882 무기 직업으로 바뀐 뒤에도 표가 다른 직업 스킬을 지정하면(방패 치기 → 묵광 · 회심의 일격 → 대검) 직업 칸만 맞춘 복사본을 넣는다
// · #882 스킬 동작 중엔 바꾸지 않고 · 바꾸기 전 기다리던 스킬(m_Magic)을 거둔다(빈 슬롯을 가리켜 MyActor.Update 매 프레임 NRE — 대검 선택 오류)
// - MP 게이트(:733) = 무기 스킬 행의 n_MP 를 0 으로 덮는다(mpOverride) · 쿨 = 표 cool_s 를 f_CoolTime 에 덮는다
// - ActiveSkillLock(activeSkillsEnabled 0) 은 런타임 스위치(RuntimeDisabled)로 해제한다 — 에셋 값은 PD 확정 때 바꾼다
// - 서버 세이브는 건드리지 않는다: 런타임 배열(arr_magicData · arr_magicMaxCoolTime · dic_MagicData)만 리플렉션으로 쓴다(ActiveSkillLock 과 같은 방식)
// + 같은 PC 의 ReSet 이 다시 읽어도 유지되게 ServerData.Equip 의 슬롯(메모리)도 같은 값으로 맞춘다(WLSkillSlotOverride 와 같은 클라 전용 API)
// ─────────────────────────────────────────────────────────────────────────────
using System ;
using System.Collections ;
using System.Collections.Generic ;
using System.Reflection ;
using UnityEngine ;
using WL.Equip ;
namespace WL.Combat.Weapon
{
public class WLWeaponSkillSlots : MonoBehaviour
{
public static WLWeaponSkillSlots Instance { get ; private set ; }
public static int Applied , Skipped ;
public static string LastLog { get ; private set ; } = "" ;
public static int [ ] LastSlots = new int [ 0 ] ;
/// <summary>#900 대쉬 도착 시전 횟수 · 마지막 스킬(검증용).</summary>
public static int DashCasts ; public static string LastDashCast = "" ;
/// <summary>#900 PD 「사정 거리 밖에서 스킬이 시전 가능한 상태일 경우(쿨타임 충족 시) 대쉬 후 바로 방패치기」 — 대쉬가 끝날 때 DashDriver 가 부른다.
/// 낀 무기 표에서 dash_cast 1 인 칸(주력 · 각성)을 원본 자동 시전 규칙(PCActor.WL_TryAutoSkillNow)으로 쓴다. 못 쓰면 false = 일반 공격.</summary>
public static bool TryDashCast ( PCActor pc )
{
if ( pc = = null | | ! pc . IsMainPC ( ) ) return false ;
var w = WLInventory . EquippedInSlot ( eFREquipmentType . Weapon ) ;
var rows = w ! = null & & w . itemJson ! = null ? WLWeaponSkillTable . Rows ( w . itemJson . GroupNo ) : null ;
if ( rows = = null ) return false ;
foreach ( var r in rows )
{
if ( ! r . dashCast | | r . IsWlSkill | | r . IsTriggered ) continue ;
int idx = r . slot - 1 ; if ( idx < 0 | | idx > 1 ) continue ;
if ( pc . WL_TryAutoSkillNow ( idx ) ) { DashCasts + + ; LastDashCast = r . skillName ; return true ; }
}
return false ;
}
/// <summary>#902 우선 시전 횟수 · 마지막 스킬(검증용).</summary>
public static int FirstCasts ; public static string LastFirstCast = "" ;
/// <summary>#902 PD 「해머 (점프 내려치기) 스킬도 시전 가능한 상태일 때(활성화 & 쿨타임 충족) 기본 공격보다 먼저 사용 · 공격 사거리 밖 적은 점프 후 내려치기」 —
/// MyActor 자동 전투가 다가가거나 때리기 직전에 부른다. 표 first_cast 1 칸을 쿨 · 사거리(표 range_m) · CC 규칙으로 시전(자동 시전 간격 2~3초는 무시 = 「쿨 충족 = 시전 가능」).</summary>
2026-10-20 12:36:12 +00:00
public static int FirstCastDeferred ;
2026-10-18 15:09:05 +00:00
public static bool TryFirstCast ( PCActor pc )
{
if ( pc = = null | | ! pc . IsMainPC ( ) ) return false ;
2026-10-20 12:36:12 +00:00
if ( WLWeaponClassApplier . Settling ) { FirstCastDeferred + + ; return false ; } // #952-④ 직업 전환 직후(컨트롤러 비동기 교체 중)엔 첫 시전을 미룬다 — 그때 나간 해머 강력한 일격이 PCMove 를 끈 채 끝나 가상패드가 굳었다(실측)
2026-10-18 15:09:05 +00:00
var w = WLInventory . EquippedInSlot ( eFREquipmentType . Weapon ) ;
var rows = w ! = null & & w . itemJson ! = null ? WLWeaponSkillTable . Rows ( w . itemJson . GroupNo ) : null ;
if ( rows = = null ) return false ;
foreach ( var r in rows )
{
if ( ! r . firstCast | | r . IsWlSkill | | r . IsTriggered ) continue ;
int idx = r . slot - 1 ; if ( idx < 0 | | idx > 1 ) continue ;
if ( pc . WL_TryAutoSkillNow ( idx , true ) ) { FirstCasts + + ; LastFirstCast = r . skillName ; return true ; }
}
return false ;
}
public static int LastWeaponId , LastTier , LastClassId ;
static FieldInfo s_fData , s_fMax , s_fCool , s_fDic , s_fCards , s_fTable ;
static MethodInfo s_opImplicit ;
static bool s_reflected , s_faulted ;
// #882 — 다른 직업의 스킬을 이 무기 표가 지정하면(예: 파괴자 방패 치기 → 묵광) 직업만 맞춘 복사본을 슬롯에 넣는다
static PropertyInfo s_pCurAnim ;
static readonly MethodInfo s_clone = typeof ( object ) . GetMethod ( "MemberwiseClone" , BindingFlags . Instance | BindingFlags . NonPublic ) ;
static readonly Dictionary < long , SkillListTableData > s_classCopies = new Dictionary < long , SkillListTableData > ( ) ;
static readonly SkillListTableData [ ] s_lastChosen = new SkillListTableData [ 6 ] ;
float _t ; UnityEngine . Object _lastPc ; int _lastWeapon = - 2 , _lastTierApplied = - 1 , _lastClass = - 1 ;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot ( )
{
var cfg = WLWeaponSettings . Instance ;
if ( cfg = = null | | cfg . enabled_ = = 0 | | Instance ! = null ) return ;
var go = new GameObject ( "WL_WeaponSkillSlots" ) ;
DontDestroyOnLoad ( go ) ;
Instance = go . AddComponent < WLWeaponSkillSlots > ( ) ;
}
void OnEnable ( ) { WLInventory . Changed + = Dirty ; }
void OnDisable ( ) { WLInventory . Changed - = Dirty ; }
void OnDestroy ( ) { if ( Instance = = this ) Instance = null ; }
void Dirty ( ) { _lastWeapon = - 2 ; _t = 999f ; }
static void Reflect ( )
{
if ( s_reflected ) return ;
s_reflected = true ;
var bf = BindingFlags . Instance | BindingFlags . NonPublic | BindingFlags . Public ;
s_fData = typeof ( PCActor ) . GetField ( "arr_magicData" , bf ) ;
s_fMax = typeof ( PCActor ) . GetField ( "arr_magicMaxCoolTime" , bf ) ;
s_fCool = typeof ( PCActor ) . GetField ( "arr_magicCoolTime" , bf ) ;
s_fCards = typeof ( PCActor ) . GetField ( "arr_PCSkillCard" , bf ) ;
s_fDic = typeof ( Actor ) . GetField ( "dic_MagicData" , bf ) ;
s_pCurAnim = typeof ( Actor ) . GetProperty ( "CurAnim" , bf ) ;
if ( s_fMax ! = null )
{
var elem = s_fMax . FieldType . GetElementType ( ) ;
if ( elem ! = null ) s_opImplicit = elem . GetMethod ( "op_Implicit" , BindingFlags . Public | BindingFlags . Static , null , new [ ] { typeof ( float ) } , null ) ;
}
}
void Update ( )
{
var cfg = WLWeaponSettings . Instance ;
if ( cfg = = null | | cfg . enabled_ = = 0 | | cfg . weaponSkills = = 0 | | s_faulted ) return ;
WL . Combat . Auto . ActiveSkillLock . RuntimeDisabled = true ; // 잠금 해제(값 하나 · 에셋은 PD 확정 때)
_t + = Time . unscaledDeltaTime ;
if ( _t < cfg . pollSec ) return ;
_t = 0f ;
try { TryApply ( cfg , "poll" ) ; }
catch ( Exception ex ) { s_faulted = true ; LastLog = "중단(예외) — " + ex . Message ; Debug . LogWarning ( "[WL-825] 무기 스킬 슬롯 " + LastLog ) ; }
}
static int CurrentClassId ( PCActor pc )
{
var f = typeof ( Actor ) . GetField ( "m_ID" , BindingFlags . Instance | BindingFlags . Public | BindingFlags . NonPublic ) ;
object v = f ! = null ? f . GetValue ( pc ) : null ;
return v is int ? ( int ) v : 0 ;
}
/// <summary>장착 무기의 스킬을 슬롯에 넣는다. 돌려주는 값 = 결과 한 줄(프로브·로그용).</summary>
public string TryApply ( WLWeaponSettings cfg , string why )
{
Reflect ( ) ;
if ( s_fData = = null | | s_fMax = = null | | s_fCool = = null ) return Note ( "리플렉션 실패(arr_magicData/MaxCoolTime)" ) ;
if ( ! MyValue . bMyPC | | MyValue . MyPC = = null ) return Note ( "PC 없음" ) ;
var pc = MyValue . MyPC ;
if ( ! WLInventory . EnsureLoaded ( ) | | ! WLWeaponSkillTable . EnsureLoaded ( ) ) return Note ( "표·인벤토리 미로드" ) ;
var w = WLInventory . EquippedInSlot ( eFREquipmentType . Weapon ) ;
int wid = w ! = null & & w . itemJson ! = null ? w . itemJson . GroupNo : 0 ;
int tier = 1 , plus ; WLGradeTier gt ;
if ( w ! = null & & WLEquipGrades . TryMap ( w . itemJson , out gt , out plus ) ) tier = gt . tier ;
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
int cls = CurrentClassId ( pc ) ;
bool samePc = ReferenceEquals ( pc , _lastPc ) ;
var arr = s_fData . GetValue ( pc ) as SkillListTableData [ ] ;
if ( samePc & & wid = = _lastWeapon & & tier = = _lastTierApplied & & cls = = _lastClass & & SlotsIntact ( arr ) ) return LastLog ; // 변화 없음
// #882 스킬 동작 중이면 다음 폴링에 바꾼다 — 쓰는 중인 슬롯 데이터를 비우면 MyActor.Update(:209) 가 매 프레임 NRE(대검 선택 오류 실측)
if ( InSkill ( pc ) ) { Skipped + + ; return Note ( "스킬 사용 중 — 다음 폴링에 적용" ) ; }
// 원하는 슬롯(0 주력 · 1 각성 · 2~5 비움)
var desired = new int [ 6 ] ; var cools = new float [ 6 ] ; var ranges = new float [ 6 ] ; var chosen = new SkillListTableData [ 6 ] ;
string block = "" ;
int weaponClass = wid > 0 ? WLWeaponAttackTable . ClassIdFor ( wid ) : 0 ;
if ( wid > 0 )
{
var rows = WLWeaponSkillTable . Rows ( wid ) ;
if ( rows ! = null )
foreach ( var r in rows )
{
int idx = r . slot - 1 ; if ( idx < 0 | | idx > 1 ) continue ;
if ( r . IsWlSkill ) continue ; // #883 WL 파츠 스킬 칸 = 파츠 스킬 실행기가 돌린다(WLPartSkillState)
if ( r . IsTriggered ) continue ; // #890 조건 발동 칸(예: 방패 치기 명중 시) = WLSkillChain 이 쿨 없이 시전 · 자동 시전 슬롯에 안 넣는다
if ( tier < r . unlockTier ) continue ; // 각성 = 등급표 해금(전설부터)
var data = table_skilllist . Ins ! = null ? table_skilllist . Ins . Get_Data_orNull ( r . skillId ) : null ;
if ( data = = null ) { block + = " 스킬 없음 " + r . skillId ; continue ; }
if ( data . n_SkillClassID > 0 & & data . n_SkillClassID ! = cls )
{
// 무기 직업으로 아직 안 바뀌었으면 기다린다 · 바뀐 뒤라면 표가 다른 직업 스킬을 지정한 것 → 직업만 맞춘 복사본(#882)
if ( weaponClass < = 0 | | weaponClass ! = cls ) { block + = " 직업 불일치(" + data . n_SkillClassID + "≠" + cls + ")" ; continue ; }
data = ForClass ( data , cls ) ; block + = " 직업 맞춤 " + r . skillId ;
}
desired [ idx ] = r . skillId ; cools [ idx ] = r . coolS ; ranges [ idx ] = r . rangeM ; chosen [ idx ] = data ;
}
}
// 런타임 배열 쓰기(ActiveSkillLock 과 같은 리플렉션 경로)
var maxArr = s_fMax . GetValue ( pc ) as Array ;
var coolArr = s_fCool . GetValue ( pc ) as Array ;
var dic = s_fDic ! = null ? s_fDic . GetValue ( pc ) as IDictionary : null ;
if ( arr = = null | | maxArr = = null | | coolArr = = null ) return Note ( "배열 없음" ) ;
try { pc . Del_Magic ( ) ; } catch { } // #882 기다리던 스킬(m_Magic)이 바뀔 슬롯을 가리키면 데이터가 비어 NRE → 먼저 거둔다(스킬 동작 중이 아닐 때라 시전 코루틴 없음)
double coolMul = 0 ; try { coolMul = pc . Get_StatInfo ( ) . Get_Stat ( eStat . COOLTIME_Multiplier ) ; } catch { }
int n = Mathf . Min ( arr . Length , 6 ) ;
for ( int i = 0 ; i < n ; i + + )
{
SkillListTableData data = chosen [ i ] ;
s_lastChosen [ i ] = data ;
if ( data ! = null )
{
if ( cfg . mpOverride ! = 0 ) data . n_MP = 0 ;
if ( cools [ i ] > 0f ) data . f_CoolTime = cools [ i ] ;
if ( ranges [ i ] > 0f ) data . f_SkillRange = ranges [ i ] ; // #888 표 range_m(쿨과 같은 규칙 · 지금 표 값 = 힘민지 값이고 돌진 베기만 7 → 2)
}
arr [ i ] = data ;
SetObscured ( maxArr , i , data ! = null ? data . Get_CoolTime ( coolMul ) : 0f ) ;
SetObscured ( coolArr , i , 0f ) ; // 바로 쓸 수 있게
if ( dic ! = null & & dic . Contains ( i ) )
{
var info = dic [ i ] ;
var fTable = info ! = null ? info . GetType ( ) . GetField ( "m_SkillTableData" , BindingFlags . Instance | BindingFlags . Public | BindingFlags . NonPublic ) : null ;
if ( fTable ! = null ) fTable . SetValue ( info , data ) ;
}
}
// 서버 데이터(메모리)도 같은 값으로 — 같은 PC 의 ReSet 이 다시 읽어도 유지(WLSkillSlotOverride 와 같은 클라 전용 API · 저장 호출 0)
try
{
var sd = ServerInfo . Ins ! = null ? ServerInfo . Ins . m_ServerData : null ;
if ( sd ! = null & & sd . Equip ! = null & & sd . Equip . Skill ! = null & & sd . Equip . Skill . ContainsKey ( sd . Equip . Preset ) )
{
var lst = sd . Equip . Skill [ sd . Equip . Preset ] ;
for ( int i = 0 ; i < n & & i < lst . Count ; i + + ) sd . Equip . Set_Equip ( eItem . Skill , desired [ i ] , i ) ;
}
}
catch ( Exception ex ) { block + = " 서버메모리 실패(" + ex . Message + ")" ; }
// 전투 패드 카드 아이콘(있을 때만)
try
{
var cards = s_fCards ! = null ? s_fCards . GetValue ( pc ) as SkillCard [ ] : null ;
if ( cards ! = null )
for ( int i = 0 ; i < n & & i < cards . Length ; i + + )
if ( cards [ i ] ! = null & & arr [ i ] ! = null ) { cards [ i ] . Set ( arr [ i ] ) ; cards [ i ] . Set_CardType ( eUICardType . Use ) ; }
}
catch ( Exception ex ) { block + = " 카드 갱신 실패(" + ex . Message + ")" ; }
_lastPc = pc ; _lastWeapon = wid ; _lastTierApplied = tier ; _lastClass = cls ;
LastSlots = desired ; LastWeaponId = wid ; LastTier = tier ; LastClassId = cls ;
Applied + + ;
return Note ( why + ": 무기 " + wid + ( w ! = null ? "(" + w . itemJson . Item_name + ")" : "" ) + " 등급 " + tier + " 직업 " + cls +
" → 슬롯 [" + desired [ 0 ] + ", " + desired [ 1 ] + "] 쿨 [" + cools [ 0 ] . ToString ( "0.#" ) + ", " + cools [ 1 ] . ToString ( "0.#" ) + "]" + block ) ;
}
/// <summary>#882 — 지금 스킬 동작 중인가(Actor.CurAnim ≥ Skill).</summary>
static bool InSkill ( PCActor pc )
{
if ( s_pCurAnim = = null ) return false ;
try { return ( int ) ( eAnim ) s_pCurAnim . GetValue ( pc , null ) > = ( int ) eAnim . Skill ; } catch { return false ; }
}
/// <summary>#882 — 마지막으로 넣은 슬롯이 그대로인가(PC ReSet 이 서버 슬롯으로 다시 채우면 직업 맞춤 복사본이 사라진다 → 다시 넣는다).</summary>
static bool SlotsIntact ( SkillListTableData [ ] arr )
{
if ( arr = = null ) return true ;
for ( int i = 0 ; i < arr . Length & & i < s_lastChosen . Length ; i + + ) if ( ! ReferenceEquals ( arr [ i ] , s_lastChosen [ i ] ) ) return false ;
return true ;
}
/// <summary>#882 — 다른 직업 스킬의 직업 칸만 이 PC 직업으로 바꾼 복사본(스킬 · 쿨 · 판정은 원본 그대로 · 원본 표 데이터 무수정).</summary>
/// <summary>#906 E 치트키 — 조건 발동 칸(예: 묵광 각성 돌진 베기)처럼 자동 시전 칸에 없는 스킬의 데이터를 표에서 가져온다(직업 다르면 직업 맞춘 복사본).</summary>
public static SkillListTableData SkillDataFor ( PCActor pc , int skillId )
{
var data = table_skilllist . Ins ! = null ? table_skilllist . Ins . Get_Data_orNull ( skillId ) : null ;
if ( data = = null | | pc = = null ) return data ;
int cls = CurrentClassId ( pc ) ;
return data . n_SkillClassID > 0 & & data . n_SkillClassID ! = cls ? ForClass ( data , cls ) : data ;
}
static SkillListTableData ForClass ( SkillListTableData src , int cls )
{
long key = ( long ) src . n_SkillID * 100000L + cls ;
SkillListTableData c ;
if ( s_classCopies . TryGetValue ( key , out c ) & & c ! = null ) return c ;
c = ( SkillListTableData ) s_clone . Invoke ( src , null ) ;
c . n_SkillClassID = cls ;
s_classCopies [ key ] = c ;
return c ;
}
static void SetObscured ( Array arr , int i , float value )
{
if ( arr = = null | | i < 0 | | i > = arr . Length ) return ;
if ( s_opImplicit ! = null ) arr . SetValue ( s_opImplicit . Invoke ( null , new object [ ] { value } ) , i ) ;
else if ( arr is float [ ] ) ( ( float [ ] ) arr ) [ i ] = value ;
}
string Note ( string s )
{
LastLog = s ;
var cfg = WLWeaponSettings . Instance ;
if ( cfg ! = null & & cfg . verboseLog ! = 0 & & s . Contains ( "→" ) ) Debug . Log ( "[WL-825] 무기 스킬 슬롯 " + s ) ;
return s ;
}
/// <summary>프로브용 — 지금 슬롯 상태(배열 · 쿨).</summary>
public static string Dump ( )
{
Reflect ( ) ;
if ( ! MyValue . bMyPC | | MyValue . MyPC = = null | | s_fData = = null ) return "PC 없음" ;
var pc = MyValue . MyPC ;
var arr = s_fData . GetValue ( pc ) as SkillListTableData [ ] ;
var coolArr = s_fCool . GetValue ( pc ) as Array ; var maxArr = s_fMax . GetValue ( pc ) as Array ;
var sb = new System . Text . StringBuilder ( ) ;
sb . Append ( "class=" ) . Append ( CurrentClassId ( pc ) ) . Append ( " lock=" ) . Append ( WL . Combat . Auto . ActiveSkillLock . Locked ) . Append ( " applied=" ) . Append ( Applied ) . Append ( " | " ) ;
if ( arr ! = null )
for ( int i = 0 ; i < arr . Length ; i + + )
{
sb . Append ( '[' ) . Append ( i ) . Append ( "] " ) ;
if ( arr [ i ] = = null ) { sb . Append ( "- " ) ; continue ; }
sb . Append ( arr [ i ] . n_SkillID ) . Append ( " mp" ) . Append ( arr [ i ] . n_MP ) . Append ( " cool " ) . Append ( coolArr ! = null ? Convert . ToSingle ( coolArr . GetValue ( i ) ) . ToString ( "0.#" ) : "?" )
. Append ( '/' ) . Append ( maxArr ! = null ? Convert . ToSingle ( maxArr . GetValue ( i ) ) . ToString ( "0.#" ) : "?" ) . Append ( ' ' ) ;
}
sb . Append ( "| " ) . Append ( LastLog ) ;
return sb . ToString ( ) ;
}
}
}