Merge pull request 'feature/CheckSkill_Cuong' (#454) from feature/CheckSkill_Cuong into develop
Reviewed-on: https://git.pthub.vn/Unity/perfect-world-unity/pulls/454
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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协程中调用)。
|
||||
/// </summary>
|
||||
public void CacheTopBone()
|
||||
{
|
||||
var smrs = GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||
var seen = new HashSet<Transform>();
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the world position of the cached top bone (set by <see cref="CacheTopBone"/>).
|
||||
/// Returns false if no bone has been cached yet.
|
||||
/// 返回缓存的最高骨骼世界坐标。若尚未缓存则返回false。
|
||||
/// </summary>
|
||||
public bool TryGetTopBoneWorld(out Vector3 worldPos)
|
||||
{
|
||||
if (_cachedTopBone == null)
|
||||
{
|
||||
worldPos = default;
|
||||
return false;
|
||||
}
|
||||
worldPos = _cachedTopBone.position;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve world anchor from merged bounds of all SkinnedMeshRenderers.
|
||||
/// 从所有SkinnedMeshRenderer合并后的包围盒解析世界锚点。
|
||||
|
||||
@@ -37,6 +37,10 @@ namespace PerfectWorld.Scripts
|
||||
public const uint MATTER_MONEY = 3;
|
||||
public const uint MATTER_TYPEMASK = 0xff;
|
||||
|
||||
private const float MatterNameExtraWorldYOffset = 0.05f;
|
||||
private const float MatterNameFallbackLocalY = 0.6f;
|
||||
private const string ItemNameTextChildName = "ItemNameText";
|
||||
|
||||
// Constructor / Constructor
|
||||
public CECMatter()
|
||||
{
|
||||
@@ -179,11 +183,24 @@ namespace PerfectWorld.Scripts
|
||||
var collider = matterObject.AddComponent<BoxCollider>();
|
||||
//this is a workaround to fix the collider size issue when load prefab go wrong at some point
|
||||
//TODO: remove this workaround after the prefab load issue is fixed
|
||||
Vector3 size = matterObject.GetComponentInChildren<Renderer>().bounds.size;
|
||||
if (size.x < 0.5f) size.x = 0.5f;
|
||||
if (size.y < 0.5f) size.y = 0.5f;
|
||||
if (size.z < 0.5f) size.z = 0.5f;
|
||||
collider.size = size;
|
||||
if (TryGetCombinedRendererBounds(matterObject.transform, null, out var combinedBounds))
|
||||
{
|
||||
Vector3 size = combinedBounds.size;
|
||||
if (size.x < 0.5f) size.x = 0.5f;
|
||||
if (size.y < 0.5f) size.y = 0.5f;
|
||||
if (size.z < 0.5f) size.z = 0.5f;
|
||||
collider.size = size;
|
||||
collider.center = matterObject.transform.InverseTransformPoint(combinedBounds.center);
|
||||
}
|
||||
else
|
||||
{
|
||||
var firstRenderer = matterObject.GetComponentInChildren<Renderer>();
|
||||
Vector3 size = firstRenderer != null ? firstRenderer.bounds.size : Vector3.one;
|
||||
if (size.x < 0.5f) size.x = 0.5f;
|
||||
if (size.y < 0.5f) size.y = 0.5f;
|
||||
if (size.z < 0.5f) size.z = 0.5f;
|
||||
collider.size = size;
|
||||
}
|
||||
}
|
||||
// Create text object to display item name above the cube
|
||||
CreateItemNameText(matterObject, Info.tid);
|
||||
@@ -244,18 +261,109 @@ namespace PerfectWorld.Scripts
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge world-space bounds of all child Renderers (MeshRenderer + SkinnedMeshRenderer).
|
||||
/// Reads sharedMesh.bounds (mesh local space) and manually converts to world space —
|
||||
/// same approach as PlayerVisual/NPCVisual — to avoid stale renderer.bounds after SetActive.
|
||||
/// 合并所有子 Renderer 的世界包围盒(MeshRenderer + SkinnedMeshRenderer)。
|
||||
/// 直接读 sharedMesh.bounds(网格本地空间)再手动转为世界坐标,避免 SetActive 后同帧 renderer.bounds 未刷新的问题。
|
||||
/// </summary>
|
||||
private static bool TryGetCombinedRendererBounds(Transform matterRoot, Transform excludeSubtree, out Bounds combinedBounds)
|
||||
{
|
||||
combinedBounds = default;
|
||||
if (matterRoot == null)
|
||||
return false;
|
||||
|
||||
var renderers = matterRoot.GetComponentsInChildren<Renderer>(true);
|
||||
bool hasAny = false;
|
||||
for (int i = 0; i < renderers.Length; i++)
|
||||
{
|
||||
var renderer = renderers[i];
|
||||
if (renderer == null)
|
||||
continue;
|
||||
if (excludeSubtree != null && renderer.transform.IsChildOf(excludeSubtree))
|
||||
continue;
|
||||
|
||||
Mesh mesh = null;
|
||||
if (renderer is SkinnedMeshRenderer smr)
|
||||
{
|
||||
mesh = smr.sharedMesh;
|
||||
}
|
||||
else if (renderer is MeshRenderer)
|
||||
{
|
||||
var mf = renderer.GetComponent<MeshFilter>();
|
||||
if (mf != null)
|
||||
mesh = mf.sharedMesh;
|
||||
}
|
||||
|
||||
if (mesh == null)
|
||||
continue;
|
||||
|
||||
// Manually build world-space bounds from mesh-local bounds + transform,
|
||||
// identical to PlayerVisual/NPCVisual — reliable even right after SetActive(true).
|
||||
// 与 PlayerVisual/NPCVisual 相同:从网格本地包围盒手动计算世界包围盒,SetActive 后同帧可靠。
|
||||
var meshBounds = mesh.bounds;
|
||||
var scale = renderer.transform.lossyScale;
|
||||
var worldCenter = renderer.transform.TransformPoint(meshBounds.center);
|
||||
var worldSize = new Vector3(
|
||||
Mathf.Abs(meshBounds.size.x * scale.x),
|
||||
Mathf.Abs(meshBounds.size.y * scale.y),
|
||||
Mathf.Abs(meshBounds.size.z * scale.z));
|
||||
var currentBounds = new Bounds(worldCenter, worldSize);
|
||||
|
||||
Debug.Log($"[Cuong] [CECMatter] renderer={renderer.name} meshLocalCenter={meshBounds.center} meshLocalSize={meshBounds.size} worldCenter={worldCenter} worldSize={worldSize} worldMaxY={currentBounds.max.y}");
|
||||
|
||||
if (!hasAny)
|
||||
{
|
||||
combinedBounds = currentBounds;
|
||||
hasAny = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
combinedBounds.Encapsulate(currentBounds);
|
||||
}
|
||||
}
|
||||
|
||||
return hasAny;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Name anchor at top of tallest mesh: combinedBounds.max.y in world, converted to local.
|
||||
/// 名牌锚点位于最高 mesh 顶部:世界坐标 combinedBounds.max.y,再转为本地坐标。
|
||||
/// </summary>
|
||||
private static bool TryGetItemNameAnchorLocal(Transform matterRoot, out Vector3 localAnchor)
|
||||
{
|
||||
if (!TryGetCombinedRendererBounds(matterRoot, null, out var combinedBounds))
|
||||
{
|
||||
localAnchor = new Vector3(0f, MatterNameFallbackLocalY, 0f);
|
||||
return false;
|
||||
}
|
||||
|
||||
var worldAnchor = new Vector3(
|
||||
combinedBounds.center.x,
|
||||
combinedBounds.max.y + MatterNameExtraWorldYOffset,
|
||||
combinedBounds.center.z);
|
||||
localAnchor = matterRoot.InverseTransformPoint(worldAnchor);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void CreateItemNameText(GameObject matterObject, int tid)
|
||||
{
|
||||
if (matterObject == null)
|
||||
return;
|
||||
|
||||
// Avoid duplicating if prefab already contains it (or Init called twice).
|
||||
if (matterObject.transform.Find("ItemNameText") != null)
|
||||
if (matterObject.transform.Find(ItemNameTextChildName) != null)
|
||||
return;
|
||||
|
||||
var textObject = new GameObject("ItemNameText");
|
||||
var textObject = new GameObject(ItemNameTextChildName);
|
||||
textObject.transform.SetParent(matterObject.transform, false);
|
||||
textObject.transform.localPosition = new Vector3(0f, 0.6f, 0f);
|
||||
if (!TryGetItemNameAnchorLocal(matterObject.transform, out var localAnchor))
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[Cuong] [CECMatter] No renderer bounds for '{matterObject.name}'; using fallback Y={MatterNameFallbackLocalY}");
|
||||
}
|
||||
textObject.transform.localPosition = localAnchor;
|
||||
|
||||
var textMesh = textObject.AddComponent<TextMeshPro>();
|
||||
|
||||
|
||||
@@ -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<NPCVisual>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger a scan of all NPC skeleton bones to cache the highest-Y bone.
|
||||
/// Call from <see cref="UINPC"/> once the NPC model and its animation are ready.
|
||||
/// 触发扫描NPC所有骨骼以缓存世界Y最高的骨骼。在NPC模型及动画就绪后由UINPC调用一次。
|
||||
/// </summary>
|
||||
public void RefreshNpcTopBone()
|
||||
{
|
||||
CacheRefs();
|
||||
npcVisual?.CacheTopBone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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))
|
||||
|
||||
@@ -63,6 +63,22 @@ namespace BrewMonster
|
||||
_initialNameplateRoutine = StartCoroutine(CoApplyInitialNameplateDeferred(maxFrames: 120));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)生效。
|
||||
/// </summary>
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_initialNameplateRoutine != null)
|
||||
return;
|
||||
if (_canvasRoot == null || _nameplateAnchor == null)
|
||||
return;
|
||||
if (_nameplateAnchor.TryGetWorldPosition(out var worldPos))
|
||||
ApplyCanvasRootLocalPosition(worldPos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call after NPC model async load (e.g. <see cref="CECNPC.QueueLoadNPCModel"/>) so SMR bounds exist before anchoring.
|
||||
/// 在NPC模型异步加载完成后调用(如 <see cref="CECNPC.QueueLoadNPCModel"/>),以便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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user