From 3e545f026d2624066df203d5343677235fb1a77b Mon Sep 17 00:00:00 2001
From: CuongNV <>
Date: Wed, 20 May 2026 14:01:38 +0700
Subject: [PATCH] fix pos textname for NPC
---
Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs | 53 +++++++++++++++++++
.../UI/GamePlay/NameplateWorldAnchor.cs | 37 +++++++++++++
.../PerfectWorld/Scripts/UI/GamePlay/UINPC.cs | 22 ++++++++
3 files changed, 112 insertions(+)
diff --git a/Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs b/Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs
index 769a82f63a..6a544d802f 100644
--- a/Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs
+++ b/Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs
@@ -151,6 +151,59 @@ public class NPCVisual : MonoBehaviour
}
}
+ // Cached reference to the bone with the highest world-Y position at model-ready time.
+ // 模型就绪时世界Y最高的骨骼缓存引用。
+ private Transform _cachedTopBone;
+
+ ///
+ /// Scan all SkinnedMeshRenderer bones, find the one with the highest world-Y, and cache it.
+ /// Call once after the model and its first animation frame are ready (e.g. from UINPC coroutine).
+ /// 扫描所有SkinnedMeshRenderer骨骼,找到世界Y最高者并缓存。
+ /// 在模型及首帧动画就绪后调用一次(例如从UINPC协程中调用)。
+ ///
+ public void CacheTopBone()
+ {
+ var smrs = GetComponentsInChildren(true);
+ var seen = new HashSet();
+ Transform highest = null;
+ float highestY = float.MinValue;
+ foreach (var smr in smrs)
+ {
+ if (smr == null || smr.bones == null)
+ continue;
+ foreach (var bone in smr.bones)
+ {
+ if (bone == null || !seen.Add(bone))
+ continue;
+ float y = bone.position.y;
+ if (y > highestY)
+ {
+ highestY = y;
+ highest = bone;
+ }
+ }
+ }
+ _cachedTopBone = highest;
+ if (debugNamePlateBounds && _cachedTopBone != null)
+ Debug.Log($"[Cuong] [NPCVisual] CacheTopBone: topBone={_cachedTopBone.name} worldY={highestY:F3}");
+ }
+
+ ///
+ /// Return the world position of the cached top bone (set by ).
+ /// Returns false if no bone has been cached yet.
+ /// 返回缓存的最高骨骼世界坐标。若尚未缓存则返回false。
+ ///
+ public bool TryGetTopBoneWorld(out Vector3 worldPos)
+ {
+ if (_cachedTopBone == null)
+ {
+ worldPos = default;
+ return false;
+ }
+ worldPos = _cachedTopBone.position;
+ return true;
+ }
+
///
/// Resolve world anchor from merged bounds of all SkinnedMeshRenderers.
/// 从所有SkinnedMeshRenderer合并后的包围盒解析世界锚点。
diff --git a/Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs b/Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs
index 8f244469a5..82e1f5b73a 100644
--- a/Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs
+++ b/Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs
@@ -26,6 +26,9 @@ namespace BrewMonster
[SerializeField] private bool applyMaleHeadWorldOffsetForAabbOnly = true;
[SerializeField] private float maleHeadWorldOffset = 0.1f;
+ [Header("NPC — skeleton hook")]
+ [SerializeField] private bool useNpcHeadHook = true;
+
[Header("NPC — SMR / root fallback")]
[SerializeField] private bool preferVisualBoundsFallback = true;
[SerializeField] private float fallbackHeightAboveRoot = 2f;
@@ -55,6 +58,17 @@ namespace BrewMonster
npcVisual = hostNpc.GetComponent();
}
+ ///
+ /// Trigger a scan of all NPC skeleton bones to cache the highest-Y bone.
+ /// Call from once the NPC model and its animation are ready.
+ /// 触发扫描NPC所有骨骼以缓存世界Y最高的骨骼。在NPC模型及动画就绪后由UINPC调用一次。
+ ///
+ public void RefreshNpcTopBone()
+ {
+ CacheRefs();
+ npcVisual?.CacheTopBone();
+ }
+
///
/// Resolve world position for the nameplate canvas root. Call once at init or every frame if the plate should follow the host.
/// 解析名牌Canvas根的世界坐标。仅在初始化时调用一次,或若名牌需跟随宿主则每帧调用。
@@ -119,6 +133,29 @@ namespace BrewMonster
if (hostNpc == null)
return false;
+ // Priority 1: skeleton hook (follows animation every frame — fixes floating/flying NPCs like 小星星).
+ // 优先级1:骨骼挂点(每帧跟随动画,修复小星星等浮空动画中名牌与模型重叠的问题)。
+ if (useNpcHeadHook)
+ {
+ var headHook = hostNpc.GetHook(headHookName);
+ if (headHook != null)
+ {
+ worldPos = headHook.position + Vector3.up * (headHookWorldOffset + extraWorldYOffset);
+ usedSkinnedMeshMergedBounds = true; // treated as "precise anchor" so UINPC coroutine exits early
+ return true;
+ }
+ }
+
+ // Priority 2: cached top bone — bone with highest world-Y found at model-ready time.
+ // Follows animation per-frame via UINPC.LateUpdate without needing a specific bone name.
+ // 优先级2:缓存的最高骨骼——模型就绪时世界Y最高的骨骼,每帧跟随动画,无需特定骨骼名。
+ if (npcVisual != null && npcVisual.TryGetTopBoneWorld(out worldPos))
+ {
+ usedSkinnedMeshMergedBounds = true;
+ worldPos.y += extraWorldYOffset;
+ return true;
+ }
+
if (preferVisualBoundsFallback
&& npcVisual != null
&& npcVisual.TryGetNamePlateAnchorWorld(out worldPos))
diff --git a/Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs b/Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs
index eb6b7165b7..dbadd51e4e 100644
--- a/Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs
+++ b/Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs
@@ -63,6 +63,22 @@ namespace BrewMonster
_initialNameplateRoutine = StartCoroutine(CoApplyInitialNameplateDeferred(maxFrames: 120));
}
+ ///
+ /// Update nameplate position every frame so it follows skeleton animations (e.g. floating/flying NPCs or monsters).
+ /// Only active after the initial anchor coroutine has resolved (i.e. _initialNameplateRoutine == null).
+ /// 每帧更新名牌位置,使其跟随骨骼动画(如浮空/飞行的NPC或怪物,例如小星星)。
+ /// 仅在初始锚点协程完成后(_initialNameplateRoutine == null)生效。
+ ///
+ private void LateUpdate()
+ {
+ if (_initialNameplateRoutine != null)
+ return;
+ if (_canvasRoot == null || _nameplateAnchor == null)
+ return;
+ if (_nameplateAnchor.TryGetWorldPosition(out var worldPos))
+ ApplyCanvasRootLocalPosition(worldPos);
+ }
+
///
/// Call after NPC model async load (e.g. ) so SMR bounds exist before anchoring.
/// 在NPC模型异步加载完成后调用(如 ),以便SMR包围盒已存在再定位名牌。
@@ -107,12 +123,17 @@ namespace BrewMonster
&& fromSmr)
{
ApplyCanvasRootLocalPosition(worldPos);
+ // Model + SMR ready: scan bones and cache the highest one for per-frame LateUpdate tracking.
+ // 模型和SMR就绪:扫描骨骼并缓存最高骨骼,供LateUpdate每帧跟踪。
+ _nameplateAnchor.RefreshNpcTopBone();
_initialNameplateRoutine = null;
yield break;
}
yield return null;
}
+ // Timeout: set position from whatever is available (hook / SMR / root fallback), then still cache top bone.
+ // 超时:使用当前可用方式设置位置(挂点/SMR/根节点回退),仍然缓存最高骨骼。
if (_nameplateAnchor.TryGetWorldPosition(out var finalPos, out _, logNpcRootFallback: true))
{
ApplyCanvasRootLocalPosition(finalPos);
@@ -125,6 +146,7 @@ namespace BrewMonster
"NPC with SMR bounds: worldPos = (combinedBounds.center.x, combinedBounds.max.y, combinedBounds.center.z) + Vector3.up * extraWorldYOffset. " +
"NPC fallback: worldPos = hostNpc.transform.position + Vector3.up * (fallbackHeightAboveRoot + extraWorldYOffset).");
}
+ _nameplateAnchor.RefreshNpcTopBone();
_initialNameplateRoutine = null;
}