2026-10-18 15:09:05 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLWeaponClasses.cs — WL 추가 직업(모션 세트) 런타임 등록 (#825 · 2026-10-09 · PD 「창을 넣어서 6종」)
/ /
// 힘민지 ClassConfig 에 없는 직업(창 10701)을 원본 표 파일을 고치지 않고 런타임에 보탠다:
// - Resources/WL/WLWeaponClasses.json 의 행 → 템플릿 직업 행(한손검 10101)을 복제해 ID·컨트롤러·무기 모델·사거리만 바꿔 table_classconfig 사전에 넣는다
// - 창 클립(Shinabro)에는 타격 이벤트가 없다 → 그 직업으로 바뀐 뒤 애니메이터 클립에 Projectile 이벤트를 런타임으로 붙인다(Hit1LookAtTarget AddEvent 선례 · 세션 동안 유지)
// 자산(컨트롤러·무기 프리팹·Addressables 등록)은 AgentScripts/WL825_SpearSetup.cs 가 에디터에서 1회 만든다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic ;
using System.Reflection ;
using Newtonsoft.Json ;
using UnityEngine ;
namespace WL.Combat.Weapon
{
public class WLWeaponClassDef
{
public int classId ; public int templateClassId = 10101 ; public string name = "" ;
/// <summary>true = 힘민지 기존 직업 행을 수정(무기 모델·사거리만) · false = 템플릿 복제로 새 직업 추가.</summary>
public bool overrideExisting ;
public bool skillsFromTemplate ; // #839 true = 템플릿 직업의 액티브 스킬을 이 직업이 쓴다(Actor.Use_Skill 훅 · 대검 10801)
public string animController = "" ; public string weaponRHPrefab = "" ; public int weaponRHIndex = 2 ; public string weaponLHPrefab = "" ; public int weaponLHIndex = - 1 ;
public float attackRange = 1.5f ; public float attackSpd = - 1f ;
public List < string > attackClipTokens = new List < string > ( ) ; // 타격 이벤트를 붙일 클립 이름 조각
public List < float > projectileTimes = new List < float > ( ) ; // 클립별 이벤트 시점(0~1 · 길이 비율)
public string projectilePrefab = "Projectile_10101" ; public float projectileAddDmg = 1f ;
// #833 — 베기/찌르기 이펙트(ShowEffect · 검기 트레일 창 시작) 시점·프리팹(클립별 · 비우면 안 붙인다).
// 프리팹 이름은 존재하는 원본(템플릿 직업의 Effect_Slash_10101_N)을 쓴다 — 트레일 경로가 못 받으면 원본 ShowEffect 가 그 프리팹을 띄운다.
public List < float > effectTimes = new List < float > ( ) ;
public List < string > effectPrefabs = new List < string > ( ) ;
}
/// <summary>#834 무기 인스턴스 로컬 회전 보정(소켓 방향 규약이 팩 손 자세와 다를 때 · 원본 프리팹 0줄).</summary>
public class WLWeaponOrient { public string prefab = "" ; public float [ ] euler ; }
2026-10-20 14:21:09 +00:00
/// <summary>#953 기존 직업의 클립에 이벤트를 하나 더 붙인다(예 활 3타 push 클립에 화살 1발 추가) — 클립 이름 조각 · 컨트롤러 이름 조각 · 시점(길이 비율) · 함수 · 프리팹 · 배율. 같은 함수가 같은 시점(±0.02 s)에 이미 있으면 안 붙인다(멱등 · 세션 동안 유지).</summary>
public class WLExtraClipEvent { public int classId ; public string controller = "" ; public string clipToken = "" ; public string functionName = "Projectile" ; public float time = 0.5f ; public string stringParameter = "" ; public float floatParameter = 1f ; public int intParameter ; public string _note = "" ; }
class WLWeaponClassesJson { public List < WLWeaponClassDef > classes ; public List < WLWeaponOrient > weaponOrient ; public List < WLExtraClipEvent > extraClipEvents ; }
2026-10-18 15:09:05 +00:00
/// <summary>보정을 이미 적용한 무기 인스턴스 표식(중복 회전 방지).</summary>
public sealed class WLWeaponOrientMark : MonoBehaviour { public string rule = "" ; }
public static class WLWeaponClasses
{
public static bool Loaded { get ; private set ; }
public static string LastLog { get ; private set ; } = "" ;
public static int Injected , EventsAdded , Overridden ;
static readonly Dictionary < int , WLWeaponClassDef > s_defs = new Dictionary < int , WLWeaponClassDef > ( ) ;
/// <summary>#839 — myClass 가 WL 추가 직업이고 json 에 skillsFromTemplate 가 켜져 있으면, 템플릿 직업(templateClassId)의 스킬(skillClass)을 허용한다. Actor.Use_Skill 훅 1줄이 부른다.</summary>
public static bool IsTemplateSkillClass ( int myClass , int skillClass )
{
WLWeaponClassDef d ;
if ( ! s_defs . TryGetValue ( myClass , out d ) | | d = = null | | ! d . skillsFromTemplate ) return false ;
return d . templateClassId > 0 & & d . templateClassId = = skillClass ;
}
static readonly HashSet < int > s_eventsDone = new HashSet < int > ( ) ; // 클립 인스턴스 ID
static readonly HashSet < int > s_overridden = new HashSet < int > ( ) ; // 수정이 끝난 기존 직업 ID
static readonly HashSet < int > s_modelRefreshed = new HashSet < int > ( ) ; // 모델 재부착이 끝난 PC 인스턴스 ID
2026-10-20 01:24:45 +00:00
/// <summary>#947 Enter Play Mode 「도메인 리로드 끔」 대비 — Play 마다 표(table_classconfig)·PC 는 새 인스턴스인데 정적 「끝남」 표시가 남으면 수정 행·모델 재부착을 건너뛴다. 표 데이터(s_defs)는 에셋이라 그대로 둔다.</summary>
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
2026-10-20 14:21:09 +00:00
static void ResetStatics ( ) { s_overridden . Clear ( ) ; s_modelRefreshed . Clear ( ) ; Injected = EventsAdded = Overridden = 0 ; s_extraDone . Clear ( ) ; ExtraEventsAdded = 0 ; ExtraLog = "" ; } // #953 런타임 AddEvent 는 Play 를 나가면 클립에서 사라진다(실측) → Play 마다 다시 붙인다
2026-10-18 15:09:05 +00:00
static readonly List < WLWeaponOrient > s_orients = new List < WLWeaponOrient > ( ) ; // #834 무기 방향 보정 규칙
public static int OrientApplied ;
/// <summary>
/// #834 — 소켓에 붙은 무기 인스턴스에 표의 로컬 회전을 1회 더한다(PD 「활이 던지는 느낌」 = 활이 팔 방향으로 누움 → X+90°).
/// Change_Weapon 이 새 인스턴스를 만들 때마다 표식이 없으므로 다시 적용된다. 매 폴링마다 불러도 싸다(소켓 7개 자식 훑기).
/// </summary>
public static int EnsureWeaponOrient ( PCActor pc )
{
EnsureLoaded ( ) ;
if ( pc = = null | | pc . tfs_weapon = = null | | s_orients . Count = = 0 ) return 0 ;
int n = 0 ;
for ( int s = 0 ; s < pc . tfs_weapon . Length ; s + + )
{
var socket = pc . tfs_weapon [ s ] ;
if ( socket = = null ) continue ;
for ( int c = 0 ; c < socket . childCount ; c + + )
{
var child = socket . GetChild ( c ) ;
if ( child = = null | | child . GetComponent < WLWeaponOrientMark > ( ) ! = null ) continue ;
for ( int r = 0 ; r < s_orients . Count ; r + + )
{
var o = s_orients [ r ] ;
if ( o = = null | | string . IsNullOrEmpty ( o . prefab ) | | o . euler = = null | | o . euler . Length < 3 | | ! child . name . StartsWith ( o . prefab ) ) continue ;
child . localRotation = Quaternion . Euler ( o . euler [ 0 ] , o . euler [ 1 ] , o . euler [ 2 ] ) * child . localRotation ;
child . gameObject . AddComponent < WLWeaponOrientMark > ( ) . rule = o . prefab ;
OrientApplied + + ; n + + ;
LastLog = "무기 방향 보정 " + child . name + " @" + socket . name + " euler(" + o . euler [ 0 ] + "," + o . euler [ 1 ] + "," + o . euler [ 2 ] + ")" ;
break ;
}
}
}
return n ;
}
/// <summary>기존 직업 행을 바꿨는가(무기 모델 재부착이 필요한 직업).</summary>
public static bool IsOverridden ( int classId ) { return s_overridden . Contains ( classId ) ; }
/// <summary>
/// 수정된 직업으로 이미 살아 있는 PC(시작 직업 10501 등)는 무기 모델이 원래 행으로 붙어 있다 → Set_Obj 로 한 번 다시 붙인다
/// (섬은 로비 맵이라 WLIslandAvatar.ApplyCombatForm 과 같이 호출 동안만 Stage 로). PC 인스턴스당 1회.
/// </summary>
public static bool RefreshModelIfNeeded ( PCActor pc , int classId )
{
if ( pc = = null | | ! s_overridden . Contains ( classId ) ) return false ;
int id = pc . GetInstanceID ( ) ;
if ( s_modelRefreshed . Contains ( id ) ) return false ;
s_modelRefreshed . Add ( id ) ;
var info = InGameInfo . Ins ;
var gf = info ! = null ? typeof ( InGameInfo ) . GetField ( "m_GameMode" , BindingFlags . Instance | 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 . Set_Obj ( ) ;
}
catch ( System . Exception ex ) { LastLog = "모델 재부착 실패 — " + ex . Message ; return false ; }
finally { if ( flipped ) gf . SetValue ( info , prev ) ; }
LastLog = "직업 " + classId + " 무기 모델 재부착(수정 행)" ;
return true ;
}
public static bool EnsureLoaded ( )
{
if ( Loaded ) return true ;
var cfg = WLWeaponSettings . Instance ;
string path = cfg ! = null & & ! string . IsNullOrEmpty ( cfg . classesPath ) ? cfg . classesPath : "WL/WLWeaponClasses" ;
try
{
var ta = Resources . Load < TextAsset > ( path ) ;
if ( ta = = null ) { Loaded = true ; LastLog = "추가 직업 표 없음(" + path + ") — 건너뜀" ; return true ; }
var j = JsonConvert . DeserializeObject < WLWeaponClassesJson > ( ta . text ) ;
s_defs . Clear ( ) ;
if ( j ! = null & & j . classes ! = null ) foreach ( var d in j . classes ) if ( d ! = null & & d . classId > 0 ) s_defs [ d . classId ] = d ;
s_orients . Clear ( ) ;
if ( j ! = null & & j . weaponOrient ! = null ) s_orients . AddRange ( j . weaponOrient ) ;
2026-10-20 14:21:09 +00:00
s_extra . Clear ( ) ; if ( j ! = null & & j . extraClipEvents ! = null ) s_extra . AddRange ( j . extraClipEvents ) ; // #953
2026-10-18 15:09:05 +00:00
Loaded = true ;
LastLog = "추가 직업 표 로드 — " + s_defs . Count + " · 방향 보정 " + s_orients . Count ;
}
catch ( System . Exception ex ) { LastLog = "추가 직업 표 로드 실패 — " + ex . Message ; Debug . LogError ( "[WL-825] " + LastLog ) ; Loaded = true ; }
return true ;
}
public static WLWeaponClassDef Def ( int classId ) { EnsureLoaded ( ) ; WLWeaponClassDef d ; return s_defs . TryGetValue ( classId , out d ) ? d : null ; }
/// <summary>표의 직업이 힘민지 ClassConfig 사전에 없으면 템플릿을 복제해 넣는다(멱등).</summary>
public static string Ensure ( )
{
EnsureLoaded ( ) ;
if ( s_defs . Count = = 0 | | table_classconfig . Ins = = null ) return LastLog ;
var f = typeof ( table_classconfig ) . GetField ( "dic_Data" , BindingFlags . Instance | BindingFlags . NonPublic | BindingFlags . Public ) ;
var dic = f ! = null ? f . GetValue ( table_classconfig . Ins ) as Dictionary < int , ClassConfigTableData > : null ;
if ( dic = = null ) { LastLog = "ClassConfig 사전 접근 실패" ; return LastLog ; }
var clone = typeof ( object ) . GetMethod ( "MemberwiseClone" , BindingFlags . Instance | BindingFlags . NonPublic ) ;
foreach ( var kv in s_defs )
{
var d = kv . Value ;
if ( d . overrideExisting )
{
// 기존 직업 행 수정(무기 모델·소켓·사거리) — PD 2026-10-09 「해머는 방패를 들고 있는 모션 → 두손 해머 모델」
ClassConfigTableData cur ;
if ( s_overridden . Contains ( d . classId ) | | ! dic . TryGetValue ( d . classId , out cur ) | | cur = = null ) continue ;
cur . s_WeaponRHPrefab = d . weaponRHPrefab ? ? "" ; cur . n_WeaponRHIndex = d . weaponRHIndex ;
cur . s_WeaponLHPrefab = d . weaponLHPrefab ? ? "" ; cur . n_WeaponLHIndex = d . weaponLHIndex ;
if ( d . attackRange > 0f ) cur . f_AttackRange = d . attackRange ;
if ( ! string . IsNullOrEmpty ( d . animController ) ) cur . s_AnimationController = d . animController ;
s_overridden . Add ( d . classId ) ; Overridden + + ;
LastLog = "직업 " + d . classId + "(" + d . name + ") 행 수정 — RH " + d . weaponRHPrefab + "@" + d . weaponRHIndex + " · LH " + ( string . IsNullOrEmpty ( d . weaponLHPrefab ) ? "없음" : d . weaponLHPrefab ) ;
continue ;
}
if ( dic . ContainsKey ( kv . Key ) ) continue ;
ClassConfigTableData tpl ;
if ( ! dic . TryGetValue ( d . templateClassId , out tpl ) | | tpl = = null ) { LastLog = "템플릿 직업 없음 " + d . templateClassId ; continue ; }
var row = clone . Invoke ( tpl , null ) as ClassConfigTableData ;
if ( row = = null ) continue ;
row . n_ClassID = d . classId ;
if ( ! string . IsNullOrEmpty ( d . animController ) ) row . s_AnimationController = d . animController ;
row . s_WeaponRHPrefab = d . weaponRHPrefab ? ? "" ; row . n_WeaponRHIndex = d . weaponRHIndex ;
row . s_WeaponLHPrefab = d . weaponLHPrefab ? ? "" ; row . n_WeaponLHIndex = d . weaponLHIndex ;
if ( d . attackRange > 0f ) row . f_AttackRange = d . attackRange ;
if ( d . attackSpd > 0f ) row . f_AttackSPD = d . attackSpd ;
dic . Add ( d . classId , row ) ;
Injected + + ;
LastLog = "직업 " + d . classId + "(" + d . name + ") 등록 — 템플릿 " + d . templateClassId + " · " + d . animController ;
var cfg = WLWeaponSettings . Instance ;
if ( cfg ! = null & & cfg . verboseLog ! = 0 ) Debug . Log ( "[WL-825] " + LastLog ) ;
}
return LastLog ;
}
/// <summary>그 직업의 공격 클립에 타격 이벤트(Projectile · 프리팹 이름 · 피해 배율)를 런타임으로 붙인다. 컨트롤러가 아직 안 올라왔으면 false.</summary>
public static bool EnsureClipEvents ( PCActor pc , int classId )
{
2026-10-20 14:21:09 +00:00
ApplyExtraEvents ( pc , classId ) ; // #953 기존 직업 클립에 추가 이벤트(활 3타 3발)
2026-10-18 15:09:05 +00:00
var d = Def ( classId ) ;
if ( d = = null | | pc = = null | | d . attackClipTokens = = null | | d . attackClipTokens . Count = = 0 ) return true ;
var an = pc . GetComponentInChildren < Animator > ( ) ;
var rac = an ! = null ? an . runtimeAnimatorController : null ;
if ( rac = = null | | ! string . IsNullOrEmpty ( d . animController ) & & ! d . animController . Contains ( rac . name ) ) return false ; // 비동기 로드 전
var clips = rac . animationClips ;
if ( clips = = null ) return false ;
int matched = 0 ;
for ( int i = 0 ; i < clips . Length ; i + + )
{
var c = clips [ i ] ; if ( c = = null ) continue ;
int ti = - 1 ;
for ( int k = 0 ; k < d . attackClipTokens . Count ; k + + ) if ( c . name . Contains ( d . attackClipTokens [ k ] ) ) { ti = k ; break ; }
if ( ti < 0 ) continue ;
matched + + ;
if ( s_eventsDone . Contains ( c . GetInstanceID ( ) ) ) continue ;
bool has = false , hasFx = false ;
var evs = c . events ;
if ( evs ! = null ) for ( int e = 0 ; e < evs . Length ; e + + ) { if ( evs [ e ] . functionName = = "Projectile" ) has = true ; if ( evs [ e ] . functionName = = "ShowEffect" ) hasFx = true ; }
if ( ! has )
{
float t = d . projectileTimes ! = null & & ti < d . projectileTimes . Count ? d . projectileTimes [ ti ] : 0.4f ;
c . AddEvent ( new AnimationEvent
{
functionName = "Projectile" ,
time = Mathf . Clamp ( c . length * t , 0f , Mathf . Max ( c . length - 0.0001f , 0f ) ) ,
stringParameter = d . projectilePrefab ,
floatParameter = d . projectileAddDmg ,
messageOptions = SendMessageOptions . DontRequireReceiver ,
} ) ;
EventsAdded + + ;
}
// #833 — 베기/찌르기 이펙트(검기 트레일 창 시작). 표에 시점·프리팹이 있을 때만.
if ( ! hasFx & & d . effectTimes ! = null & & d . effectPrefabs ! = null & & ti < d . effectTimes . Count & & ti < d . effectPrefabs . Count & & ! string . IsNullOrEmpty ( d . effectPrefabs [ ti ] ) )
{
c . AddEvent ( new AnimationEvent
{
functionName = "ShowEffect" ,
time = Mathf . Clamp ( c . length * d . effectTimes [ ti ] , 0f , Mathf . Max ( c . length - 0.0001f , 0f ) ) ,
stringParameter = d . effectPrefabs [ ti ] ,
intParameter = 1 ,
messageOptions = SendMessageOptions . DontRequireReceiver ,
} ) ;
EventsAdded + + ;
}
s_eventsDone . Add ( c . GetInstanceID ( ) ) ;
}
return matched > 0 ;
}
2026-10-20 14:21:09 +00:00
static readonly List < WLExtraClipEvent > s_extra = new List < WLExtraClipEvent > ( ) ;
static readonly HashSet < long > s_extraDone = new HashSet < long > ( ) ; // (클립 인스턴스 ID · 규칙 index)
public static int ExtraEventsAdded ; public static string ExtraLog = "" ;
/// <summary>#953 PD 「활 3타 때 투사체가 연속으로 3발 나가도록(지금 2발 · 중간에 한 발 더)」 — 표 extraClipEvents 의 그 직업 규칙을 지금 컨트롤러의 클립(이름 조각)에 붙인다. 컨트롤러가 아직 안 바뀌었으면(비동기) 이름 조각이 안 맞아 건너뛴다. 클립은 에셋이라 한 번 붙이면 세션 동안 남는다(같은 시점 중복 금지).</summary>
static void ApplyExtraEvents ( PCActor pc , int classId )
{
EnsureLoaded ( ) ;
if ( pc = = null | | s_extra . Count = = 0 ) return ;
var an = pc . GetComponentInChildren < Animator > ( ) ; var rac = an ! = null ? an . runtimeAnimatorController : null ; if ( rac = = null ) return ;
int added = 0 ;
for ( int i = 0 ; i < s_extra . Count ; i + + )
{
var x = s_extra [ i ] ; if ( x = = null | | x . classId ! = classId | | string . IsNullOrEmpty ( x . clipToken ) | | string . IsNullOrEmpty ( x . functionName ) ) continue ;
if ( ! string . IsNullOrEmpty ( x . controller ) & & ! rac . name . Contains ( x . controller ) ) continue ;
foreach ( var c in rac . animationClips )
{
if ( c = = null | | ! c . name . Contains ( x . clipToken ) ) continue ;
long key = ( ( long ) c . GetInstanceID ( ) < < 8 ) | ( long ) ( i & 255 ) ; if ( s_extraDone . Contains ( key ) ) continue ;
if ( pc . Get_CurAnim ( ) = = eAnim . Skill ) return ; // 붙인 뒤 Rebind 가 동작을 끊으므로 스킬 중엔 미룸(다음 폴링 · 기본 공격은 한 번 끊겨도 됨 — 자동 전투 중엔 대기 프레임이 거의 없음)
float at = Mathf . Clamp ( c . length * x . time , 0f , Mathf . Max ( c . length - 0.0001f , 0f ) ) ; bool has = false ;
var evs = c . events ; if ( evs ! = null ) foreach ( var e in evs ) if ( e . functionName = = x . functionName & & Mathf . Abs ( e . time - at ) < 0.02f ) { has = true ; break ; }
if ( ! has ) { c . AddEvent ( new AnimationEvent { functionName = x . functionName , time = at , stringParameter = x . stringParameter , floatParameter = x . floatParameter , intParameter = x . intParameter , messageOptions = SendMessageOptions . DontRequireReceiver } ) ; ExtraEventsAdded + + ; added + + ; }
s_extraDone . Add ( key ) ; ExtraLog = c . name + " +" + x . functionName + "@" + at . ToString ( "F2" ) + ( has ? "(이미 있음)" : "" ) + " " + x . stringParameter ;
}
}
if ( added > 0 ) { an . Rebind ( ) ; ExtraLog + = " · Rebind" ; } // 런타임 AddEvent 는 Rebind 전엔 애니메이터가 안 읽는다(Hit1LookAtTarget 선례 · #953 실측: Rebind 없이는 3발째가 안 나갔다)
}
2026-10-18 15:09:05 +00:00
}
}