From 1517d46fc030035de080efad84e8da3092e22271 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 30 Jul 2026 13:12:27 +0800 Subject: [PATCH] feat: add actor behavior runtime --- .../Shared/ActorBehavior/BehaviorWorld.cs | 301 ++++++++++++++++++ .../ActorBehavior/BehaviorWorldTests.cs | 86 +++++ 2 files changed, 387 insertions(+) create mode 100644 Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs create mode 100644 Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs new file mode 100644 index 00000000..641da895 --- /dev/null +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; + +namespace XWorld.Framework.ActorBehavior +{ + public sealed class BehaviorWorld + { + private readonly BehaviorWorldOptions _options; + private readonly Dictionary _catalogs = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _actors = new Dictionary(); + private readonly Dictionary _activeBehaviors = new Dictionary(); + private readonly List _events = new List(); + + public BehaviorWorld(BehaviorWorldOptions options) + { + _options = options; + } + + public int ActiveProjectileCount => 0; + + public void RegisterCatalog(BehaviorConfigCatalog catalog) + { + if (catalog == null) throw new ArgumentNullException(nameof(catalog)); + _catalogs[catalog.Type] = catalog; + } + + public void AddActor(BehaviorActorState actor) + { + if (actor == null) throw new ArgumentNullException(nameof(actor)); + _actors[actor.ActorId] = actor; + } + + public bool SwitchActorCatalog(int actorId, string catalogType) + { + if (!_actors.TryGetValue(actorId, out BehaviorActorState actor)) return false; + if (!_catalogs.ContainsKey(catalogType)) return false; + actor.CatalogType = catalogType; + return true; + } + + public BehaviorStartResult TryStartBehavior(int actorId, string behaviorId, BehaviorInput input) + { + if (!_actors.TryGetValue(actorId, out BehaviorActorState actor)) + return BehaviorStartResult.Fail("actor not found"); + if (!_catalogs.TryGetValue(actor.CatalogType, out BehaviorConfigCatalog catalog)) + return BehaviorStartResult.Fail("catalog not found"); + if (!catalog.Behaviors.TryGetValue(behaviorId, out BehaviorSpec spec)) + return BehaviorStartResult.Fail("behavior not found"); + if (_activeBehaviors.ContainsKey(actorId)) + return BehaviorStartResult.Fail("actor already has active behavior"); + + var active = new ActiveBehavior + { + ActorId = actorId, + Catalog = catalog, + Spec = spec, + Input = input, + Elapsed = 0f + }; + _activeBehaviors[actorId] = active; + _events.Add(new BehaviorEvent { Kind = BehaviorEventKind.BehaviorStarted, ActorId = actorId, BehaviorId = spec.Id }); + FireTimeline(active, -0.00001f, 0f); + return BehaviorStartResult.Ok(); + } + + public bool StopBehavior(int actorId, BehaviorStopReason reason) + { + if (!_activeBehaviors.TryGetValue(actorId, out ActiveBehavior active)) return false; + if (!active.Spec.CanBeInterrupted && reason != BehaviorStopReason.Death && reason != BehaviorStopReason.Completed) return false; + _activeBehaviors.Remove(actorId); + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.BehaviorStopped, + ActorId = actorId, + BehaviorId = active.Spec.Id, + StopReason = reason + }); + return true; + } + + public void Tick(float deltaTime) + { + if (deltaTime <= 0f) return; + var completed = new List(); + foreach (ActiveBehavior active in new List(_activeBehaviors.Values)) + { + float previous = active.Elapsed; + active.Elapsed += deltaTime; + FireTimeline(active, previous, active.Elapsed); + if (active.Elapsed >= active.Spec.Duration) + { + completed.Add(active.ActorId); + } + } + + for (int i = 0; i < completed.Count; i++) + { + StopBehavior(completed[i], BehaviorStopReason.Completed); + } + } + + public IReadOnlyList DrainEvents() + { + var drained = _events.ToArray(); + _events.Clear(); + return drained; + } + + public bool HasActiveBehavior(int actorId) + { + return _activeBehaviors.ContainsKey(actorId); + } + + public int ActiveBuffCount(int actorId) + { + return 0; + } + + public bool ApplyBuff(int targetActorId, string catalogType, string buffId, int sourceActorId) + { + return false; + } + + private void FireTimeline(ActiveBehavior active, float previousElapsed, float currentElapsed) + { + List timeline = active.Spec.Timeline; + for (int i = 0; i < timeline.Count; i++) + { + if (active.FiredTimeline.Contains(i)) continue; + TimelineEntrySpec entry = timeline[i]; + if (entry.Trigger != TimelineTriggerKind.Time) continue; + if (entry.Time > currentElapsed || entry.Time <= previousElapsed) continue; + active.FiredTimeline.Add(i); + FireTimelineEntry(active, entry); + } + } + + private void FireTimelineEntry(ActiveBehavior active, TimelineEntrySpec entry) + { + if (entry.Kind == TimelineEntryKind.Animation) + { + if (_options.Mode == BehaviorRunMode.ClientPresentation || _options.Mode == BehaviorRunMode.ClientPredict) + { + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.TimelineAnimation, + ActorId = active.ActorId, + BehaviorId = active.Spec.Id, + Animation = entry.Animation + }); + } + return; + } + + if (entry.Kind == TimelineEntryKind.Effect) + { + if (_options.Mode == BehaviorRunMode.ClientPresentation || _options.Mode == BehaviorRunMode.ClientPredict) + { + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.TimelineEffect, + ActorId = active.ActorId, + BehaviorId = active.Spec.Id, + Prefab = entry.Prefab + }); + } + return; + } + + if (entry.Kind == TimelineEntryKind.LogicEffect && _options.Mode == BehaviorRunMode.ServerLogic) + { + LogicEffectSpec effect = FindLogicEffect(active.Spec.LogicEffects, entry.EffectId); + if (effect != null) ApplyLogicEffect(active.ActorId, active.Catalog, effect, active.Input, 0); + } + } + + private void ApplyLogicEffect(int sourceActorId, BehaviorConfigCatalog catalog, LogicEffectSpec effect, BehaviorInput input, int hitActorId) + { + foreach (int targetActorId in SelectTargets(sourceActorId, effect, input, hitActorId)) + { + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.LogicEffectApplied, + ActorId = sourceActorId, + SourceActorId = sourceActorId, + TargetActorId = targetActorId, + LogicEffectId = effect.Id + }); + + for (int i = 0; i < effect.Effects.Count; i++) + { + LogicEffectOpSpec op = effect.Effects[i]; + if (op.Type == LogicEffectOpKind.Attribute) + { + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.AttributeEffectRequested, + ActorId = sourceActorId, + SourceActorId = sourceActorId, + TargetActorId = targetActorId, + LogicEffectId = effect.Id, + Attribute = op.Attribute, + Value = op.Value + }); + } + } + } + } + + private IEnumerable SelectTargets(int sourceActorId, LogicEffectSpec effect, BehaviorInput input, int hitActorId) + { + if (!_actors.TryGetValue(sourceActorId, out BehaviorActorState source)) + yield break; + + if (effect.Target == LogicTargetKind.HitActor) + { + int targetId = hitActorId != 0 ? hitActorId : input.TargetActorId; + if (targetId != 0 && _actors.ContainsKey(targetId)) yield return targetId; + yield break; + } + + foreach (BehaviorActorState actor in _actors.Values) + { + if (!actor.Alive) continue; + if (!PassesTargetFilter(source, actor, effect.Target)) continue; + if (IsInsideShape(source, actor, effect)) + yield return actor.ActorId; + } + } + + private static bool PassesTargetFilter(BehaviorActorState source, BehaviorActorState target, LogicTargetKind filter) + { + if (filter == LogicTargetKind.Self || filter == LogicTargetKind.Owner) + return target.ActorId == source.ActorId; + if (filter == LogicTargetKind.Ally) + return target.ActorId != source.ActorId && target.TeamId == source.TeamId; + if (filter == LogicTargetKind.Enemy) + return target.TeamId != source.TeamId; + return true; + } + + private static bool IsInsideShape(BehaviorActorState source, BehaviorActorState target, LogicEffectSpec effect) + { + float distance = BehaviorVector3.DistanceXZ(source.Position, target.Position); + if (effect.Shape == LogicShapeKind.Sphere) + return distance <= effect.Radius + target.Radius; + if (effect.Shape == LogicShapeKind.Ring) + return distance >= effect.MinRadius && distance <= effect.Radius + target.Radius; + if (effect.Shape == LogicShapeKind.Sector) + { + if (distance > effect.Radius + target.Radius) return false; + BehaviorVector3 toTarget = (target.Position - source.Position).NormalizedXZ(); + BehaviorVector3 forward = source.Forward.NormalizedXZ(); + float dot = Clamp(BehaviorVector3.DotXZ(forward, toTarget), -1f, 1f); + float angle = (float)(Math.Acos(dot) * 180.0 / Math.PI); + return angle <= effect.Angle * 0.5f; + } + if (effect.Shape == LogicShapeKind.Line) + { + float width = effect.Width > 0f ? effect.Width : target.Radius; + float dist = DistancePointToSegmentXZ(target.Position, source.Position, source.Position + source.Forward.NormalizedXZ() * effect.Radius); + return dist <= width + target.Radius; + } + return target.ActorId == source.ActorId; + } + + private static float DistancePointToSegmentXZ(BehaviorVector3 point, BehaviorVector3 start, BehaviorVector3 end) + { + BehaviorVector3 ab = end - start; + BehaviorVector3 ap = point - start; + float abLenSq = ab.X * ab.X + ab.Z * ab.Z; + if (abLenSq <= 0.00001f) return BehaviorVector3.DistanceXZ(point, start); + float t = Clamp((ap.X * ab.X + ap.Z * ab.Z) / abLenSq, 0f, 1f); + BehaviorVector3 nearest = start + ab * t; + return BehaviorVector3.DistanceXZ(point, nearest); + } + + private static float Clamp(float value, float min, float max) + { + if (value < min) return min; + return value > max ? max : value; + } + + private static LogicEffectSpec FindLogicEffect(List effects, string effectId) + { + for (int i = 0; i < effects.Count; i++) + if (effects[i].Id == effectId) return effects[i]; + return null; + } + + private sealed class ActiveBehavior + { + public int ActorId; + public BehaviorConfigCatalog Catalog; + public BehaviorSpec Spec; + public BehaviorInput Input; + public float Elapsed; + public readonly HashSet FiredTimeline = new HashSet(); + } + } +} diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs new file mode 100644 index 00000000..ba162123 --- /dev/null +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs @@ -0,0 +1,86 @@ +using Xunit; +using XWorld.Framework.ActorBehavior; + +namespace XWorld.Framework.Tests.ActorBehavior +{ + public class BehaviorWorldTests + { + [Fact] + public void TryStartBehavior_AllowsOnlyOneActiveBehavior() + { + BehaviorWorld world = TestWorld.CreateServerWorld(); + Assert.True(world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)).Success); + + BehaviorStartResult second = world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + + Assert.False(second.Success); + Assert.True(world.HasActiveBehavior(1)); + } + + [Fact] + public void StopBehavior_AllowsInterruptibleBehaviorToSwitch() + { + BehaviorWorld world = TestWorld.CreateServerWorld(); + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + + Assert.True(world.StopBehavior(1, BehaviorStopReason.Stunned)); + Assert.True(world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)).Success); + + Assert.Contains(world.DrainEvents(), e => e.Kind == BehaviorEventKind.BehaviorStopped); + } + + [Fact] + public void Tick_TriggersTimelineEventsAtConfiguredTimes() + { + BehaviorWorld world = TestWorld.CreatePresentationWorld(); + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + + world.Tick(0.26f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineAnimation && e.Animation == "slash_01"); + Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineEffect && e.Prefab == "Assets/Game/Art/Effect/slash.prefab"); + } + + [Fact] + public void ServerLogic_SkipsPresentationEventsAndAppliesLogic() + { + BehaviorWorld world = TestWorld.CreateServerWorld(); + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + + world.Tick(0.26f); + var events = world.DrainEvents(); + + Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.TimelineAnimation); + Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.TimelineEffect); + Assert.Contains(events, e => e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 2 && e.Attribute == "hp"); + } + } + + internal static class TestWorld + { + public static BehaviorWorld CreateServerWorld() + { + return Create(BehaviorRunMode.ServerLogic); + } + + public static BehaviorWorld CreatePresentationWorld() + { + return Create(BehaviorRunMode.ClientPresentation); + } + + private static BehaviorWorld Create(BehaviorRunMode mode) + { + BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson( + "fighter", + TestBehaviorJson.Skill, + TestBehaviorJson.FlyObject, + TestBehaviorJson.Buff); + var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = mode }); + world.RegisterCatalog(catalog); + world.AddActor(new BehaviorActorState { ActorId = 1, CatalogType = "fighter", TeamId = 1, Position = new BehaviorVector3(0, 0, 0), Forward = new BehaviorVector3(1, 0, 0), Radius = 0.3f, Alive = true }); + world.AddActor(new BehaviorActorState { ActorId = 2, CatalogType = "fighter", TeamId = 2, Position = new BehaviorVector3(1, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true }); + return world; + } + } +}