262 lines
14 KiB
C#
262 lines
14 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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>();
|
||
// 팔레트(WL-816b)를 이미 끼운 무기 인스턴스 ID. 크기 보정과 **따로** 기억한다(스위치가 독립이라).
|
||
readonly HashSet<int> _paletted = new HashSet<int>();
|
||
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(); _paletted.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 %).
|
||
// 단 **팔레트(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;
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
}
|
||
}
|