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; }