// ───────────────────────────────────────────────────────────────────────────── // 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; 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(); } 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"); } /// /// #851 PD 「던전에 들어갈 때 멋대로 무기가 바뀌면 안돼」 — 씬마다 새로 만들어지는 내 PC 를 **처음부터** 장착 무기의 직업으로 만든다. /// 원본은 서버 데이터의 장착 직업(Equip[PC] · 10501)으로 PC 를 만들고, 이 적용기가 폴링으로 뒤늦게 바꿔 「해머 → 검」 교체가 보였다. /// 원본 훅 = PCActor.Set 1줄. 무기가 없거나 표에 없으면 받은 값 그대로(원본 동작). /// 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; } /// 장착 무기의 직업으로 전환을 시도한다. 돌려주는 값 = 결과 한 줄(프로브·로그용). 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("안정화 대기"); } 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) { } Forced++; } _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); } Applied++; _settleUntil = Time.unscaledTime + cfg.settleSec; return Note(why + ": 직업 " + cur + " → " + desired + " (" + w.itemJson.Item_name + " · " + WLWeaponAttackTable.Describe(w.itemJson.GroupNo) + ")" + (flipped ? " · 로비 모드 우회" : "")); } 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; } } }