diff --git a/Assets/Res_Addr/PC/LH_M05.prefab b/Assets/Res_Addr/PC/LH_M05.prefab index 72e865f7e..5f276b7a7 100644 --- a/Assets/Res_Addr/PC/LH_M05.prefab +++ b/Assets/Res_Addr/PC/LH_M05.prefab @@ -6189,6 +6189,7 @@ GameObject: - component: {fileID: 3657923760099891804} - component: {fileID: 1835281278008071509} - component: {fileID: 1155056168462878929} + - component: {fileID: 8835979289890761207} m_Layer: 3 m_Name: LH_M05 m_TagString: Untagged @@ -6400,6 +6401,20 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Assembly-CSharp::WL.Character.WLPcScaleCompensator overrideCompensation: 0 +--- !u!114 &8835979289890761207 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7522112115323654996} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1ce0821367b0b2d4d805337732041172, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::WL.Character.WLWeaponFitter + overrideTargetRatio: 0 + sockets: [] --- !u!1 &7572986597832592123 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/WL/Character/WLWeaponFitSettings.cs b/Assets/WL/Character/WLWeaponFitSettings.cs new file mode 100644 index 000000000..44a82087e --- /dev/null +++ b/Assets/WL/Character/WLWeaponFitSettings.cs @@ -0,0 +1,122 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLWeaponFitSettings.cs — 치비 PC 의 무기 크기 자동 보정 단일 출처(SOT · C45) +// +// 발주서 WL-814p (#814 잔여 결함) · 근거 = 완료보고 WL-814n §1 ⑰ +// 치비 교체 후 `Elven_Sword_01` 월드 최대변 1.341 m = 캐릭터 키(1.1912 m)의 **113 %** +// → 어른 몸(Ai01 1.7017 m) 기준으로 만든 무기가 치비 손에 그대로 붙어 과대. +// +// ■ 어디를 고치나 — 원본 수정 0 +// 원본이 무기를 다는 지점은 `Assets/Script/Character/PCActor.cs:358` +// Make_Weapon(fullpath, tfs_weapon[index], Vector3.zero, Vector3.one) +// = Addressables 비동기 콜백에서 소켓에 **localScale 1** 로 붙인다(실측). +// 814n 에서 원본 1줄 예외를 이미 소진했으므로 훅을 더 넣지 않는다 → +// **새 프리팹 `LH_M05` 에만 붙는 `WLWeaponFitter`** 가 소켓을 감시해서 보정한다. +// Ai01 경로에는 이 컴포넌트가 없으므로 구조적으로 영향 0. +// +// ■ 되돌리기(C8) +// 이 에셋의 `enabled_` 를 끄거나 에셋을 지우면 배수 1.0000 = 컴포넌트 no-op = 지금 상태 100 %. +// +// 에셋 경로(고정): Assets/WL/Settings/Resources/WL/WLWeaponFitSettings.asset +// 🔴 Assets/WL/Character/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace WL.Character +{ + [CreateAssetMenu(fileName = "WLWeaponFitSettings", menuName = "WL/Weapon Fit Settings", order = 332)] + public sealed class WLWeaponFitSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLWeaponFitSettings"; + + static WLWeaponFitSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null = 무동작. + public static WLWeaponFitSettings Instance + { + get + { + if (!s_lookupDone) { s_cached = Resources.Load(ResourcesPath); s_lookupDone = true; } + return s_cached; + } + } + + /// QA·A/B 전용 런타임 스위치. 0 = 에셋의 enabled_ · 1 = 강제 on · -1 = 강제 off. + public static int RuntimeOverride; + + /// 보정이 살아 있는가. false 면 무기 스케일에 손대지 않는다(C8). + public static bool Enabled + { + get + { + var i = Instance; + if (i == null || RuntimeOverride < 0) return false; + return RuntimeOverride > 0 || i.enabled_; + } + } + + /// 테스트용: 캐시를 비운다. + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + [Serializable] + public sealed class WeaponRatio + { + [Tooltip("무기 프리팹 이름(Assets/Res_Addr/Weapon/<이름>.prefab). \"(Clone)\" 은 무시하고 비교한다.")] + public string weaponName; + + [Tooltip("이 무기만 쓸 비율(무기 최대변 ÷ 캐릭터 키). 0 이하면 targetRatio 를 쓴다.")] + public float ratio; + } + + [Header("전체 스위치 (PD 되돌리기 1순위)")] + [Tooltip("끄면 무기 스케일에 아무것도 하지 않는다 = 지금 상태 100 %. 에셋을 지워도 같다.")] + public bool enabled_ = true; + + [Tooltip("보정이 일어날 때 Debug.Log 를 남긴다(A/B 확인용). 기본 끔.")] + public bool verboseLog; + + [Header("목표 비율")] + [Tooltip("보정 후 무기 최대변 ÷ 캐릭터 키. 0.60 = 캐릭터 키의 60 %.\n" + + "치비 교체 전 어른 Ai01(1.7017 m) 기준 Elven_Sword_01 은 0.79 였다(1.341/1.7017).")] + public float targetRatio = 0.60f; + + [Tooltip("보정 배수 하한(원본 크기 대비). 0.3 = 아무리 커도 30 % 미만으로는 줄이지 않는다.")] + public float minScale = 0.3f; + + [Tooltip("보정 배수 상한(원본 크기 대비). 1.0 = 무기를 키우지는 않는다.")] + public float maxScale = 1.0f; + + [Header("무기별 예외 (비어 있으면 전부 targetRatio)")] + [Tooltip("이름 → 비율 override. 방패·오브처럼 길이 기준이 다른 무기를 따로 잡을 때만 쓴다.")] + public List perWeapon = new List(); + + /// 무기 이름에 맞는 목표 비율. 스위치가 꺼져 있으면 0(= 무보정). + public static float ResolveRatio(string weaponName) + { + var i = Instance; + if (!Enabled || i == null) return 0f; + if (i.perWeapon != null && !string.IsNullOrEmpty(weaponName)) + { + var key = Strip(weaponName); + for (int n = 0; n < i.perWeapon.Count; n++) + { + var row = i.perWeapon[n]; + if (row == null || string.IsNullOrEmpty(row.weaponName) || row.ratio <= 0f) continue; + if (string.Equals(Strip(row.weaponName), key, StringComparison.OrdinalIgnoreCase)) return row.ratio; + } + } + return i.targetRatio > 0f ? i.targetRatio : 0f; + } + + /// "Elven_Sword_01(Clone)" → "Elven_Sword_01" (원본 `DSUtil.Get_Clone` 이 Instantiate 기본 이름을 쓴다). + public static string Strip(string n) + { + if (string.IsNullOrEmpty(n)) return n; + int i = n.IndexOf("(Clone)", StringComparison.Ordinal); + return i >= 0 ? n.Substring(0, i).Trim() : n.Trim(); + } + } +} diff --git a/Assets/WL/Character/WLWeaponFitSettings.cs.meta b/Assets/WL/Character/WLWeaponFitSettings.cs.meta new file mode 100644 index 000000000..542cc67de --- /dev/null +++ b/Assets/WL/Character/WLWeaponFitSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f27385554e136541a8204a9809b8435 \ No newline at end of file diff --git a/Assets/WL/Character/WLWeaponFitter.cs b/Assets/WL/Character/WLWeaponFitter.cs new file mode 100644 index 000000000..bbef7313c --- /dev/null +++ b/Assets/WL/Character/WLWeaponFitter.cs @@ -0,0 +1,240 @@ +// ───────────────────────────────────────────────────────────────────────────── +// 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(); + 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(); _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 %) + if (!WLWeaponFitSettings.Enabled) { if (_applied.Count > 0) _applied.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 (!force && _applied.ContainsKey(id)) continue; // 이미 보정한 인스턴스 = 건너뜀(멱등) + float k = Fit(w, sock); + _applied[id] = k; + if (k != 1f) LastApplied++; + } + } + + // 사라진 무기 인스턴스 정리(누수 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]); + } + 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; + } + } +} diff --git a/Assets/WL/Character/WLWeaponFitter.cs.meta b/Assets/WL/Character/WLWeaponFitter.cs.meta new file mode 100644 index 000000000..ec80ea8d4 --- /dev/null +++ b/Assets/WL/Character/WLWeaponFitter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1ce0821367b0b2d4d805337732041172 \ No newline at end of file diff --git a/Assets/WL/Settings/Resources/WL/WLWeaponFitSettings.asset b/Assets/WL/Settings/Resources/WL/WLWeaponFitSettings.asset new file mode 100644 index 000000000..7c80cbb77 --- /dev/null +++ b/Assets/WL/Settings/Resources/WL/WLWeaponFitSettings.asset @@ -0,0 +1,20 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5f27385554e136541a8204a9809b8435, type: 3} + m_Name: WLWeaponFitSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Character.WLWeaponFitSettings + enabled_: 1 + verboseLog: 0 + targetRatio: 0.6 + minScale: 0.3 + maxScale: 1 + perWeapon: [] diff --git a/Assets/WL/Settings/Resources/WL/WLWeaponFitSettings.asset.meta b/Assets/WL/Settings/Resources/WL/WLWeaponFitSettings.asset.meta new file mode 100644 index 000000000..3474fee9a --- /dev/null +++ b/Assets/WL/Settings/Resources/WL/WLWeaponFitSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a67110aa61f25e14196931ef77889cba +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: