fix: complete actor behavior timelines

This commit is contained in:
ud18010
2026-07-30 13:29:35 +08:00
parent 1380d5107a
commit bc52b74ff8
4 changed files with 221 additions and 2 deletions
@@ -204,7 +204,11 @@ namespace XWorld.Framework.ActorBehavior
foreach (FlyObjectSpec fly in _flyObjects.Values) foreach (FlyObjectSpec fly in _flyObjects.Values)
{ {
ValidateDuration(fly.Duration, "flyObject " + fly.Id); ValidateDuration(fly.Duration, "flyObject " + fly.Id);
if (fly.Movement.Speed < 0f)
throw new InvalidOperationException("Invalid speed for flyObject " + fly.Id);
ValidateDuration(fly.Collision.Radius, "flyObject collision " + fly.Id); ValidateDuration(fly.Collision.Radius, "flyObject collision " + fly.Id);
if (fly.Collision.HitLimit < 0)
throw new InvalidOperationException("Invalid hitLimit for flyObject " + fly.Id);
ValidateTimeline(fly.Timeline, fly.LogicEffects, "flyObject " + fly.Id); ValidateTimeline(fly.Timeline, fly.LogicEffects, "flyObject " + fly.Id);
ValidateLogicEffects(fly.LogicEffects, "flyObject " + fly.Id); ValidateLogicEffects(fly.LogicEffects, "flyObject " + fly.Id);
} }
@@ -242,8 +246,12 @@ namespace XWorld.Framework.ActorBehavior
AddUnique(ids, effect.Id, "logicEffect"); AddUnique(ids, effect.Id, "logicEffect");
if (effect.Shape != LogicShapeKind.Locked && effect.Radius < 0f) if (effect.Shape != LogicShapeKind.Locked && effect.Radius < 0f)
throw new InvalidOperationException("Invalid radius for " + effect.Id + " in " + owner); throw new InvalidOperationException("Invalid radius for " + effect.Id + " in " + owner);
if (effect.Shape == LogicShapeKind.Ring && (effect.MinRadius < 0f || effect.MinRadius > effect.Radius))
throw new InvalidOperationException("Invalid minRadius for " + effect.Id + " in " + owner);
if (effect.Shape == LogicShapeKind.Sector && (effect.Angle <= 0f || effect.Angle > 360f)) if (effect.Shape == LogicShapeKind.Sector && (effect.Angle <= 0f || effect.Angle > 360f))
throw new InvalidOperationException("Invalid sector angle for " + effect.Id + " in " + owner); throw new InvalidOperationException("Invalid sector angle for " + effect.Id + " in " + owner);
if (effect.Shape == LogicShapeKind.Line && effect.Width < 0f)
throw new InvalidOperationException("Invalid width for " + effect.Id + " in " + owner);
foreach (LogicEffectOpSpec op in effect.Effects) foreach (LogicEffectOpSpec op in effect.Effects)
{ {
if (op.Type == LogicEffectOpKind.Buff && !_buffs.ContainsKey(op.BuffId)) if (op.Type == LogicEffectOpKind.Buff && !_buffs.ContainsKey(op.BuffId))
@@ -236,6 +236,13 @@ namespace XWorld.Framework.ActorBehavior
return; return;
} }
if (entry.Kind == TimelineEntryKind.Buff)
{
int targetActorId = active.Input.Kind == BehaviorInputKind.TargetActor ? active.Input.TargetActorId : active.ActorId;
ApplyBuff(targetActorId, active.Catalog.Type, entry.BuffId, active.ActorId);
return;
}
if (entry.Kind == TimelineEntryKind.Projectile) if (entry.Kind == TimelineEntryKind.Projectile)
{ {
SpawnProjectile(active.ActorId, active.Catalog, entry.FlyObjectId, active.Input); SpawnProjectile(active.ActorId, active.Catalog, entry.FlyObjectId, active.Input);
@@ -308,8 +315,11 @@ namespace XWorld.Framework.ActorBehavior
TargetActorId = buff.TargetActorId, TargetActorId = buff.TargetActorId,
BuffId = buff.Spec.Id BuffId = buff.Spec.Id
}); });
for (int e = 0; e < buff.Spec.LogicEffects.Count; e++) if (_options.Mode == BehaviorRunMode.ServerLogic)
ApplyLogicEffect(buff.TargetActorId, buff.Catalog, buff.Spec.LogicEffects[e], BehaviorInput.FromTargetActor(buff.TargetActorId), buff.TargetActorId); {
for (int e = 0; e < buff.Spec.LogicEffects.Count; e++)
ApplyLogicEffect(buff.TargetActorId, buff.Catalog, buff.Spec.LogicEffects[e], BehaviorInput.FromTargetActor(buff.TargetActorId), buff.TargetActorId);
}
buff.NextTickTime += buff.Spec.TickInterval; buff.NextTickTime += buff.Spec.TickInterval;
} }
@@ -364,6 +374,7 @@ namespace XWorld.Framework.ActorBehavior
FlyObjectId = spec.Id, FlyObjectId = spec.Id,
Position = projectile.Position Position = projectile.Position
}); });
FireProjectileTimeline(projectile, -0.00001f, 0f);
} }
private BehaviorVector3 ResolveProjectileDirection(BehaviorActorState source, BehaviorInput input, int targetActorId) private BehaviorVector3 ResolveProjectileDirection(BehaviorActorState source, BehaviorInput input, int targetActorId)
@@ -382,6 +393,7 @@ namespace XWorld.Framework.ActorBehavior
var expired = new HashSet<ActiveProjectile>(); var expired = new HashSet<ActiveProjectile>();
foreach (ActiveProjectile projectile in _projectiles) foreach (ActiveProjectile projectile in _projectiles)
{ {
float previousAge = projectile.Age;
projectile.Age += deltaTime; projectile.Age += deltaTime;
MoveProjectile(projectile, deltaTime); MoveProjectile(projectile, deltaTime);
_events.Add(new BehaviorEvent _events.Add(new BehaviorEvent
@@ -392,6 +404,7 @@ namespace XWorld.Framework.ActorBehavior
FlyObjectId = projectile.Spec.Id, FlyObjectId = projectile.Spec.Id,
Position = projectile.Position Position = projectile.Position
}); });
FireProjectileTimeline(projectile, previousAge, projectile.Age);
if (_options.Mode == BehaviorRunMode.ServerLogic) if (_options.Mode == BehaviorRunMode.ServerLogic)
CheckProjectileCollision(projectile, expired); CheckProjectileCollision(projectile, expired);
@@ -458,6 +471,54 @@ namespace XWorld.Framework.ActorBehavior
} }
} }
private void FireProjectileTimeline(ActiveProjectile projectile, float previousElapsed, float currentElapsed)
{
List<TimelineEntrySpec> timeline = projectile.Spec.Timeline;
for (int i = 0; i < timeline.Count; i++)
{
if (projectile.FiredTimeline.Contains(i)) continue;
TimelineEntrySpec entry = timeline[i];
if (entry.Trigger != TimelineTriggerKind.Time) continue;
if (entry.Time > currentElapsed || entry.Time <= previousElapsed) continue;
projectile.FiredTimeline.Add(i);
FireProjectileTimelineEntry(projectile, entry);
}
}
private void FireProjectileTimelineEntry(ActiveProjectile projectile, TimelineEntrySpec entry)
{
if (entry.Kind == TimelineEntryKind.Effect)
{
if (_options.Mode == BehaviorRunMode.ClientPresentation || _options.Mode == BehaviorRunMode.ClientPredict)
{
_events.Add(new BehaviorEvent
{
Kind = BehaviorEventKind.TimelineEffect,
ActorId = projectile.SourceActorId,
SourceActorId = projectile.SourceActorId,
FlyObjectId = projectile.Spec.Id,
Prefab = entry.Prefab,
Position = projectile.Position
});
}
return;
}
if (entry.Kind == TimelineEntryKind.LogicEffect && _options.Mode == BehaviorRunMode.ServerLogic)
{
LogicEffectSpec effect = FindLogicEffect(projectile.Spec.LogicEffects, entry.EffectId);
if (effect != null)
ApplyLogicEffect(projectile.SourceActorId, projectile.Catalog, effect, BehaviorInput.FromTargetActor(projectile.TargetActorId), projectile.TargetActorId);
return;
}
if (entry.Kind == TimelineEntryKind.Buff)
{
int targetActorId = projectile.TargetActorId != 0 ? projectile.TargetActorId : projectile.SourceActorId;
ApplyBuff(targetActorId, projectile.Catalog.Type, entry.BuffId, projectile.SourceActorId);
}
}
private void FireProjectileCollisionLogic(ActiveProjectile projectile, int hitActorId) private void FireProjectileCollisionLogic(ActiveProjectile projectile, int hitActorId)
{ {
List<TimelineEntrySpec> timeline = projectile.Spec.Timeline; List<TimelineEntrySpec> timeline = projectile.Spec.Timeline;
@@ -575,6 +636,7 @@ namespace XWorld.Framework.ActorBehavior
public float Age; public float Age;
public float VerticalVelocity; public float VerticalVelocity;
public int HitCount; public int HitCount;
public readonly HashSet<int> FiredTimeline = new HashSet<int>();
} }
private sealed class ActiveBuff private sealed class ActiveBuff
@@ -45,6 +45,36 @@ namespace XWorld.Framework.Tests.ActorBehavior
Assert.Contains("missing_wave", ex.Message); Assert.Contains("missing_wave", ex.Message);
} }
[Fact]
public void LoadFromJson_RejectsInvalidFlyObjectMovementAndCollision()
{
string badSpeedFlyObject = TestBehaviorJson.FlyObject.Replace("\"speed\": 6.0", "\"speed\": -6.0");
string badHitLimitFlyObject = TestBehaviorJson.FlyObject.Replace("\"hitLimit\": 1", "\"hitLimit\": -1");
InvalidOperationException speedEx = Assert.Throws<InvalidOperationException>(() =>
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, badSpeedFlyObject, TestBehaviorJson.Buff));
InvalidOperationException hitLimitEx = Assert.Throws<InvalidOperationException>(() =>
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, badHitLimitFlyObject, TestBehaviorJson.Buff));
Assert.Contains("speed", speedEx.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("hitLimit", hitLimitEx.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void LoadFromJson_RejectsInvalidRingAndLineShapeParameters()
{
InvalidOperationException ringEx = Assert.Throws<InvalidOperationException>(() =>
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithInvalidRing, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
InvalidOperationException negativeRingEx = Assert.Throws<InvalidOperationException>(() =>
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithNegativeRingMinRadius, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
InvalidOperationException lineEx = Assert.Throws<InvalidOperationException>(() =>
BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithInvalidLine, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff));
Assert.Contains("minRadius", ringEx.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("minRadius", negativeRingEx.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("width", lineEx.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact] [Fact]
public void ExampleConfigFiles_LoadFromRepository() public void ExampleConfigFiles_LoadFromRepository()
{ {
@@ -144,5 +174,80 @@ namespace XWorld.Framework.Tests.ActorBehavior
}] }]
}] }]
}"; }";
public const string SkillWithBuffTimeline = @"{
""type"": ""fighter"",
""version"": 1,
""behaviors"": [{
""id"": ""slow_cast"",
""duration"": 0.2,
""canBeInterrupted"": true,
""input"": ""TargetActor"",
""timeline"": [
{ ""time"": 0.0, ""kind"": ""Buff"", ""buffId"": ""slow_01"" }
],
""logicEffects"": []
}]
}";
public const string SkillWithInvalidRing = @"{
""type"": ""fighter"",
""version"": 1,
""behaviors"": [{
""id"": ""bad_ring"",
""duration"": 0.2,
""timeline"": [
{ ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""ring_hit"" }
],
""logicEffects"": [{
""id"": ""ring_hit"",
""shape"": ""Ring"",
""radius"": 1.0,
""minRadius"": 2.0,
""target"": ""Enemy"",
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
}]
}]
}";
public const string SkillWithNegativeRingMinRadius = @"{
""type"": ""fighter"",
""version"": 1,
""behaviors"": [{
""id"": ""bad_negative_ring"",
""duration"": 0.2,
""timeline"": [
{ ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""ring_hit"" }
],
""logicEffects"": [{
""id"": ""ring_hit"",
""shape"": ""Ring"",
""radius"": 1.0,
""minRadius"": -0.1,
""target"": ""Enemy"",
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
}]
}]
}";
public const string SkillWithInvalidLine = @"{
""type"": ""fighter"",
""version"": 1,
""behaviors"": [{
""id"": ""bad_line"",
""duration"": 0.2,
""timeline"": [
{ ""time"": 0.0, ""kind"": ""LogicEffect"", ""effectId"": ""line_hit"" }
],
""logicEffects"": [{
""id"": ""line_hit"",
""shape"": ""Line"",
""radius"": 1.0,
""width"": -0.2,
""target"": ""Enemy"",
""effects"": [{ ""type"": ""Attribute"", ""attribute"": ""hp"", ""value"": -1 }]
}]
}]
}";
} }
} }
@@ -115,6 +115,50 @@ namespace XWorld.Framework.Tests.ActorBehavior
Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffRemoved && e.TargetActorId == 2); Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffRemoved && e.TargetActorId == 2);
Assert.Equal(0, world.ActiveBuffCount(2)); Assert.Equal(0, world.ActiveBuffCount(2));
} }
[Fact]
public void ClientPresentation_BuffTickDoesNotEmitAuthoritativeAttributeRequests()
{
BehaviorWorld world = TestWorld.CreatePresentationWorld();
Assert.True(world.ApplyBuff(2, "fighter", "slow_01", 1));
world.DrainEvents();
world.Tick(1.0f);
var events = world.DrainEvents();
Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffTicked && e.TargetActorId == 2);
Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.AttributeEffectRequested);
Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.LogicEffectApplied);
}
[Fact]
public void BehaviorTimelineBuff_AppliesConfiguredBuff()
{
BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.SkillWithBuffTimeline, TestBehaviorJson.FlyObject, TestBehaviorJson.Buff);
var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = BehaviorRunMode.ServerLogic });
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 });
world.TryStartBehavior(1, "slow_cast", BehaviorInput.FromTargetActor(2));
world.Tick(0.01f);
var events = world.DrainEvents();
Assert.Contains(events, e => e.Kind == BehaviorEventKind.BuffAdded && e.TargetActorId == 2 && e.BuffId == "slow_01");
Assert.Equal(1, world.ActiveBuffCount(2));
}
[Fact]
public void ProjectileTimelineEffect_FiresInClientPresentation()
{
BehaviorWorld world = TestWorld.CreatePresentationWorld();
world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0));
world.Tick(0.33f);
var events = world.DrainEvents();
Assert.Contains(events, e => e.Kind == BehaviorEventKind.TimelineEffect && e.FlyObjectId == "blade_wave" && e.Prefab == "Assets/Game/Art/Effect/blade_wave.prefab");
}
} }
internal static class TestWorld internal static class TestWorld