// ───────────────────────────────────────────────────────────────────────────── // WLWeaponFitter.cs — 치비 PC 손에 맞게 무기 프리팹 크기를 자동 보정한다 (발주서 WL-814p) // // ■ 문제 (814n §1 ⑰ 실측) // 무기는 어른 몸(Ai01 키 1.7017 m · 인게임 1.1912 m)에 맞춰 만들어졌는데, 치비(M05)는 // 키가 같아도 **팔이 짧고 손이 작다**. `Elven_Sword_01` 월드 최대변 1.341 m = 키의 113 %. // // ■ 왜 여기(프리팹 컴포넌트)인가 — 원본 수정 0 // 무기 부착 지점은 `PCActor.cs:358` `Make_Weapon(fullpath, tfs_weapon[index], Vector3.zero, Vector3.one)` // = **Addressables 비동기 콜백**(`AddrResourceMgr.LoadObject(… handle => …)`)이라 // 호출 프레임에 자식이 생기지 않는다. 814n 에서 원본 1줄 훅 예외를 이미 소진했으므로 // 훅을 더 넣지 않고 **소켓을 감시**한다. // // ■ 생성 시점 감지 = LateUpdate 소켓 스캔 (근거) // ⓐ 비동기라 `Change_Weapon()` 직후 스캔은 항상 빈손이다 → 1회 스캔 불가. // ⓑ `Transform` 에는 자식 추가 콜백이 없다(`OnTransformChildrenChanged` 는 **부모 오브젝트**에 // 스크립트가 붙어 있어야 하는데 소켓은 원본 본 계층이라 컴포넌트를 붙일 수 없다 = 원본 프리팹 수정). // ⓒ 그래서 루트(이 컴포넌트)가 `LateUpdate` 마다 소켓 7개의 자식을 훑는다. // 비용: 소켓 7 × 자식 0~1 = 프레임당 7~9 회 `childCount`/`GetChild` — 측정·보정은 **새 자식에만 1회**. // ⓓ `LateUpdate` 인 이유: 애니메이터가 본을 갱신한 뒤라 소켓의 월드 스케일(`lossyScale`)이 확정돼 있다. // // ■ 멱등 (같은 인스턴스 재보정 0) // 보정한 자식의 `GetInstanceID()` 를 기억한다. `Change_Weapon()` 이 `Destroy` 후 새로 만들면 // 인스턴스 ID 가 달라지므로 **재장착 시에는 다시 계산**된다. 사라진 ID 는 매 스캔 정리(누수 0). // // ■ 크기 측정 = 포즈 무관 (측정 방식이 무기와 캐릭터가 서로 다르다 · 실측 근거) // · 무기 = `Renderer.localBounds`(= `mesh.bounds`) 를 **무기 루트 로컬 공간**으로 모아 재고 `lossyScale` 을 곱한다. // 월드 AABB 는 손이 기울면 값이 변하지만 이 방식은 **방향 맞춘 실치수**라 프레임마다 같다. // · 캐릭터 = `Renderer.bounds`(월드 AABB) ÷ 루트 `lossyScale.y`. 🔴 스킨드 메시의 `localBounds` 는 // **루트 본 공간** 기준이라 `r.transform` 행렬로 환산하면 틀린다(LH_M05 실측 0.4635 m ≠ 실제 1.1912 m). // `bounds` 는 정적 바운즈를 루트 본으로 옮긴 것이라 **크기가 포즈에 안 변한다**. // · 트레일·파티클·라인 렌더러는 **크기 측정에서 제외**한다(이펙트가 길이를 부풀리면 무기가 과도하게 줄어든다). // · 배수는 (키 ÷ 무기길이) 비율이라 **루트 스케일에 불변** → 814n `WLPcScaleCompensator` 와 실행 순서가 엇갈려도 값이 같다. // // 🔴 이 컴포넌트는 새 프리팹 `LH_M05` 에만 붙는다 → `Ai01` 경로(스위치 off)는 구조적으로 영향 0. // 🔴 `WLWeaponFitSettings.enabled_ = 0` 이면 배수 1.0000 · 아무것도 건드리지 않는다(C8). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.Character { [DisallowMultipleComponent] public sealed class WLWeaponFitter : MonoBehaviour { [Tooltip("0 보다 크면 설정 에셋의 targetRatio 대신 이 값을 쓴다(프리팹 단위 예외용).")] public float overrideTargetRatio; [Tooltip("비워 두면 루트의 PCActor.tfs_weapon 7개를 자동으로 쓴다. 채우면 이 목록만 감시한다.")] public Transform[] sockets; // 보정 완료한 무기 인스턴스 ID → 적용한 배수(멱등 · 재장착 시 ID 가 바뀌므로 자동 재계산) readonly Dictionary _applied = new Dictionary(); // 팔레트(WL-816b)를 이미 끼운 무기 인스턴스 ID. 크기 보정과 **따로** 기억한다(스위치가 독립이라). readonly HashSet _paletted = new HashSet(); readonly List _live = new List(); readonly List _dead = new List(); Transform[] _cachedSockets; float _baseHeight = -1f; // 루트 로컬 공간에서 잰 캐릭터 키(스케일 1 기준) · 1회 측정 /// 지금 캐릭터 키(월드 m). 루트 스케일이 바뀌면 같이 바뀐다. public float CharacterHeight { get { if (_baseHeight <= 0f) _baseHeight = MeasureBodyLocalHeight(); return _baseHeight * Mathf.Abs(transform.lossyScale.y); } } /// /// 몸 높이를 루트 스케일 1 기준으로 환산해 1회만 잰다. **소켓 아래(= 무기)는 제외**한다 — /// 무기가 먼저 붙은 프레임에 키를 재면 무기 길이만큼 키가 부풀어 배수가 틀어진다. /// /// 🔴 캐릭터는 `Renderer.localBounds` 를 쓰면 안 된다 — 스킨드 메시의 로컬 바운즈는 /// **루트 본 공간** 기준이라 `r.transform` 행렬로 환산하면 틀린다(LH_M05 실측 0.4635 m ≠ 1.1912 m). /// `Renderer.bounds`(월드)는 스킨드라도 정적 바운즈를 루트 본으로 옮긴 것이라 **크기가 포즈에 안 변한다** /// → 월드 AABB 높이를 루트 lossyScale 로 나눠 저장하면 로비(1.0)·인게임(0.7) 어디서도 같은 값이 나온다. /// float MeasureBodyLocalHeight() { var socks = ResolveSockets(); var rends = GetComponentsInChildren(false); bool any = false; var b = new Bounds(); for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null) continue; if (!(r is SkinnedMeshRenderer) && !(r is MeshRenderer)) continue; if (socks != null && IsUnder(r.transform, socks)) continue; if (!any) { b = r.bounds; any = true; } else b.Encapsulate(r.bounds); } if (!any) return 0f; float sy = Mathf.Abs(transform.lossyScale.y); return sy > 1e-5f ? b.size.y / sy : b.size.y; } /// 마지막 스캔에서 보정한 무기 수(프로브용). public int LastApplied { get; private set; } void OnEnable() { _applied.Clear(); _paletted.Clear(); _baseHeight = -1f; } void LateUpdate() { Scan(false); } /// 소켓을 훑어 새로 생긴 무기에만 보정을 1회 적용한다. 보정한 개수를 돌려준다. public int Scan(bool force) { LastApplied = 0; var socks = ResolveSockets(); if (socks == null || socks.Length == 0) return 0; // 🔴 크기 보정 스위치 off = 보정 완전 무동작(C8 되돌리기 = 지금 상태 100 %). // 단 **팔레트(816b)는 이 스위치와 독립**이라 아래 루프는 계속 돈다 — // 보정을 꺼도 도트 화풍은 남고, 팔레트를 꺼도 보정은 남는다. bool fit = WLWeaponFitSettings.Enabled; if (!fit && _applied.Count > 0) _applied.Clear(); bool pal = WL.Look.Character.WLCharacterPaletteSettings.WeaponEnabled; if (!fit && !pal) { if (_paletted.Count > 0) _paletted.Clear(); return 0; } _live.Clear(); for (int s = 0; s < socks.Length; s++) { var sock = socks[s]; if (sock == null) continue; int cc = sock.childCount; for (int c = 0; c < cc; c++) { var w = sock.GetChild(c); int id = w.gameObject.GetInstanceID(); _live.Add(id); if (fit && (force || !_applied.ContainsKey(id))) // 이미 보정한 인스턴스 = 건너뜀(멱등) { float k = Fit(w, sock); _applied[id] = k; if (k != 1f) LastApplied++; } // ── 팔레트(WL-816b) — 메시(UV)·머티리얼만 바꾼다. 정점 좌표가 같아 위 배수 계산과 무관하다. if (pal && (force || !_paletted.Contains(id))) { WL.Look.Character.WLWeaponPalette.Apply(w); _paletted.Add(id); } } } // 사라진 무기 인스턴스 정리(누수 0) if (_applied.Count > _live.Count) { _dead.Clear(); foreach (var kv in _applied) if (!_live.Contains(kv.Key)) _dead.Add(kv.Key); for (int i = 0; i < _dead.Count; i++) _applied.Remove(_dead[i]); } if (_paletted.Count > _live.Count) { _dead.Clear(); foreach (var id in _paletted) if (!_live.Contains(id)) _dead.Add(id); for (int i = 0; i < _dead.Count; i++) _paletted.Remove(_dead[i]); } return LastApplied; } /// 무기 1개를 보정한다. 적용한 배수(1 = 무보정)를 돌려준다. public float Fit(Transform weapon, Transform socket) { var st = WLWeaponFitSettings.Instance; float ratio = overrideTargetRatio > 0f ? (WLWeaponFitSettings.Enabled ? overrideTargetRatio : 0f) : WLWeaponFitSettings.ResolveRatio(weapon.name); if (ratio <= 0f) return 1f; // 스위치 off · 설정 없음 = 무동작 float charH = CharacterHeight; if (charH <= 0f) return 1f; float cur = CurrentMaxDim(weapon); if (cur <= 1e-5f) return 1f; // 측정할 메시가 없다(이펙트 전용 등) float k = (ratio * charH) / cur; float lo = st != null && st.minScale > 0f ? st.minScale : 0.3f; float hi = st != null && st.maxScale > 0f ? st.maxScale : 1f; k = Mathf.Clamp(k, lo, hi); if (Mathf.Abs(k - 1f) < 1e-4f) return 1f; weapon.localScale = weapon.localScale * k; if (st != null && st.verboseLog) Debug.Log(string.Format("[WL-814p] 무기 보정: {0} @{1} · {2:F4} m / 키 {3:F4} m = {4:F1} % → ×{5:F4} → {6:F4} m ({7:F1} %)", WLWeaponFitSettings.Strip(weapon.name), socket != null ? socket.name : "?", cur, charH, cur / charH * 100f, k, cur * k, cur * k / charH * 100f)); return k; } /// 무기의 지금 월드 최대변(m · 포즈 무관 = 로컬 AABB × lossyScale). public float CurrentMaxDim(Transform weapon) { var sz = MeasureLocalSize(weapon); var ls = weapon.lossyScale; return Mathf.Max(sz.x * Mathf.Abs(ls.x), Mathf.Max(sz.y * Mathf.Abs(ls.y), sz.z * Mathf.Abs(ls.z))); } Transform[] ResolveSockets() { if (sockets != null && sockets.Length > 0) return sockets; if (_cachedSockets != null && _cachedSockets.Length > 0) return _cachedSockets; var actor = GetComponent(); if (actor != null && actor.tfs_weapon != null && actor.tfs_weapon.Length > 0) _cachedSockets = actor.tfs_weapon; return _cachedSockets; } /// /// 무기() 로컬 공간에서 잰 메시 AABB 크기 = **방향 맞춘 실치수**(스케일 1 기준 · 포즈 무관). /// 월드 AABB 는 손이 기울면 값이 변해 프레임마다 달라지므로 쓰지 않는다. /// 트레일·파티클·라인 렌더러는 제외한다(이펙트가 길이를 부풀리면 무기가 과도하게 줄어든다). /// 무기는 전부 `MeshRenderer` 라 `localBounds`(= `mesh.bounds`) × `r.transform` 행렬이 정확하다(실측). /// public static Vector3 MeasureLocalSize(Transform root) { var rends = root.GetComponentsInChildren(false); bool any = false; Vector3 min = Vector3.zero, max = Vector3.zero; var w2l = root.worldToLocalMatrix; for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null) continue; if (!(r is SkinnedMeshRenderer) && !(r is MeshRenderer)) continue; // ParticleSystem/Trail/Line 제외 var b = r.localBounds; // 렌더러 로컬 공간 · 포즈 무관 var m = w2l * r.localToWorldMatrix; var c = b.center; var e = b.extents; for (int k = 0; k < 8; k++) { var p = m.MultiplyPoint3x4(new Vector3( c.x + ((k & 1) == 0 ? -e.x : e.x), c.y + ((k & 2) == 0 ? -e.y : e.y), c.z + ((k & 4) == 0 ? -e.z : e.z))); if (!any) { min = max = p; any = true; } else { min = Vector3.Min(min, p); max = Vector3.Max(max, p); } } } return any ? (max - min) : Vector3.zero; } static bool IsUnder(Transform t, Transform[] parents) { for (int i = 0; i < parents.Length; i++) { var p = parents[i]; if (p == null) continue; if (t == p || t.IsChildOf(p)) return true; } return false; } } }