Project_WL/Assets/WL/Character/WLWeaponFitter.cs

241 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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<GameObject>(… 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<int, float> _applied = new Dictionary<int, float>();
readonly List<int> _live = new List<int>();
readonly List<int> _dead = new List<int>();
Transform[] _cachedSockets;
float _baseHeight = -1f; // 루트 로컬 공간에서 잰 캐릭터 키(스케일 1 기준) · 1회 측정
/// <summary>지금 캐릭터 키(월드 m). 루트 스케일이 바뀌면 같이 바뀐다.</summary>
public float CharacterHeight
{
get
{
if (_baseHeight <= 0f) _baseHeight = MeasureBodyLocalHeight();
return _baseHeight * Mathf.Abs(transform.lossyScale.y);
}
}
/// <summary>
/// 몸 높이를 루트 스케일 1 기준으로 환산해 1회만 잰다. **소켓 아래(= 무기)는 제외**한다 —
/// 무기가 먼저 붙은 프레임에 키를 재면 무기 길이만큼 키가 부풀어 배수가 틀어진다.
///
/// 🔴 캐릭터는 `Renderer.localBounds` 를 쓰면 안 된다 — 스킨드 메시의 로컬 바운즈는
/// **루트 본 공간** 기준이라 `r.transform` 행렬로 환산하면 틀린다(LH_M05 실측 0.4635 m ≠ 1.1912 m).
/// `Renderer.bounds`(월드)는 스킨드라도 정적 바운즈를 루트 본으로 옮긴 것이라 **크기가 포즈에 안 변한다**
/// → 월드 AABB 높이를 루트 lossyScale 로 나눠 저장하면 로비(1.0)·인게임(0.7) 어디서도 같은 값이 나온다.
/// </summary>
float MeasureBodyLocalHeight()
{
var socks = ResolveSockets();
var rends = GetComponentsInChildren<Renderer>(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;
}
/// <summary>마지막 스캔에서 보정한 무기 수(프로브용).</summary>
public int LastApplied { get; private set; }
void OnEnable() { _applied.Clear(); _baseHeight = -1f; }
void LateUpdate() { Scan(false); }
/// <summary>소켓을 훑어 새로 생긴 무기에만 보정을 1회 적용한다. 보정한 개수를 돌려준다.</summary>
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;
}
/// <summary>무기 1개를 보정한다. 적용한 배수(1 = 무보정)를 돌려준다.</summary>
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;
}
/// <summary>무기의 지금 월드 최대변(m · 포즈 무관 = 로컬 AABB × lossyScale).</summary>
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<PCActor>();
if (actor != null && actor.tfs_weapon != null && actor.tfs_weapon.Length > 0) _cachedSockets = actor.tfs_weapon;
return _cachedSockets;
}
/// <summary>
/// 무기(<paramref name="root"/>) 로컬 공간에서 잰 메시 AABB 크기 = **방향 맞춘 실치수**(스케일 1 기준 · 포즈 무관).
/// 월드 AABB 는 손이 기울면 값이 변해 프레임마다 달라지므로 쓰지 않는다.
/// 트레일·파티클·라인 렌더러는 제외한다(이펙트가 길이를 부풀리면 무기가 과도하게 줄어든다).
/// 무기는 전부 `MeshRenderer` 라 `localBounds`(= `mesh.bounds`) × `r.transform` 행렬이 정확하다(실측).
/// </summary>
public static Vector3 MeasureLocalSize(Transform root)
{
var rends = root.GetComponentsInChildren<Renderer>(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;
}
}
}