Files
test/Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs
T
2026-05-27 17:47:58 +07:00

317 lines
11 KiB
C#

using Animancer;
using BrewMonster;
using BrewMonster.Scripts.ECModel;
using System;
using System.Collections.Generic;
using UnityEngine;
using static CECModel;
public class NPCVisual : MonoBehaviour
{
[SerializeField] NamedAnimancerComponent namedAnimancer;
protected CECNPC.INFO m_NPCInfo;
[SerializeField] private Queue<AnimationQueue> _animationQueue = new Queue<AnimationQueue>();
[SerializeField] private AnimancerState _currentState;
private string _previousAnimationName;
private string _currentAnimationName;
private bool debugNamePlateBounds = false;
private const float FadeTime = 100f;
private const FadeMode FadeMode = Animancer.FadeMode.FixedDuration;
public CECNPC.INFO GetNPCINFO => m_NPCInfo;
public bool TryPlayAction(string animationName, CECAttackEvent cECAttackEvent, bool isHit = false, bool bRestart = true, bool bLoop = false)
{
if (namedAnimancer == null)
return false;
if (namedAnimancer.IsPlaying(animationName))
{
if (!bRestart)
return true;
if (!bLoop)
return false;
}
if (!InternalPlayAnimation(animationName))
return false;
_previousAnimationName = animationName;
if (isHit)
_currentState.Events.OnEnd = () => SetHitOnEnd(cECAttackEvent);
else
ApplyAnimationEndCallbacks(animationName, bLoop);
return true;
}
bool InternalPlayAnimation(string animationName, float duration = FadeTime, FadeMode fadeMode = FadeMode, float playSpeed = 1.0f)
{
if (namedAnimancer == null)
return false;
if (!namedAnimancer.States.TryGet(animationName, out _))
return false;
_currentState = namedAnimancer.TryPlay(animationName, duration / 1000f, fadeMode);
if (_currentState == null)
return false;
_currentState.Time = 0;
_currentState.Speed = playSpeed > 0f ? playSpeed : 1.0f;
_currentAnimationName = animationName;
return true;
}
void ApplyAnimationEndCallbacks(string animationName, bool isLoop)
{
if (_currentState == null)
return;
_currentState.Events.OnEnd = () =>
{
if (isLoop)
EnqueueAnimationForLooping(animationName);
};
}
bool EnqueueAnimationForLooping(string animationName)
{
if (namedAnimancer == null || _animationQueue.Count > 0)
return false;
_animationQueue.Enqueue(new AnimationQueue
{
AnimationName = animationName,
IsForceStopPrevious = false,
IsLoop = true,
});
return true;
}
private void SetHitOnEnd(CECAttackEvent cECAttackEvent)
{
cECAttackEvent.m_bSignaled = true;
}
public void InitNPCEventDoneHandler(CECNPC.INFO iNFO)
{
m_NPCInfo = iNFO;
namedAnimancer = GetComponentInChildren<NamedAnimancerComponent>();
if (namedAnimancer == null)
{
BMLogger.LogWarning("animancer == null");
return;
}
namedAnimancer.Animator.cullingMode = AnimatorCullingMode.CullUpdateTransforms;
EventBus.SubscribeChannel<QueueNPCActionEvent>(m_NPCInfo.nid, OnQueueAction);
EventBus.SubscribeChannel<ClearComActFlagEvent>(m_NPCInfo.nid, OnClearComActFlagEvent);
}
private void OnClearComActFlagEvent(ClearComActFlagEvent @event)
{
_animationQueue.Clear();
}
private void Update()
{
PlayNext();
}
private void OnQueueAction(QueueNPCActionEvent @event)
{
if (!EnqueueAnimation(@event))
{
BMLogger.LogError("HoangDev : EnqueueAnimation Failed");
}
}
public bool EnqueueAnimation(QueueNPCActionEvent @event)
{
if (namedAnimancer == null) return false;
_animationQueue.Enqueue(new AnimationQueue
{
AnimationName = @event.AnimationName,
IsForceStopPrevious = false,
IsLoop = false,
});
return true;
}
private void PlayNext()
{
if (_animationQueue.Count == 0)
return;
if (_animationQueue.Peek().IsForceStopPrevious)
{
_currentState?.Stop();
_currentState = null;
}
if (_currentState != null && _currentState.NormalizedTime < 1f)
return;
var animationQueue = _animationQueue.Dequeue();
_previousAnimationName = animationQueue.AnimationName;
float duration = animationQueue.ITransTime > 0 ? animationQueue.ITransTime : FadeTime;
if (!InternalPlayAnimation(animationQueue.AnimationName, duration, FadeMode, animationQueue.PlaySpeed))
return;
ApplyAnimationEndCallbacks(animationQueue.AnimationName, animationQueue.IsLoop);
}
private void OnDestroy()
{
EventBus.UnsubscribeAllInChannel(m_NPCInfo.nid);
}
public bool IsAnimationExist(string animationName)
{
if (namedAnimancer == null) return false;
return namedAnimancer.States.TryGet(animationName, out var existingState) ? true : false;
}
public bool IsPlayAnimation()
{
if (namedAnimancer == null) return false;
return namedAnimancer.IsPlaying();
}
public bool IsPlayAnimation(string animationName)
{
if (namedAnimancer == null) return false;
return namedAnimancer.IsPlaying(animationName);
}
/// <summary>
/// Refresh the namedAnimancer reference when the model changes (e.g., shape change)
/// 当模型更改时(例如形状更改)刷新namedAnimancer引用
/// </summary>
/// <param name="modelRoot">The root GameObject of the model to search for NamedAnimancerComponent / 要搜索NamedAnimancerComponent的模型根GameObject</param>
public void RefreshNamedAnimancer(GameObject modelRoot = null)
{
_currentState = null;
_previousAnimationName = null;
_currentAnimationName = null;
_animationQueue.Clear();
if (modelRoot != null)
namedAnimancer = modelRoot.GetComponentInChildren<NamedAnimancerComponent>(true);
else
namedAnimancer = GetComponentInChildren<NamedAnimancerComponent>(true);
if (namedAnimancer == null)
BMLogger.LogWarning($"NPCVisual: RefreshNamedAnimancer - namedAnimancer == null (modelRoot: {modelRoot?.name ?? "null"})");
}
// 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合并后的包围盒解析世界锚点。
/// </summary>
public bool TryGetNamePlateAnchorWorld(out Vector3 worldPos)
{
worldPos = default;
var skinnedMeshRenderers = GetComponentsInChildren<SkinnedMeshRenderer>(true);
if (skinnedMeshRenderers == null || skinnedMeshRenderers.Length == 0)
{
return false;
}
Bounds combinedBounds = default;
bool hasAnySkinnedMesh = false;
for (int i = 0; i < skinnedMeshRenderers.Length; i++)
{
var renderer = skinnedMeshRenderers[i];
if (renderer == null || renderer.sharedMesh == null)
{
continue;
}
var meshBounds = renderer.sharedMesh.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);
if (debugNamePlateBounds)
{
BMLogger.Log($"[Cuong] [NPCVisual] smr={renderer.name} localCenter={meshBounds.center} localSize={meshBounds.size} worldCenter={worldCenter} worldSize={worldSize} worldMaxY={currentBounds.max.y}");
}
if (!hasAnySkinnedMesh)
{
combinedBounds = currentBounds;
hasAnySkinnedMesh = true;
}
else
{
combinedBounds.Encapsulate(currentBounds);
}
}
if (!hasAnySkinnedMesh)
{
if (debugNamePlateBounds)
{
Debug.LogWarning("[Cuong] [NPCVisual] TryGetNamePlateAnchorWorld: no valid SkinnedMeshRenderer/sharedMesh.");
}
return false;
}
worldPos = new Vector3(combinedBounds.center.x, combinedBounds.max.y, combinedBounds.center.z);
if (debugNamePlateBounds)
{
Debug.Log($"[Cuong] [NPCVisual] combinedCenter={combinedBounds.center} combinedSize={combinedBounds.size} anchorWorld={worldPos}");
}
return true;
}
}