feat: add actor behavior runtime
This commit is contained in:
@@ -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<string, BehaviorConfigCatalog> _catalogs = new Dictionary<string, BehaviorConfigCatalog>(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<int, BehaviorActorState> _actors = new Dictionary<int, BehaviorActorState>();
|
||||||
|
private readonly Dictionary<int, ActiveBehavior> _activeBehaviors = new Dictionary<int, ActiveBehavior>();
|
||||||
|
private readonly List<BehaviorEvent> _events = new List<BehaviorEvent>();
|
||||||
|
|
||||||
|
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<int>();
|
||||||
|
foreach (ActiveBehavior active in new List<ActiveBehavior>(_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<BehaviorEvent> 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<TimelineEntrySpec> 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<int> 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<LogicEffectSpec> 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<int> FiredTimeline = new HashSet<int>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user