diff --git a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs index 641da895..b28c288c 100644 --- a/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs +++ b/Client/Assets/Framework/Shared/ActorBehavior/BehaviorWorld.cs @@ -9,14 +9,16 @@ namespace XWorld.Framework.ActorBehavior private readonly Dictionary _catalogs = new Dictionary(StringComparer.Ordinal); private readonly Dictionary _actors = new Dictionary(); private readonly Dictionary _activeBehaviors = new Dictionary(); + private readonly List _projectiles = new List(); private readonly List _events = new List(); + private int _nextProjectileId = 1; public BehaviorWorld(BehaviorWorldOptions options) { _options = options; } - public int ActiveProjectileCount => 0; + public int ActiveProjectileCount => _projectiles.Count; public void RegisterCatalog(BehaviorConfigCatalog catalog) { @@ -81,6 +83,8 @@ namespace XWorld.Framework.ActorBehavior public void Tick(float deltaTime) { if (deltaTime <= 0f) return; + TickProjectiles(deltaTime); + var completed = new List(); foreach (ActiveBehavior active in new List(_activeBehaviors.Values)) { @@ -171,6 +175,12 @@ namespace XWorld.Framework.ActorBehavior { LogicEffectSpec effect = FindLogicEffect(active.Spec.LogicEffects, entry.EffectId); if (effect != null) ApplyLogicEffect(active.ActorId, active.Catalog, effect, active.Input, 0); + return; + } + + if (entry.Kind == TimelineEntryKind.Projectile) + { + SpawnProjectile(active.ActorId, active.Catalog, entry.FlyObjectId, active.Input); } } @@ -203,10 +213,150 @@ namespace XWorld.Framework.ActorBehavior Value = op.Value }); } + else if (op.Type == LogicEffectOpKind.Projectile) + { + SpawnProjectile(sourceActorId, catalog, op.FlyObjectId, input); + } } } } + private void SpawnProjectile(int sourceActorId, BehaviorConfigCatalog catalog, string flyObjectId, BehaviorInput input) + { + if (!_actors.TryGetValue(sourceActorId, out BehaviorActorState source)) return; + if (!catalog.FlyObjects.TryGetValue(flyObjectId, out FlyObjectSpec spec)) return; + + int targetActorId = input.Kind == BehaviorInputKind.TargetActor ? input.TargetActorId : 0; + BehaviorVector3 direction = ResolveProjectileDirection(source, input, targetActorId); + var projectile = new ActiveProjectile + { + RuntimeId = _nextProjectileId++, + SourceActorId = sourceActorId, + Catalog = catalog, + Spec = spec, + Position = source.Position, + Direction = direction, + TargetActorId = targetActorId, + Age = 0f, + VerticalVelocity = spec.Movement.Speed * 0.5f + }; + _projectiles.Add(projectile); + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.ProjectileSpawned, + ActorId = sourceActorId, + SourceActorId = sourceActorId, + FlyObjectId = spec.Id, + Position = projectile.Position + }); + } + + private BehaviorVector3 ResolveProjectileDirection(BehaviorActorState source, BehaviorInput input, int targetActorId) + { + if (input.Kind == BehaviorInputKind.Direction) + return input.Direction.NormalizedXZ(); + if (input.Kind == BehaviorInputKind.TargetPosition) + return (input.TargetPosition - source.Position).NormalizedXZ(); + if (targetActorId != 0 && _actors.TryGetValue(targetActorId, out BehaviorActorState target)) + return (target.Position - source.Position).NormalizedXZ(); + return source.Forward.NormalizedXZ(); + } + + private void TickProjectiles(float deltaTime) + { + var expired = new HashSet(); + foreach (ActiveProjectile projectile in _projectiles) + { + projectile.Age += deltaTime; + MoveProjectile(projectile, deltaTime); + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.ProjectileMoved, + ActorId = projectile.SourceActorId, + SourceActorId = projectile.SourceActorId, + FlyObjectId = projectile.Spec.Id, + Position = projectile.Position + }); + + if (_options.Mode == BehaviorRunMode.ServerLogic) + CheckProjectileCollision(projectile, expired); + + if (projectile.Age >= projectile.Spec.Duration) + expired.Add(projectile); + } + + if (expired.Count == 0) return; + _projectiles.RemoveAll(p => expired.Contains(p)); + } + + private void MoveProjectile(ActiveProjectile projectile, float deltaTime) + { + FlyMovementSpec movement = projectile.Spec.Movement; + BehaviorVector3 direction = projectile.Direction.NormalizedXZ(); + if (movement.Kind == FlyMovementKind.Homing + && projectile.TargetActorId != 0 + && _actors.TryGetValue(projectile.TargetActorId, out BehaviorActorState target)) + { + direction = (target.Position - projectile.Position).NormalizedXZ(); + projectile.Direction = direction; + } + + projectile.Position += direction * (movement.Speed * deltaTime); + if (movement.Kind == FlyMovementKind.Parabola) + { + projectile.Position = new BehaviorVector3( + projectile.Position.X, + projectile.Position.Y + projectile.VerticalVelocity * deltaTime - 0.5f * movement.Gravity * deltaTime * deltaTime, + projectile.Position.Z); + projectile.VerticalVelocity -= movement.Gravity * deltaTime; + } + } + + private void CheckProjectileCollision(ActiveProjectile projectile, HashSet expired) + { + if (!_actors.TryGetValue(projectile.SourceActorId, out BehaviorActorState source)) return; + foreach (BehaviorActorState actor in _actors.Values) + { + if (!actor.Alive) continue; + if (actor.ActorId == projectile.SourceActorId) continue; + if (actor.TeamId == source.TeamId) continue; + float distance = BehaviorVector3.DistanceXZ(projectile.Position, actor.Position); + if (distance > projectile.Spec.Collision.Radius + actor.Radius) continue; + + projectile.HitCount++; + _events.Add(new BehaviorEvent + { + Kind = BehaviorEventKind.ProjectileHit, + ActorId = projectile.SourceActorId, + SourceActorId = projectile.SourceActorId, + TargetActorId = actor.ActorId, + FlyObjectId = projectile.Spec.Id, + Position = projectile.Position + }); + FireProjectileCollisionLogic(projectile, actor.ActorId); + + if (projectile.Spec.Collision.HitLimit > 0 && projectile.HitCount >= projectile.Spec.Collision.HitLimit) + { + expired.Add(projectile); + return; + } + } + } + + private void FireProjectileCollisionLogic(ActiveProjectile projectile, int hitActorId) + { + List timeline = projectile.Spec.Timeline; + for (int i = 0; i < timeline.Count; i++) + { + TimelineEntrySpec entry = timeline[i]; + if (entry.Kind != TimelineEntryKind.LogicEffect || entry.Trigger != TimelineTriggerKind.OnCollision) + continue; + LogicEffectSpec effect = FindLogicEffect(projectile.Spec.LogicEffects, entry.EffectId); + if (effect != null) + ApplyLogicEffect(projectile.SourceActorId, projectile.Catalog, effect, BehaviorInput.FromTargetActor(hitActorId), hitActorId); + } + } + private IEnumerable SelectTargets(int sourceActorId, LogicEffectSpec effect, BehaviorInput input, int hitActorId) { if (!_actors.TryGetValue(sourceActorId, out BehaviorActorState source)) @@ -297,5 +447,19 @@ namespace XWorld.Framework.ActorBehavior public float Elapsed; public readonly HashSet FiredTimeline = new HashSet(); } + + private sealed class ActiveProjectile + { + public int RuntimeId; + public int SourceActorId; + public BehaviorConfigCatalog Catalog; + public FlyObjectSpec Spec; + public BehaviorVector3 Position; + public BehaviorVector3 Direction; + public int TargetActorId; + public float Age; + public float VerticalVelocity; + public int HitCount; + } } } diff --git a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs index ba162123..62c67530 100644 --- a/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs +++ b/Server/Framework.Shared.Tests/ActorBehavior/BehaviorWorldTests.cs @@ -55,6 +55,45 @@ namespace XWorld.Framework.Tests.ActorBehavior Assert.DoesNotContain(events, e => e.Kind == BehaviorEventKind.TimelineEffect); Assert.Contains(events, e => e.Kind == BehaviorEventKind.AttributeEffectRequested && e.TargetActorId == 2 && e.Attribute == "hp"); } + + [Fact] + public void Projectile_SpawnsMovesHitsAndExpiresAtHitLimit() + { + BehaviorWorld world = TestWorld.CreateServerWorld(); + + world.TryStartBehavior(1, "slash", BehaviorInput.FromDirection(1, 0)); + world.Tick(0.33f); + Assert.True(world.ActiveProjectileCount > 0); + + world.Tick(0.1f); + var events = world.DrainEvents(); + + Assert.Contains(events, e => e.Kind == BehaviorEventKind.ProjectileSpawned); + Assert.Contains(events, e => e.Kind == BehaviorEventKind.ProjectileMoved); + Assert.Contains(events, e => e.Kind == BehaviorEventKind.ProjectileHit && e.TargetActorId == 2); + Assert.Equal(0, world.ActiveProjectileCount); + } + + [Theory] + [InlineData("Line")] + [InlineData("Parabola")] + [InlineData("Homing")] + public void ProjectileMovementModes_UpdatePosition(string movementKind) + { + string fly = TestBehaviorJson.FlyObject.Replace(@"""kind"": ""Line""", @"""kind"": """ + movementKind + @""""); + BehaviorConfigCatalog catalog = BehaviorConfigCatalog.LoadFromJson("fighter", TestBehaviorJson.Skill, fly, TestBehaviorJson.Buff); + var world = new BehaviorWorld(new BehaviorWorldOptions { Mode = BehaviorRunMode.ClientPresentation }); + 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(4, 0, 0), Forward = new BehaviorVector3(-1, 0, 0), Radius = 0.3f, Alive = true }); + + world.TryStartBehavior(1, "slash", BehaviorInput.FromTargetActor(2)); + world.Tick(0.33f); + world.DrainEvents(); + world.Tick(0.1f); + + Assert.Contains(world.DrainEvents(), e => e.Kind == BehaviorEventKind.ProjectileMoved && e.Position.X > 0); + } } internal static class TestWorld