diff --git a/Client/Assets/Script/Tests/EditMode/ActorBehaviorPresentationRouterTests.cs b/Client/Assets/Script/Tests/EditMode/ActorBehaviorPresentationRouterTests.cs new file mode 100644 index 00000000..f9e72294 --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/ActorBehaviorPresentationRouterTests.cs @@ -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 Animations = new List(); + public readonly List Effects = new List(); + public readonly List StoppedActors = new List(); + + 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; + } + } +} diff --git a/Client/Assets/Script/Tests/EditMode/ActorBehaviorPresentationRouterTests.cs.meta b/Client/Assets/Script/Tests/EditMode/ActorBehaviorPresentationRouterTests.cs.meta new file mode 100644 index 00000000..24026b74 --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/ActorBehaviorPresentationRouterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 28bdddb483244b36967eea08c1fe7f58 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/xmain/ActorBehavior.meta b/Client/Assets/Script/xmain/ActorBehavior.meta new file mode 100644 index 00000000..8588bd30 --- /dev/null +++ b/Client/Assets/Script/xmain/ActorBehavior.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e5be8c7f074c4db3a92a03772f9844a3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentation.cs b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentation.cs new file mode 100644 index 00000000..e7a576ab --- /dev/null +++ b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentation.cs @@ -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 actors = new Dictionary(); + private readonly Dictionary actorVersions = new Dictionary(); + + 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(), 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 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(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 SpawnedEffects = new List(); + public int Version; + + public ActorPresentationTarget(Animator animator, Transform actorTransform, Transform effectRoot, int version) + { + Animator = animator; + ActorTransform = actorTransform; + EffectRoot = effectRoot; + Version = version; + } + } + } +} diff --git a/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentation.cs.meta b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentation.cs.meta new file mode 100644 index 00000000..816c2811 --- /dev/null +++ b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 38bded11880342a5a5bc0f3b7d95fab5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentationRouter.cs b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentationRouter.cs new file mode 100644 index 00000000..51e5c8b9 --- /dev/null +++ b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentationRouter.cs @@ -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 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; + } + } +} diff --git a/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentationRouter.cs.meta b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentationRouter.cs.meta new file mode 100644 index 00000000..65434a51 --- /dev/null +++ b/Client/Assets/Script/xmain/ActorBehavior/ActorBehaviorPresentationRouter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2bae7bcb6ca34e47b506cba5660d5b62 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: