feat: add actor behavior client presentation

This commit is contained in:
ud18010
2026-07-30 16:27:27 +08:00
parent 8ef870ff6c
commit 03cee1036d
7 changed files with 518 additions and 0 deletions
@@ -0,0 +1,129 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using XGame.ActorBehavior;
using XWorld.Framework.ActorBehavior;
public sealed class ActorBehaviorPresentationRouterTests
{
[Test]
public void Dispatch_TimelineAnimation_PlaysAnimatorState()
{
var sink = new RecordingSink();
var router = new ActorBehaviorPresentationRouter();
router.Dispatch(new[]
{
new BehaviorEvent
{
Kind = BehaviorEventKind.TimelineAnimation,
ActorId = 7,
Animation = "140_skill01a_qt"
}
}, sink);
Assert.That(sink.Animations, Is.EqualTo(new[] { "7:140_skill01a_qt" }));
}
[Test]
public void Dispatch_TimelineEffect_PlaysPrefabAtEventPosition()
{
var sink = new RecordingSink();
var router = new ActorBehaviorPresentationRouter();
router.Dispatch(new[]
{
new BehaviorEvent
{
Kind = BehaviorEventKind.TimelineEffect,
ActorId = 7,
Prefab = "Assets/Game/Art/Effect/Prefab/skill/9110004_zhujue_quantao_skill01a.prefab",
Position = new BehaviorVector3(1f, 2f, 3f)
}
}, sink);
Assert.That(sink.Effects.Select(e => e.ActorId), Is.EqualTo(new[] { 7 }));
Assert.That(sink.Effects.Select(e => e.PrefabPath), Is.EqualTo(new[] { "Assets/Game/Art/Effect/Prefab/skill/9110004_zhujue_quantao_skill01a.prefab" }));
Assert.That(sink.Effects[0].Position.X, Is.EqualTo(1f));
Assert.That(sink.Effects[0].Position.Y, Is.EqualTo(2f));
Assert.That(sink.Effects[0].Position.Z, Is.EqualTo(3f));
Assert.That(sink.Effects[0].HasExplicitPosition, Is.True);
}
[Test]
public void Dispatch_ProjectileTimelineEffect_TreatsZeroPositionAsExplicitWorldPosition()
{
var sink = new RecordingSink();
var router = new ActorBehaviorPresentationRouter();
router.Dispatch(new[]
{
new BehaviorEvent
{
Kind = BehaviorEventKind.TimelineEffect,
ActorId = 7,
FlyObjectId = "arrow01",
Prefab = "Assets/Game/Art/Effect/Prefab/skill/hit.prefab",
Position = BehaviorVector3.Zero
}
}, sink);
Assert.That(sink.Effects[0].HasExplicitPosition, Is.True);
}
[Test]
public void Dispatch_BehaviorStopped_StopsActorPresentation()
{
var sink = new RecordingSink();
var router = new ActorBehaviorPresentationRouter();
router.Dispatch(new[]
{
new BehaviorEvent
{
Kind = BehaviorEventKind.BehaviorStopped,
ActorId = 7
}
}, sink);
Assert.That(sink.StoppedActors, Is.EqualTo(new[] { 7 }));
}
private sealed class RecordingSink : IActorBehaviorPresentationSink
{
public readonly List<string> Animations = new List<string>();
public readonly List<EffectRecord> Effects = new List<EffectRecord>();
public readonly List<int> StoppedActors = new List<int>();
public void PlayAnimation(int actorId, string stateName)
{
Animations.Add(actorId + ":" + stateName);
}
public void PlayEffect(int actorId, string prefabPath, BehaviorVector3 position, bool hasExplicitPosition)
{
Effects.Add(new EffectRecord(actorId, prefabPath, position, hasExplicitPosition));
}
public void StopActorPresentation(int actorId)
{
StoppedActors.Add(actorId);
}
}
private readonly struct EffectRecord
{
public readonly int ActorId;
public readonly string PrefabPath;
public readonly BehaviorVector3 Position;
public readonly bool HasExplicitPosition;
public EffectRecord(int actorId, string prefabPath, BehaviorVector3 position, bool hasExplicitPosition)
{
ActorId = actorId;
PrefabPath = prefabPath;
Position = position;
HasExplicitPosition = hasExplicitPosition;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 28bdddb483244b36967eea08c1fe7f58
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e5be8c7f074c4db3a92a03772f9844a3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,284 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using XWorld.Framework.ActorBehavior;
using UObject = UnityEngine.Object;
namespace XGame.ActorBehavior
{
public sealed class ActorBehaviorPresentation : MonoBehaviour, IActorBehaviorPresentationSink
{
[SerializeField] private float crossFadeSeconds = 0.1f;
[SerializeField] private bool validateAnimatorState = true;
[SerializeField] private bool attachActorEffects = true;
[SerializeField] private float defaultEffectLifetime;
private readonly ActorBehaviorPresentationRouter router = new ActorBehaviorPresentationRouter();
private readonly Dictionary<int, ActorPresentationTarget> actors = new Dictionary<int, ActorPresentationTarget>();
private readonly Dictionary<int, int> actorVersions = new Dictionary<int, int>();
public float CrossFadeSeconds
{
get => crossFadeSeconds;
set => crossFadeSeconds = Mathf.Max(0f, value);
}
public bool ValidateAnimatorState
{
get => validateAnimatorState;
set => validateAnimatorState = value;
}
public bool AttachActorEffects
{
get => attachActorEffects;
set => attachActorEffects = value;
}
public float DefaultEffectLifetime
{
get => defaultEffectLifetime;
set => defaultEffectLifetime = Mathf.Max(0f, value);
}
public void RegisterActor(int actorId, GameObject actorObject)
{
if (actorObject == null)
{
UnregisterActor(actorId, true);
return;
}
RegisterActor(actorId, actorObject.GetComponentInChildren<Animator>(), actorObject.transform, null);
}
public void RegisterActor(int actorId, Animator animator, Transform effectRoot = null)
{
Transform actorTransform = animator != null ? animator.transform : effectRoot;
RegisterActor(actorId, animator, actorTransform, effectRoot);
}
public void RegisterActor(int actorId, Animator animator, Transform actorTransform, Transform effectRoot)
{
if (actorId <= 0)
{
Debug.LogWarning("[ActorBehaviorPresentation] ignore actor registration with invalid actorId=" + actorId);
return;
}
StopActorPresentation(actorId);
actors[actorId] = new ActorPresentationTarget(animator, actorTransform, effectRoot, NextActorVersion(actorId));
}
public void UnregisterActor(int actorId, bool destroySpawnedEffects = true)
{
if (destroySpawnedEffects)
{
StopActorPresentation(actorId);
}
actors.Remove(actorId);
}
public void PlayEvents(IEnumerable<BehaviorEvent> events)
{
router.Dispatch(events, this);
}
public void PlayAnimation(int actorId, string stateName)
{
if (string.IsNullOrEmpty(stateName))
{
return;
}
if (!actors.TryGetValue(actorId, out ActorPresentationTarget target) || target.Animator == null)
{
Debug.LogWarning("[ActorBehaviorPresentation] missing animator for actorId=" + actorId + ", state=" + stateName);
return;
}
int stateHash = Animator.StringToHash(stateName);
if (validateAnimatorState && target.Animator.runtimeAnimatorController != null && !target.Animator.HasState(0, stateHash))
{
Debug.LogWarning("[ActorBehaviorPresentation] animator state not found: actorId=" + actorId + ", state=" + stateName);
return;
}
target.Animator.CrossFade(stateHash, crossFadeSeconds, 0, 0f);
}
public void PlayEffect(int actorId, string prefabPath, BehaviorVector3 position)
{
PlayEffect(actorId, prefabPath, position, HasExplicitPosition(position));
}
public void PlayEffect(int actorId, string prefabPath, BehaviorVector3 position, bool hasExplicitPosition)
{
if (string.IsNullOrEmpty(prefabPath))
{
return;
}
StartCoroutine(LoadAndPlayEffect(actorId, prefabPath, position, hasExplicitPosition));
}
public void StopActorPresentation(int actorId)
{
if (!actors.TryGetValue(actorId, out ActorPresentationTarget target))
{
return;
}
target.Version = NextActorVersion(actorId);
for (int i = target.SpawnedEffects.Count - 1; i >= 0; i--)
{
DestroyUnityObject(target.SpawnedEffects[i]);
}
target.SpawnedEffects.Clear();
}
private void OnDestroy()
{
StopAllCoroutines();
var actorIds = new List<int>(actors.Keys);
for (int i = 0; i < actorIds.Count; i++)
{
StopActorPresentation(actorIds[i]);
}
actors.Clear();
}
private IEnumerator LoadAndPlayEffect(int actorId, string prefabPath, BehaviorVector3 position, bool hasExplicitPosition)
{
ActorPresentationTarget startTarget = null;
bool actorBoundEffect = !hasExplicitPosition && actors.TryGetValue(actorId, out startTarget);
int startVersion = actorBoundEffect && startTarget != null ? startTarget.Version : 0;
UObject loaded = null;
yield return XResLoader.coLoadRes(prefabPath, typeof(GameObject), obj => loaded = obj);
GameObject prefab = loaded as GameObject;
if (prefab == null)
{
Debug.LogWarning("[ActorBehaviorPresentation] effect prefab load failed: " + prefabPath);
yield break;
}
if (actorBoundEffect && (!actors.TryGetValue(actorId, out ActorPresentationTarget currentTarget) || currentTarget.Version != startVersion))
{
yield break;
}
GameObject instance = InstantiateEffect(actorId, prefab, position, hasExplicitPosition);
if (instance == null)
{
yield break;
}
if (actors.TryGetValue(actorId, out ActorPresentationTarget target))
{
target.SpawnedEffects.Add(instance);
if (defaultEffectLifetime > 0f)
{
StartCoroutine(DestroyEffectAfterLifetime(actorId, target.Version, instance, defaultEffectLifetime));
}
}
else if (defaultEffectLifetime > 0f)
{
Destroy(instance, defaultEffectLifetime);
}
}
private IEnumerator DestroyEffectAfterLifetime(int actorId, int version, GameObject instance, float lifetime)
{
yield return new WaitForSeconds(lifetime);
if (actors.TryGetValue(actorId, out ActorPresentationTarget target) && target.Version == version)
{
target.SpawnedEffects.Remove(instance);
}
DestroyUnityObject(instance);
}
private GameObject InstantiateEffect(int actorId, GameObject prefab, BehaviorVector3 position, bool hasExplicitPosition)
{
actors.TryGetValue(actorId, out ActorPresentationTarget target);
if (target != null && !hasExplicitPosition && attachActorEffects)
{
Transform parent = target.EffectRoot != null ? target.EffectRoot : target.ActorTransform;
GameObject instance = Instantiate(prefab, parent);
instance.transform.localPosition = Vector3.zero;
instance.transform.localRotation = Quaternion.identity;
return instance;
}
Vector3 worldPosition = hasExplicitPosition ? ToUnity(position) : GetActorPosition(target);
Quaternion worldRotation = target != null && target.ActorTransform != null
? target.ActorTransform.rotation
: Quaternion.identity;
return Instantiate(prefab, worldPosition, worldRotation);
}
private int NextActorVersion(int actorId)
{
int version;
actorVersions.TryGetValue(actorId, out version);
version++;
actorVersions[actorId] = version;
return version;
}
private static Vector3 GetActorPosition(ActorPresentationTarget target)
{
return target != null && target.ActorTransform != null
? target.ActorTransform.position
: Vector3.zero;
}
private static bool HasExplicitPosition(BehaviorVector3 position)
{
return Mathf.Abs(position.X) > 0.0001f
|| Mathf.Abs(position.Y) > 0.0001f
|| Mathf.Abs(position.Z) > 0.0001f;
}
private static Vector3 ToUnity(BehaviorVector3 position)
{
return new Vector3(position.X, position.Y, position.Z);
}
private static void DestroyUnityObject(UObject obj)
{
if (obj == null)
{
return;
}
if (Application.isPlaying)
{
Destroy(obj);
}
else
{
DestroyImmediate(obj);
}
}
private sealed class ActorPresentationTarget
{
public readonly Animator Animator;
public readonly Transform ActorTransform;
public readonly Transform EffectRoot;
public readonly List<GameObject> SpawnedEffects = new List<GameObject>();
public int Version;
public ActorPresentationTarget(Animator animator, Transform actorTransform, Transform effectRoot, int version)
{
Animator = animator;
ActorTransform = actorTransform;
EffectRoot = effectRoot;
Version = version;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 38bded11880342a5a5bc0f3b7d95fab5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using XWorld.Framework.ActorBehavior;
namespace XGame.ActorBehavior
{
public interface IActorBehaviorPresentationSink
{
void PlayAnimation(int actorId, string stateName);
void PlayEffect(int actorId, string prefabPath, BehaviorVector3 position, bool hasExplicitPosition);
void StopActorPresentation(int actorId);
}
public sealed class ActorBehaviorPresentationRouter
{
public void Dispatch(IEnumerable<BehaviorEvent> events, IActorBehaviorPresentationSink sink)
{
if (sink == null)
{
throw new ArgumentNullException(nameof(sink));
}
if (events == null)
{
return;
}
foreach (BehaviorEvent evt in events)
{
if (evt == null)
{
continue;
}
switch (evt.Kind)
{
case BehaviorEventKind.TimelineAnimation:
if (!string.IsNullOrEmpty(evt.Animation))
{
sink.PlayAnimation(evt.ActorId, evt.Animation);
}
break;
case BehaviorEventKind.TimelineEffect:
if (!string.IsNullOrEmpty(evt.Prefab))
{
sink.PlayEffect(evt.ActorId, evt.Prefab, evt.Position, HasExplicitPosition(evt));
}
break;
case BehaviorEventKind.BehaviorStopped:
sink.StopActorPresentation(evt.ActorId);
break;
}
}
}
private static bool HasExplicitPosition(BehaviorEvent evt)
{
return !string.IsNullOrEmpty(evt.FlyObjectId)
|| evt.Position.X != 0f
|| evt.Position.Y != 0f
|| evt.Position.Z != 0f;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2bae7bcb6ca34e47b506cba5660d5b62
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: