2026-10-18 15:09:05 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLWeaponClassApplier.cs — 장착 무기 → 힘민지 직업(모션 세트) 전환 (#825 · 2026-10-09 · 기획 v1 판단 ① 권고 A)
/ /
// 장착한 무기(FR GroupNo)의 표 class_id 로 `PCActor.Change_Class(cd, true)` 를 부른다 → 애니메이터·무기 모델·사거리·탄(Projectile_{CID})이
// 함께 바뀐다. `_ui = true` 라 서버 세이브(Equip[PC])는 건드리지 않는다(힘민지 ServerInfo.Write 오염 방지 · 코드 조사 2026-10-09).
// - 켜고 끄기 = WLWeaponSettings.classSwitch (PD 판단 ① 확정 전 기본 0)
// - 섬(로비 맵)에서는 Set_Obj 가 로비 애니메이터를 끼우므로 WLIslandAvatar.ApplyCombatForm 과 같은 방식으로 호출 동안만 Stage 로 바꾼다
// - 공격·스킬 모션 중에는 바꾸지 않고, 전환 뒤 settleSec 동안은 다시 바꾸지 않는다(컨트롤러 비동기 로드)
// - 전환 뒤 WLEquipStats.RefreshPc 로 장비 스탯을 다시 반영한다
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine ;
using WL.Equip ;
namespace WL.Combat.Weapon
{
// WLWeaponSettings 는 WLWeaponSettings.cs 로 옮김(#883 · 파일 이름 = 클래스 이름이어야 에셋이 스크립트를 참조한다)
public class WLWeaponClassApplier : MonoBehaviour
{
public static WLWeaponClassApplier Instance { get ; private set ; }
public static int Applied , Skipped , Forced ;
2026-10-20 12:36:12 +00:00
/// <summary>#952-④ 마지막 직업 전환 시각(unscaled) — 전환 직후 컨트롤러가 비동기로 바뀌는 동안(settleSec) 첫 시전(first_cast)이 나가면 스킬 동작이 옛 컨트롤러에서 돌아 타격 이벤트가 없고(WLJumpSlam 「타격 이벤트 없음」 실측) 스킬이 끈 PCMove 가 안 돌아온다 → WLWeaponSkillSlots.TryFirstCast 가 이 시각으로 미룬다.</summary>
public static float LastSwitchAt = - 999f ;
public static bool Settling { get { var cfg = WLWeaponSettings . Instance ; return Time . unscaledTime - LastSwitchAt < ( cfg ! = null ? cfg . settleSec : 1.5f ) ; } }
2026-10-18 15:09:05 +00:00
public static string LastLog { get ; private set ; } = "" ;
public static int LastClassId , DesiredClassId ;
float _t ; float _settleUntil ; float _pendingSince = - 1f ;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot ( )
{
var cfg = WLWeaponSettings . Instance ;
if ( cfg = = null | | cfg . enabled_ = = 0 | | Instance ! = null ) return ;
var go = new GameObject ( "WL_WeaponClassApplier" ) ;
DontDestroyOnLoad ( go ) ;
Instance = go . AddComponent < WLWeaponClassApplier > ( ) ;
}
void OnEnable ( ) { WLInventory . Changed + = OnInventoryChanged ; }
void OnDisable ( ) { WLInventory . Changed - = OnInventoryChanged ; }
void OnDestroy ( ) { if ( Instance = = this ) Instance = null ; }
void OnInventoryChanged ( ) { _t = 999f ; } // 다음 Update 에서 바로 점검
void Update ( )
{
var cfg = WLWeaponSettings . Instance ;
if ( cfg = = null | | cfg . enabled_ = = 0 | | cfg . classSwitch = = 0 ) return ;
_t + = Time . unscaledDeltaTime ;
if ( _t < cfg . pollSec ) return ;
_t = 0f ;
TryApply ( cfg , "poll" ) ;
}
/// <summary>
/// #851 PD 「던전에 들어갈 때 멋대로 무기가 바뀌면 안돼」 — 씬마다 새로 만들어지는 내 PC 를 **처음부터** 장착 무기의 직업으로 만든다.
/// 원본은 서버 데이터의 장착 직업(Equip[PC] · 10501)으로 PC 를 만들고, 이 적용기가 폴링으로 뒤늦게 바꿔 「해머 → 검」 교체가 보였다.
/// 원본 훅 = PCActor.Set 1줄. 무기가 없거나 표에 없으면 받은 값 그대로(원본 동작).
/// </summary>
public static int ResolveSpawnClass ( int baseClassId )
{
var cfg = WLWeaponSettings . Instance ;
if ( cfg = = null | | cfg . enabled_ = = 0 | | cfg . classSwitch = = 0 ) return baseClassId ;
try
{
if ( ! WLInventory . EnsureLoaded ( ) ) return baseClassId ;
WLStarterKit . Ensure ( cfg ) ; // 🔴 임시 시작 장비(검 장착)를 출생 전에 — 시작 직후 「해머 → 검」 교체도 없앤다(세이브 객체당 1회 · 멱등)
var w = WLInventory . EquippedInSlot ( eFREquipmentType . Weapon ) ;
int desired = w ! = null & & w . itemJson ! = null ? WLWeaponAttackTable . ClassIdFor ( w . itemJson . GroupNo ) : 0 ;
if ( desired < = 0 | | desired = = baseClassId ) return baseClassId ;
WLWeaponClasses . Ensure ( ) ; // WL 추가 직업(창·대검) 행을 ClassConfig 에 보탠다(멱등)
var cd = table_classconfig . Ins ! = null ? table_classconfig . Ins . Get_Data_orNull ( desired ) : null ;
if ( cd = = null ) return baseClassId ;
SpawnResolved + + ;
LastSpawnLog = "spawn: 직업 " + baseClassId + " → " + desired + " (" + w . itemJson . Item_name + ")" ;
if ( cfg . verboseLog ! = 0 ) Debug . Log ( "[WL-825] " + LastSpawnLog ) ;
return desired ;
}
catch ( System . Exception ) { return baseClassId ; } // 원본은 없는 ID 에 예외를 던진다 — 원래 값으로
}
public static int SpawnResolved ;
public static string LastSpawnLog = "" ;
static int CurrentClassId ( PCActor pc )
{
if ( pc = = null ) return 0 ;
var f = typeof ( Actor ) . GetField ( "m_ID" , System . Reflection . BindingFlags . Instance | System . Reflection . BindingFlags . Public | System . Reflection . 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 )
{
if ( ! MyValue . bMyPC | | MyValue . MyPC = = null ) return Note ( "PC 없음" ) ;
var pc = MyValue . MyPC ;
if ( ! WLInventory . EnsureLoaded ( ) ) return Note ( "인벤토리 미로드" ) ;
WLStarterKit . Ensure ( cfg ) ; // 🔴 임시(PD 2026-10-10) — 무기 6계열 1개씩 + 검 장착 · 1회
var w = WLInventory . EquippedInSlot ( eFREquipmentType . Weapon ) ;
int desired = w ! = null & & w . itemJson ! = null ? WLWeaponAttackTable . ClassIdFor ( w . itemJson . GroupNo ) : 0 ;
DesiredClassId = desired ;
int cur = CurrentClassId ( pc ) ;
LastClassId = cur ;
if ( desired < = 0 ) { Skipped + + ; return Note ( w = = null ? "무기 미장착" : "표에 없는 무기 그룹 " + w . itemJson . GroupNo ) ; }
WLWeaponClasses . Ensure ( ) ; // WL 추가 직업(창) 행을 ClassConfig 에 보탠다(멱등)
WLWeaponClasses . EnsureWeaponOrient ( pc ) ; // #834 활 등 무기 인스턴스 방향 보정(표식 1회 · 재장착 때 재적용)
if ( cur = = desired )
{
_pendingSince = - 1f ;
bool ev = WLWeaponClasses . EnsureClipEvents ( pc , cur ) ; // 추가 직업 = 클립 타격 이벤트 보강(컨트롤러 로드 뒤)
bool rm = pc . Get_CurAnim ( ) < eAnim . Attack & & WLWeaponClasses . RefreshModelIfNeeded ( pc , cur ) ; // 수정 행(망치) = 살아 있는 PC 의 무기 모델 재부착 1회
return Note ( "이미 직업 " + desired + " (" + w . itemJson . Item_name + ")" + ( ev ? "" : " · 클립 이벤트 대기" ) + ( rm ? " · 모델 재부착" : "" ) ) ;
}
if ( Time . unscaledTime < _settleUntil ) { Skipped + + ; return Note ( "안정화 대기" ) ; }
2026-10-20 12:36:12 +00:00
bool forced = false ;
2026-10-18 15:09:05 +00:00
if ( pc . Get_CurAnim ( ) > = eAnim . Attack )
{
// 연속 전투에서는 공격 끝 프레임을 폴링으로 못 잡는다(2026-10-09 스모크: 6종 전부 「보류」) → 일정 시간 뒤 공격을 끊고 바꾼다
if ( _pendingSince < 0f ) _pendingSince = Time . unscaledTime ;
if ( cfg . forceSwitchAfterSec < = 0f | | Time . unscaledTime - _pendingSince < cfg . forceSwitchAfterSec ) { Skipped + + ; return Note ( "공격 중 — 보류" ) ; }
try { pc . Play_Idle ( ) ; } catch ( System . Exception ) { }
2026-10-20 12:36:12 +00:00
Forced + + ; forced = true ;
2026-10-18 15:09:05 +00:00
}
2026-10-20 12:36:12 +00:00
bool stalePcMove = false ; try { stalePcMove = OptionInfo . Ins ! = null & & ! OptionInfo . Ins . Get_PCMove ( ) ; } catch ( System . Exception ) { } // #952-④ 스킬이 꺼 둔 가상패드 이동 허용이 대기 상태에 남아 있음 = 끊긴 스킬의 잔재
2026-10-18 15:09:05 +00:00
_pendingSince = - 1f ;
ClassConfigTableData cd = null ;
try { cd = table_classconfig . Ins ! = null ? table_classconfig . Ins . Get_Data_orNull ( desired ) : null ; } catch ( System . Exception ) { cd = null ; } // 원본은 없는 ID 에 예외를 던진다
if ( cd = = null ) { Skipped + + ; return Note ( "ClassConfig 없음 " + desired ) ; }
// 섬(로비 맵)은 Set_Obj 가 로비 애니메이터를 끼운다 → 호출 동안만 Stage 로(WLIslandAvatar.ApplyCombatForm 과 같은 방식)
var info = InGameInfo . Ins ;
var gf = info ! = null ? typeof ( InGameInfo ) . GetField ( "m_GameMode" , System . Reflection . BindingFlags . Instance | System . Reflection . BindingFlags . NonPublic ) : null ;
object prev = gf ! = null ? gf . GetValue ( info ) : null ;
bool flipped = false ;
try
{
if ( gf ! = null & & prev is eGameMode & & ( eGameMode ) prev = = eGameMode . Lobby ) { gf . SetValue ( info , eGameMode . Stage ) ; flipped = true ; }
pc . Change_Class ( cd , true ) ;
}
catch ( System . Exception ex ) { return Note ( "전환 실패 — " + ex . Message ) ; }
finally { if ( flipped ) gf . SetValue ( info , prev ) ; }
try { WLEquipStats . RefreshPc ( ) ; } catch ( System . Exception ex ) { Debug . LogWarning ( "[WL-825] 스탯 재반영 실패 — " + ex . Message ) ; }
2026-10-20 12:36:12 +00:00
Applied + + ; LastSwitchAt = Time . unscaledTime ;
if ( forced | | stalePcMove ) RestoreMotion ( pc , forced ? "강제 전환" : "PCMove 꺼진 채 전환" ) ; // #952-④
2026-10-18 15:09:05 +00:00
_settleUntil = Time . unscaledTime + cfg . settleSec ;
2026-10-20 12:36:12 +00:00
return Note ( why + ": 직업 " + cur + " → " + desired + " (" + w . itemJson . Item_name + " · " + WLWeaponAttackTable . Describe ( w . itemJson . GroupNo ) + ")" + ( flipped ? " · 로비 모드 우회" : "" ) + ( forced | | stalePcMove ? " · 상태 원상(" + LastRestore + ")" : "" ) ) ;
}
public static int Restored ; public static string LastRestore = "" ;
static System . Reflection . PropertyInfo s_pIsStop ; static System . Reflection . FieldInfo s_fImmortal ; static System . Reflection . MethodInfo s_mModel , s_mWeapon ;
/// <summary>#952-④ PD 「마법책 무기 변경 후 캐릭터 이동이 멈추는 현상」 — 스킬(해머 강력한 일격 등) 도중 직업을 강제로 바꾸면(Play_Idle → Change_Class) 그 스킬이 끝에 되돌릴 상태가 남는다:
/// 가상패드(FloatingJoystick)가 읽는 OptionInfo.PCMove(false 면 드래그·뗌을 모두 무시 → 뗌을 못 받아 IsStop·JoystickStatus 1 이 굳는다 = PD 에디터 실측) · 무적 · 모델 숨김 · 애니메이터 속도 0 · WL 시전 잠금 · 착지 연출. 전환 직후 원상.</summary>
static void RestoreMotion ( PCActor pc , string why )
{
var sb = new System . Text . StringBuilder ( why ) ;
try { if ( OptionInfo . Ins ! = null & & ! OptionInfo . Ins . Get_PCMove ( ) ) { OptionInfo . Ins . Set_PCMove ( pc , true ) ; sb . Append ( " · PCMove" ) ; } } catch ( System . Exception ) { }
try { if ( pc . m_animation ! = null & & pc . m_animation . speed ! = 1f ) { pc . m_animation . speed = 1f ; sb . Append ( " · 속도 1" ) ; } } catch ( System . Exception ) { }
try
{
var t = typeof ( Actor ) ; var bf = System . Reflection . BindingFlags . Instance | System . Reflection . BindingFlags . NonPublic | System . Reflection . BindingFlags . Public ;
if ( s_fImmortal = = null ) s_fImmortal = t . GetField ( "m_bImmortal" , bf ) ;
if ( s_fImmortal ! = null & & ( bool ) s_fImmortal . GetValue ( pc ) ) { s_fImmortal . SetValue ( pc , false ) ; sb . Append ( " · 무적 해제" ) ; }
if ( s_mModel = = null ) s_mModel = t . GetMethod ( "Model_OnOff" , bf ) ;
if ( s_mWeapon = = null ) s_mWeapon = t . GetMethod ( "Weapon_OnOff" , bf ) ;
bool hidden = false ; foreach ( var r in pc . GetComponentsInChildren < SkinnedMeshRenderer > ( true ) ) if ( r ! = null & & ! r . enabled ) { hidden = true ; break ; }
if ( hidden ) { if ( s_mModel ! = null ) s_mModel . Invoke ( pc , new object [ ] { true } ) ; if ( s_mWeapon ! = null ) s_mWeapon . Invoke ( pc , new object [ ] { true } ) ; sb . Append ( " · 모델 보임" ) ; }
if ( s_pIsStop = = null ) s_pIsStop = t . GetProperty ( "IsStop" , bf ) ;
if ( s_pIsStop ! = null & & pc . JoystickStatus = = 0 & & ( bool ) s_pIsStop . GetValue ( pc , null ) ) { s_pIsStop . SetValue ( pc , false , null ) ; sb . Append ( " · IsStop 해제" ) ; } // 조이스틱을 쥔 상태(1)의 IsStop 은 뗄 때 원본이 푼다
}
catch ( System . Exception ex ) { sb . Append ( " · 리플렉션 실패 " ) . Append ( ex . Message ) ; }
try { if ( pc . JoystickStatus = = 2 ) { pc . Set_JoystickStatus ( 3 ) ; sb . Append ( " · 조이스틱 2→0" ) ; } } catch ( System . Exception ) { }
try { WLSkillCancel . ResetFor ( pc ) ; } catch ( System . Exception ) { }
try { WLGhostStrike . CancelFor ( pc ) ; } catch ( System . Exception ) { }
Restored + + ; LastRestore = sb . ToString ( ) ;
2026-10-18 15:09:05 +00:00
}
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 ;
}
}
}