// ───────────────────────────────────────────────────────────────────────────── // 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; /// #952-④ 마지막 직업 전환 시각(unscaled) — 전환 직후 컨트롤러가 비동기로 바뀌는 동안(settleSec) 첫 시전(first_cast)이 나가면 스킬 동작이 옛 컨트롤러에서 돌아 타격 이벤트가 없고(WLJumpSlam 「타격 이벤트 없음」 실측) 스킬이 끈 PCMove 가 안 돌아온다 → WLWeaponSkillSlots.TryFirstCast 가 이 시각으로 미룬다. public static float LastSwitchAt = -999f; public static bool Settling { get { var cfg = WLWeaponSettings.Instance; return Time.unscaledTime - LastSwitchAt < (cfg != null ? cfg.settleSec : 1.5f); } } 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("안정화 대기"); } bool forced = false; 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++; forced = true; } bool stalePcMove = false; try { stalePcMove = OptionInfo.Ins != null && !OptionInfo.Ins.Get_PCMove(); } catch (System.Exception) { } // #952-④ 스킬이 꺼 둔 가상패드 이동 허용이 대기 상태에 남아 있음 = 끊긴 스킬의 잔재 _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++; LastSwitchAt = Time.unscaledTime; if (forced || stalePcMove) RestoreMotion(pc, forced ? "강제 전환" : "PCMove 꺼진 채 전환"); // #952-④ _settleUntil = Time.unscaledTime + cfg.settleSec; 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; /// #952-④ PD 「마법책 무기 변경 후 캐릭터 이동이 멈추는 현상」 — 스킬(해머 강력한 일격 등) 도중 직업을 강제로 바꾸면(Play_Idle → Change_Class) 그 스킬이 끝에 되돌릴 상태가 남는다: /// 가상패드(FloatingJoystick)가 읽는 OptionInfo.PCMove(false 면 드래그·뗌을 모두 무시 → 뗌을 못 받아 IsStop·JoystickStatus 1 이 굳는다 = PD 에디터 실측) · 무적 · 모델 숨김 · 애니메이터 속도 0 · WL 시전 잠금 · 착지 연출. 전환 직후 원상. 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(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(); } 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; } } }