// ───────────────────────────────────────────────────────────────────────────── // WLFloatingBook.cs — 마법책(새 무기 9009 · 직업 10303) 장착 중 PC 옆에 떠다니는 책 (#944 · 2026-10-19) // // PD 원문: 「신규 마법책 장비도 인벤토리에 추가해줘」(새 무기 9009 · 직업 10303) · 「마법책은 오브 모션과 같도록 수정하고 캐릭터 주위에 책 오브젝트가 떠다니게 해줘. (보유 에셋중 Low Poly Casual Magic Book를 활용하고, // 부유 위치나 컨셉은 스크린샷을 참고할 것)」 · 「아트 컨셉은 무시해도 되며 위치 및 동작 형태만 참고할 것」 // ■ 원리 — 원본 코드 훅 0줄. WLWeaponClassApplier 와 같이 씬 로드 뒤 상주 오브젝트 1개를 만들고, WLInventory.Changed(장착 변경) + pollSec 폴링으로 // 「내 PC 가 있고 · 살아 있고(활성) · 장착 무기 GroupNo 가 weaponIds 에 든다(또는 그 직업이 classIds 에 든다)」를 판정한다. // 참이면 책 프리팹(Resources · WL 사본)을 PC 와 같은 씬에 count 권 만들고, LateUpdate(애니메이터 뒤)마다 PC 기준 (side · height · forward) 자리로 // 부드럽게 따라가게 둔다(상하 흔들림 · 좌우 흔들림 · 선택 공전). 거짓이 되면(해제 · 사망 · PC 교체) 바로 없앤다. 씬이 바뀌면 책은 씬과 함께 사라지고 // 다음 판정에서 새 PC 에 다시 만든다. // ■ 애니 — 책 클립(7.46 초 · 0~1 초 펼침 · 넘김 · 4.5 초부터 닫힘)을 처음부터 재생해 펼친 뒤 [animLoopStartSec, animLoopEndSec) 을 반복한다(넘김). // ■ 값 = Resources/WL/WLFloatingBook.json (C45 · 코드 기본값은 0/빈 값 — 파일이 없으면 꺼짐) · 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업) // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using WL.Equip; namespace WL.Combat.Weapon { [System.Serializable] public class WLFloatingBookJson { public int enabled; public int[] weaponIds = new int[0]; // 장착 무기 GroupNo 가 여기 들면 띄운다(#944 마법책 9009) public int[] classIds = new int[0]; // (선택) 장착 무기의 직업(공격표 class_id)이 여기 들어도 띄운다 — 같은 직업을 쓰는 다른 무기에도 뜨므로 기본은 비움 public string prefab = ""; // Resources 경로(확장자 없음) public int count = 1; // 권 수(여러 권 = PC 둘레에 고르게) public float scale = 1f; // 프리팹 배율 public float side, height, forward; // PC 기준 오른쪽 · 발밑 위 · 정면 m public float[] euler = new float[3]; // PC(또는 카메라) 방향 기준 책 회전(도) public int faceCamera; // 1 = 책 방향(yaw)을 카메라 기준으로(펼친 면이 늘 화면 쪽) · 0 = PC 정면 기준 public float bobAmp, bobHz; // 상하 흔들림 진폭 m · 초당 횟수 public float swayYawDeg, swayRollDeg, swayHz;// 좌우 돌기 · 기울기 흔들림(도) · 초당 횟수 public float orbitDegPerSec; // 0 = 옆에 고정 · 그 외 = PC 둘레 공전 public float followLerp, turnLerp; // 따라가기 · 돌기 부드러움(초당 · 0 = 즉시) public float animLoopStartSec, animLoopEndSec;// 책 클립 반복 구간(초 · 끝 ≤ 시작 = 반복 안 함) public int matchPcLayer; // 1 = 책 레이어를 PC 와 같게(같은 카메라 경로로 그려지게) public float pollSec = 0.25f; // #949 PD 「책이 캐릭터를 가리지 않게 가능한 캐릭터보다 뒤쪽에 · 시전 동작 중엔 캐릭터 모션 앞쪽으로 유지」 public int cameraSpace; // 1 = 평소 자리(side · forward)를 카메라 yaw 기준으로 — forward + = 카메라에서 먼 쪽(화면 위 · 캐릭터 뒤) · 0 = PC 정면 기준 public string[] castStates = new string[0]; // PC 현재 클립 이름에 이 조각이 들면 「시전 중」 → castSide · castHeight · castForward(PC 정면 기준) 로 public float castSide, castHeight, castForward; public int hideHandWeapon; // #949 1 = 책이 떠 있는 동안 PC 손 소켓(w_*) 아래 무기 렌더러를 끈다(지팡이·셉터 숨김 · 책이 사라지면 되돌림) — 직업 표(수정 행)로 비우면 Set_Obj 재부착이 로비 컨트롤러를 끼워 실패(실측) public string note = ""; } public static class WLFloatingBookSettings { public const string ResourcesPath = "WL/WLFloatingBook"; static WLFloatingBookJson s_cfg; static bool s_loaded; public static WLFloatingBookJson Cfg { get { if (!s_loaded) Load(); return s_cfg; } } public static void Reload() { s_loaded = false; } static void Load() { s_loaded = true; try { var ta = Resources.Load(ResourcesPath); s_cfg = ta != null ? JsonUtility.FromJson(ta.text) : null; } catch (System.Exception ex) { WLFloatingBook.LastLog = "설정 읽기 실패 — " + ex.Message; s_cfg = null; } if (s_cfg == null) s_cfg = new WLFloatingBookJson { enabled = 0 }; } } /// 상주 드라이버(씬 로드 뒤 1개 · DontDestroyOnLoad) — 책 만들기 · 없애기 · 매 프레임 배치. public sealed class WLFloatingBook : MonoBehaviour { public static WLFloatingBook Instance { get; private set; } public static int Spawned, Cleared, Frames, LoopJumps; public static string LastLog = ""; readonly List _books = new List(); readonly List _anims = new List(); PCActor _pc; float _t, _t0, _orbit; bool _dirty = true; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (Instance != null) return; var c = WLFloatingBookSettings.Cfg; if (c == null || c.enabled == 0) return; var go = new GameObject("WL_FloatingBook"); DontDestroyOnLoad(go); Instance = go.AddComponent(); } void OnEnable() { WLInventory.Changed += OnChanged; } void OnDisable() { WLInventory.Changed -= OnChanged; Clear("비활성"); } void OnDestroy() { if (Instance == this) Instance = null; } void OnChanged() { _dirty = true; } void Update() { var c = WLFloatingBookSettings.Cfg; if (c == null) return; _t += Time.unscaledDeltaTime; if (!_dirty && _t < Mathf.Max(0.05f, c.pollSec)) return; _t = 0f; _dirty = false; try { Tick(c); } catch (System.Exception ex) { LastLog = "예외 — " + ex.Message; } } void Tick(WLFloatingBookJson c) { PCActor pc = MyValue.bMyPC ? MyValue.MyPC : null; bool want = c.enabled != 0 && pc != null && !pc.IsDead() && WantsBook(c); bool missing = false; for (int i = 0; i < _books.Count; i++) if (_books[i] == null) { missing = true; break; } if (_books.Count > 0 && (!want || missing || !ReferenceEquals(pc, _pc))) Clear(!want ? "조건 해제" : missing ? "씬 정리됨" : "PC 교체"); if (want && _books.Count == 0) Spawn(c, pc); if (want && c.hideHandWeapon != 0 && _books.Count > 0) HideHandWeapon(pc); // 매 폴링 — 원본이 무기 모델을 다시 붙여도(Set_Obj) 다시 끈다 } // #949 손 소켓(w_*) 아래 무기 렌더러 끄기 · 되돌리기 readonly List _hiddenWeapon = new List(); public static int WeaponHidden; void HideHandWeapon(PCActor pc) { foreach (var t in pc.GetComponentsInChildren(true)) { if (!t.name.StartsWith("w_")) continue; foreach (var r in t.GetComponentsInChildren(true)) { if (r == null || !r.enabled || r is ParticleSystemRenderer) continue; r.enabled = false; _hiddenWeapon.Add(r); WeaponHidden++; } } } void RestoreHandWeapon() { for (int i = 0; i < _hiddenWeapon.Count; i++) if (_hiddenWeapon[i] != null) _hiddenWeapon[i].enabled = true; _hiddenWeapon.Clear(); } /// 장착 무기(FR GroupNo)가 weaponIds 에 들거나, 그 무기의 공격표 class_id 가 classIds 에 드는가. static bool WantsBook(WLFloatingBookJson c) { bool byWeapon = c.weaponIds != null && c.weaponIds.Length > 0, byClass = c.classIds != null && c.classIds.Length > 0; if (!byWeapon && !byClass || !WLInventory.EnsureLoaded()) return false; var w = WLInventory.EquippedInSlot(eFREquipmentType.Weapon); int gid = w != null && w.itemJson != null ? w.itemJson.GroupNo : 0; if (gid <= 0) return false; if (byWeapon && System.Array.IndexOf(c.weaponIds, gid) >= 0) return true; int cls = byClass ? WLWeaponAttackTable.ClassIdFor(gid) : 0; return cls > 0 && System.Array.IndexOf(c.classIds, cls) >= 0; } void Spawn(WLFloatingBookJson c, PCActor pc) { var prefab = string.IsNullOrEmpty(c.prefab) ? null : Resources.Load(c.prefab); if (prefab == null) { LastLog = "책 프리팹 없음 — " + c.prefab; return; } int n = Mathf.Max(1, c.count); for (int i = 0; i < n; i++) { var go = Instantiate(prefab); _books.Add(go.transform); _anims.Add(go.GetComponentInChildren()); // 먼저 등록(예외가 나도 Clear 가 치운다) · 기본 상태가 처음(닫힘)부터 재생 → 펼친 뒤 반복 구간 go.name = prefab.name + "_" + i; var ps = pc.gameObject.scene; // PC 와 같은 씬 → 씬이 바뀌면 함께 사라진다 if (ps.IsValid() && go.scene != ps) { if (ps.name == "DontDestroyOnLoad") DontDestroyOnLoad(go); else UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(go, ps); } go.transform.localScale = prefab.transform.localScale * c.scale; if (c.matchPcLayer != 0) SetLayer(go.transform, pc.gameObject.layer); } _pc = pc; _t0 = Time.time; _orbit = 0f; Place(c, true); Spawned++; LastLog = "책 " + n + "권 생성 — " + prefab.name + " · PC " + pc.name + " · 배율 " + c.scale + " · 옆 " + c.side + " 위 " + c.height + " 앞 " + c.forward; } void Clear(string why) { if (_books.Count == 0) return; for (int i = 0; i < _books.Count; i++) if (_books[i] != null) Destroy(_books[i].gameObject); _books.Clear(); _anims.Clear(); _pc = null; RestoreHandWeapon(); // #949 Cleared++; LastLog = "책 정리 — " + why; } static void SetLayer(Transform t, int layer) { t.gameObject.layer = layer; for (int i = 0; i < t.childCount; i++) SetLayer(t.GetChild(i), layer); } void LateUpdate() { if (_books.Count == 0 || _pc == null) return; var c = WLFloatingBookSettings.Cfg; if (c == null) return; Place(c, false); LoopAnim(c); Frames++; } void Place(WLFloatingBookJson c, bool snap) { float t = Time.time - _t0; _orbit += c.orbitDegPerSec * Time.deltaTime; var pt = _pc.transform; var pcYaw = Quaternion.Euler(0f, pt.eulerAngles.y, 0f); Quaternion faceYaw = pcYaw; if (c.faceCamera != 0) { var cam = Camera.main; if (cam != null) { var f = cam.transform.forward; f.y = 0f; if (f.sqrMagnitude > 1e-4f) faceYaw = Quaternion.LookRotation(f.normalized, Vector3.up); } } var e = c.euler != null && c.euler.Length >= 3 ? Quaternion.Euler(c.euler[0], c.euler[1], c.euler[2]) : Quaternion.identity; // #949 시전 중(현재 클립 이름에 castStates 조각) = PC 정면 기준 cast 자리 · 평소 = cameraSpace 면 카메라 yaw 기준(캐릭터 뒤 = 화면 위) bool casting = IsCasting(c); Casting = casting; float side = casting ? c.castSide : c.side, height = casting ? c.castHeight : c.height, forward = casting ? c.castForward : c.forward; Quaternion basis = casting || c.cameraSpace == 0 ? pcYaw : (c.faceCamera != 0 ? faceYaw : pcYaw); if (!casting && c.cameraSpace != 0 && c.faceCamera == 0) { var cam = Camera.main; if (cam != null) { var f = cam.transform.forward; f.y = 0f; if (f.sqrMagnitude > 1e-4f) basis = Quaternion.LookRotation(f.normalized, Vector3.up); } } int n = _books.Count; float kp = snap || c.followLerp <= 0f ? 1f : 1f - Mathf.Exp(-c.followLerp * Time.deltaTime); float kr = snap || c.turnLerp <= 0f ? 1f : 1f - Mathf.Exp(-c.turnLerp * Time.deltaTime); for (int i = 0; i < n; i++) { var b = _books[i]; if (b == null) continue; float share = (float)i / n; // 여러 권 = 둘레 · 흔들림 위상 고르게 var ring = Quaternion.Euler(0f, _orbit + 360f * share, 0f); float bob = Mathf.Sin((t * c.bobHz + share) * Mathf.PI * 2f) * c.bobAmp; float sw = Mathf.Sin((t * c.swayHz + share) * Mathf.PI * 2f); var pos = pt.position + basis * (ring * new Vector3(side, 0f, forward)) + Vector3.up * (height + bob); var rot = faceYaw * ring * e * Quaternion.Euler(0f, sw * c.swayYawDeg, sw * c.swayRollDeg); if (kp >= 1f && kr >= 1f) b.SetPositionAndRotation(pos, rot); else b.SetPositionAndRotation(Vector3.Lerp(b.position, pos, kp), Quaternion.Slerp(b.rotation, rot, kr)); } } public static bool Casting; // 프로브용 — 마지막 배치가 시전 자리였는가 /// #949 PC 애니메이터 0층 현재 클립 이름에 castStates 조각이 들면 시전 중. bool IsCasting(WLFloatingBookJson c) { if (c.castStates == null || c.castStates.Length == 0 || _pc == null) return false; var an = _pc.m_animation; if (an == null) return false; var ci = an.GetCurrentAnimatorClipInfo(0); string name = ci.Length > 0 && ci[0].clip != null ? ci[0].clip.name : ""; if (name.Length == 0) return false; for (int i = 0; i < c.castStates.Length; i++) if (!string.IsNullOrEmpty(c.castStates[i]) && name.Contains(c.castStates[i])) return true; return false; } /// 펼친 뒤 넘김 구간 반복 — 상태 시간이 끝 초를 넘으면 시작 초로 되돌린다. void LoopAnim(WLFloatingBookJson c) { if (c.animLoopEndSec <= c.animLoopStartSec) return; for (int i = 0; i < _anims.Count; i++) { var an = _anims[i]; if (an == null || !an.isActiveAndEnabled) continue; var st = an.GetCurrentAnimatorStateInfo(0); float len = st.length; if (len <= 0.0001f) continue; if (st.normalizedTime * len >= c.animLoopEndSec) { an.Play(st.fullPathHash, 0, c.animLoopStartSec / len); LoopJumps++; } } } /// 프로브용 한 줄 — 책 수 · 셈 · PC 와의 거리/높이. public static string Describe() { var s = "spawned=" + Spawned + " cleared=" + Cleared + " frames=" + Frames + " loops=" + LoopJumps; var me = Instance; if (me != null && me._pc != null) for (int i = 0; i < me._books.Count; i++) { var b = me._books[i]; if (b == null) continue; var d = b.position - me._pc.transform.position; var local = Quaternion.Inverse(Quaternion.Euler(0f, me._pc.transform.eulerAngles.y, 0f)) * d; s += " | " + b.name + " local(" + local.x.ToString("F2") + "," + local.y.ToString("F2") + "," + local.z.ToString("F2") + ") scale " + b.localScale.x.ToString("F2"); } return s + " · " + LastLog; } } }