From 34b1424bb6d8fd5229518498d304f38db0f2f0d6 Mon Sep 17 00:00:00 2001 From: SepComet <202308010230@stu.csust.edu.cn> Date: Sun, 22 Feb 2026 10:47:42 +0800 Subject: [PATCH] Checkpoint 2 & Checkpoint 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkpoint 2:Simulation 与 Job 数据通道打通 - 新增 Job 数据通道(Persistent NativeList)与 Simulation <-> NativeContainer 拷贝/回写流程 - 在生命周期接入通道管理(初始化、清空、集中释放)并接入 UseJobSimulation 分支 - 增强通道健壮性:失效容器自动重建,避免 EditMode 下 ObjectDisposedException - 增加 UseJobSimulation 分支回归用例(EditMode/PlayMode 各 1 条) Checkpoint 3:敌人移动与朝向 Job 化 - UseJobSimulation 分支正式切到 Job 路径 - 新增敌人移动/朝向 Job 实现(IJobParallelFor): - Burst 版本 + 非 Burst 版本(受 UseBurstJobs 开关控制) - Job 计算移动、朝向、状态;主线程补做分离避让回补 - 扩展数据通道能力(为 Job 输出缓冲和投射物输出同步提供接口) - 补充分支回归测试(开启 UseJobSimulation 后验证追踪与朝向) --- .../Simulation/SimulationWorld.EnemyJobs.cs | 242 ++++++++++++ .../SimulationWorld.EnemyJobs.cs.meta | 11 + .../SimulationWorld.JobDataChannel.cs | 349 ++++++++++++++++++ .../SimulationWorld.JobDataChannel.cs.meta | 11 + .../Scripts/Simulation/SimulationWorld.cs | 7 +- .../EditMode/SimulationWorldTickTests.cs | 21 ++ .../PlayMode/SimulationWorldPlayModeTests.cs | 22 ++ docs/TodoList.md | 6 +- 8 files changed, 663 insertions(+), 6 deletions(-) create mode 100644 Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs create mode 100644 Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs.meta create mode 100644 Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs create mode 100644 Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs.meta diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs new file mode 100644 index 0000000..d2d827a --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs @@ -0,0 +1,242 @@ +using CustomDebugger; +using CustomUtility; +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + [BurstCompile] + private struct EnemyMovementBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + public NativeArray Outputs; + public float DeltaTime; + public float3 PlayerPosition; + + public void Execute(int index) + { + ExecuteEnemyMovement(index, Inputs, Outputs, DeltaTime, PlayerPosition); + } + } + + private struct EnemyMovementJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + public NativeArray Outputs; + public float DeltaTime; + public float3 PlayerPosition; + + public void Execute(int index) + { + ExecuteEnemyMovement(index, Inputs, Outputs, DeltaTime, PlayerPosition); + } + } + + private void TickEnemiesJobified(in SimulationTickContext context) + { + using (CustomProfilerMarker.TickEnemies_BuildInput.Auto()) + { + SyncSimulationToJobInput(); + } + + using (CustomProfilerMarker.TickEnemies_StateUpdate.Auto()) + { + ExecuteEnemyMovementJob(in context); + } + + using (CustomProfilerMarker.TickEnemies_MoveSeparation.Auto()) + { + ApplyEnemySeparationForJobOutput(); + } + + using (CustomProfilerMarker.TickEnemies_WriteBack.Auto()) + { + SyncProjectilesToJobOutput(); + ApplyJobOutputToSimulation(); + } + } + + private void ExecuteEnemyMovementJob(in SimulationTickContext context) + { + int enemyCount = _enemyJobInputs.Length; + PrepareEnemyJobOutputBuffer(enemyCount); + + if (enemyCount == 0) + { + return; + } + + if (context.DeltaTime <= 0f) + { + CopyEnemyInputToOutput(); + return; + } + + float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z); + NativeArray inputArray = _enemyJobInputs.AsArray(); + NativeArray outputArray = _enemyJobOutputs.AsArray(); + + JobHandle handle; + if (_useBurstJobs) + { + EnemyMovementBurstJob burstJob = new EnemyMovementBurstJob + { + Inputs = inputArray, + Outputs = outputArray, + DeltaTime = context.DeltaTime, + PlayerPosition = playerPosition + }; + handle = burstJob.Schedule(enemyCount, 64); + } + else + { + EnemyMovementJob job = new EnemyMovementJob + { + Inputs = inputArray, + Outputs = outputArray, + DeltaTime = context.DeltaTime, + PlayerPosition = playerPosition + }; + handle = job.Schedule(enemyCount, 64); + } + + handle.Complete(); + } + + private void CopyEnemyInputToOutput() + { + for (int i = 0; i < _enemyJobInputs.Length; i++) + { + EnemyJobInputData input = _enemyJobInputs[i]; + _enemyJobOutputs[i] = new EnemyJobOutputData + { + EntityId = input.EntityId, + Position = input.Position, + Forward = input.Forward, + Rotation = input.Rotation, + Speed = input.Speed, + AttackRange = input.AttackRange, + AvoidEnemyOverlap = input.AvoidEnemyOverlap, + EnemyBodyRadius = input.EnemyBodyRadius, + SeparationIterations = input.SeparationIterations, + TargetType = input.TargetType, + State = input.State + }; + } + } + + private void ApplyEnemySeparationForJobOutput() + { + if (_enemyJobOutputs.Length == 0) + { + return; + } + + _enemySeparationAgents.Clear(); + for (int i = 0; i < _enemyJobOutputs.Length; i++) + { + EnemyJobOutputData output = _enemyJobOutputs[i]; + if (!output.AvoidEnemyOverlap) + { + continue; + } + + Vector3 position = output.Position; + position.y = 0f; + _enemySeparationAgents.Add(new EnemySeparationAgent + { + AgentId = output.EntityId, + Position = position, + Radius = output.EnemyBodyRadius > 0f ? output.EnemyBodyRadius : 0.45f + }); + } + + if (_enemySeparationAgents.Count == 0) + { + return; + } + + EnemySeparationSolverProvider.SetSimulationAgents(_enemySeparationAgents); + for (int i = 0; i < _enemyJobOutputs.Length; i++) + { + EnemyJobOutputData output = _enemyJobOutputs[i]; + if (!output.AvoidEnemyOverlap || output.State != EnemyStateChasing) + { + continue; + } + + Vector3 resolvedPosition = EnemySeparationSolverProvider.ResolveSimulation( + output.EntityId, + output.Position, + output.Forward, + output.SeparationIterations > 0 ? output.SeparationIterations : 1); + + output.Position = resolvedPosition; + _enemyJobOutputs[i] = output; + } + } + + private static void ExecuteEnemyMovement(int index, NativeArray inputs, + NativeArray outputs, float deltaTime, float3 playerPosition) + { + EnemyJobInputData input = inputs[index]; + float attackRange = input.AttackRange > 0f ? input.AttackRange : DefaultAttackRange; + float attackRangeSqr = attackRange * attackRange; + + float3 currentPosition = new float3(input.Position.x, input.Position.y, input.Position.z); + float3 horizontalPosition = new float3(currentPosition.x, 0f, currentPosition.z); + float3 toPlayer = playerPosition - horizontalPosition; + float sqrDistance = math.lengthsq(toPlayer); + bool isInAttackRange = sqrDistance <= attackRangeSqr; + bool canChase = !isInAttackRange && input.Speed > 0f && sqrDistance > float.Epsilon; + + float3 forward = new float3(input.Forward.x, input.Forward.y, input.Forward.z); + float3 desiredPosition = currentPosition; + quaternion rotation = new quaternion(input.Rotation.x, input.Rotation.y, input.Rotation.z, input.Rotation.w); + + if (canChase) + { + forward = math.normalizesafe(toPlayer, forward); + desiredPosition = currentPosition + forward * input.Speed * deltaTime; + if (math.lengthsq(forward) > float.Epsilon) + { + rotation = quaternion.LookRotationSafe(forward, math.up()); + } + } + + int nextState; + if (isInAttackRange) + { + nextState = EnemyStateInAttackRange; + } + else if (canChase) + { + nextState = EnemyStateChasing; + } + else + { + nextState = EnemyStateIdle; + } + + outputs[index] = new EnemyJobOutputData + { + EntityId = input.EntityId, + Position = new Vector3(desiredPosition.x, desiredPosition.y, desiredPosition.z), + Forward = new Vector3(forward.x, forward.y, forward.z), + Rotation = new Quaternion(rotation.value.x, rotation.value.y, rotation.value.z, rotation.value.w), + Speed = input.Speed, + AttackRange = attackRange, + AvoidEnemyOverlap = input.AvoidEnemyOverlap, + EnemyBodyRadius = input.EnemyBodyRadius, + SeparationIterations = input.SeparationIterations, + TargetType = input.TargetType, + State = nextState + }; + } + } +} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs.meta b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs.meta new file mode 100644 index 0000000..86287bc --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 99b885bb5cfe48e7bd9dba74fe683149 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs new file mode 100644 index 0000000..11766cf --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs @@ -0,0 +1,349 @@ +using System; +using Unity.Collections; +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private struct EnemyJobInputData + { + public int EntityId; + public Vector3 Position; + public Vector3 Forward; + public Quaternion Rotation; + public float Speed; + public float AttackRange; + public bool AvoidEnemyOverlap; + public float EnemyBodyRadius; + public int SeparationIterations; + public int TargetType; + public int State; + } + + private struct EnemyJobOutputData + { + public int EntityId; + public Vector3 Position; + public Vector3 Forward; + public Quaternion Rotation; + public float Speed; + public float AttackRange; + public bool AvoidEnemyOverlap; + public float EnemyBodyRadius; + public int SeparationIterations; + public int TargetType; + public int State; + } + + private struct ProjectileJobInputData + { + public int EntityId; + public int OwnerEntityId; + public Vector3 Position; + public Vector3 Forward; + public float Speed; + public float RemainingLifetime; + public int State; + } + + private struct ProjectileJobOutputData + { + public int EntityId; + public int OwnerEntityId; + public Vector3 Position; + public Vector3 Forward; + public float Speed; + public float RemainingLifetime; + public int State; + } + + private NativeList _enemyJobInputs; + private NativeList _enemyJobOutputs; + private NativeList _projectileJobInputs; + private NativeList _projectileJobOutputs; + + private void InitializeJobDataChannels() + { + if (AreJobDataChannelsUsable()) + { + return; + } + + DisposeJobDataChannels(); + _enemyJobInputs = new NativeList(64, Allocator.Persistent); + _enemyJobOutputs = new NativeList(64, Allocator.Persistent); + _projectileJobInputs = new NativeList(64, Allocator.Persistent); + _projectileJobOutputs = new NativeList(64, Allocator.Persistent); + } + + private void DisposeJobDataChannels() + { + if (_enemyJobInputs.IsCreated) + { + _enemyJobInputs.Dispose(); + } + _enemyJobInputs = default; + + if (_enemyJobOutputs.IsCreated) + { + _enemyJobOutputs.Dispose(); + } + _enemyJobOutputs = default; + + if (_projectileJobInputs.IsCreated) + { + _projectileJobInputs.Dispose(); + } + _projectileJobInputs = default; + + if (_projectileJobOutputs.IsCreated) + { + _projectileJobOutputs.Dispose(); + } + _projectileJobOutputs = default; + } + + private void ClearJobDataChannels() + { + if (!AreJobDataChannelsUsable()) + { + return; + } + + if (_enemyJobInputs.IsCreated) + { + _enemyJobInputs.Clear(); + } + + if (_enemyJobOutputs.IsCreated) + { + _enemyJobOutputs.Clear(); + } + + if (_projectileJobInputs.IsCreated) + { + _projectileJobInputs.Clear(); + } + + if (_projectileJobOutputs.IsCreated) + { + _projectileJobOutputs.Clear(); + } + } + + private void SyncSimulationToJobInput() + { + InitializeJobDataChannels(); + EnsureCapacity(ref _enemyJobInputs, _enemies.Count); + EnsureCapacity(ref _projectileJobInputs, _projectiles.Count); + + _enemyJobInputs.Clear(); + _projectileJobInputs.Clear(); + + for (int i = 0; i < _enemies.Count; i++) + { + _enemyJobInputs.Add(ConvertToEnemyJobInput(_enemies[i])); + } + + for (int i = 0; i < _projectiles.Count; i++) + { + _projectileJobInputs.Add(ConvertToProjectileJobInput(_projectiles[i])); + } + } + + private void SyncSimulationToJobOutput() + { + InitializeJobDataChannels(); + EnsureCapacity(ref _enemyJobOutputs, _enemies.Count); + EnsureCapacity(ref _projectileJobOutputs, _projectiles.Count); + + _enemyJobOutputs.Clear(); + _projectileJobOutputs.Clear(); + + for (int i = 0; i < _enemies.Count; i++) + { + _enemyJobOutputs.Add(ConvertToEnemyJobOutput(_enemies[i])); + } + + for (int i = 0; i < _projectiles.Count; i++) + { + _projectileJobOutputs.Add(ConvertToProjectileJobOutput(_projectiles[i])); + } + } + + private void PrepareEnemyJobOutputBuffer(int enemyCount) + { + InitializeJobDataChannels(); + EnsureCapacity(ref _enemyJobOutputs, enemyCount); + _enemyJobOutputs.Clear(); + + if (enemyCount > 0) + { + _enemyJobOutputs.ResizeUninitialized(enemyCount); + } + } + + private void SyncProjectilesToJobOutput() + { + InitializeJobDataChannels(); + EnsureCapacity(ref _projectileJobOutputs, _projectiles.Count); + _projectileJobOutputs.Clear(); + + for (int i = 0; i < _projectiles.Count; i++) + { + _projectileJobOutputs.Add(ConvertToProjectileJobOutput(_projectiles[i])); + } + } + + private void ApplyJobOutputToSimulation() + { + int enemyCount = Mathf.Min(_enemies.Count, _enemyJobOutputs.Length); + for (int i = 0; i < enemyCount; i++) + { + _enemies[i] = ConvertToEnemySimData(_enemyJobOutputs[i]); + } + + int projectileCount = Mathf.Min(_projectiles.Count, _projectileJobOutputs.Length); + for (int i = 0; i < projectileCount; i++) + { + _projectiles[i] = ConvertToProjectileSimData(_projectileJobOutputs[i]); + } + } + + private bool AreJobDataChannelsUsable() + { + return IsNativeListUsable(_enemyJobInputs) && + IsNativeListUsable(_enemyJobOutputs) && + IsNativeListUsable(_projectileJobInputs) && + IsNativeListUsable(_projectileJobOutputs); + } + + private static void EnsureCapacity(ref NativeList nativeList, int targetCount) where T : unmanaged + { + if (!nativeList.IsCreated || targetCount <= 0) + { + return; + } + + if (nativeList.Capacity < targetCount) + { + nativeList.Capacity = targetCount; + } + } + + private static bool IsNativeListUsable(NativeList nativeList) where T : unmanaged + { + if (!nativeList.IsCreated) + { + return false; + } + + try + { + _ = nativeList.Length; + return true; + } + catch (ObjectDisposedException) + { + return false; + } + } + + private static EnemyJobInputData ConvertToEnemyJobInput(in EnemySimData enemy) + { + return new EnemyJobInputData + { + EntityId = enemy.EntityId, + Position = enemy.Position, + Forward = enemy.Forward, + Rotation = enemy.Rotation, + Speed = enemy.Speed, + AttackRange = enemy.AttackRange, + AvoidEnemyOverlap = enemy.AvoidEnemyOverlap, + EnemyBodyRadius = enemy.EnemyBodyRadius, + SeparationIterations = enemy.SeparationIterations, + TargetType = enemy.TargetType, + State = enemy.State + }; + } + + private static EnemyJobOutputData ConvertToEnemyJobOutput(in EnemySimData enemy) + { + return new EnemyJobOutputData + { + EntityId = enemy.EntityId, + Position = enemy.Position, + Forward = enemy.Forward, + Rotation = enemy.Rotation, + Speed = enemy.Speed, + AttackRange = enemy.AttackRange, + AvoidEnemyOverlap = enemy.AvoidEnemyOverlap, + EnemyBodyRadius = enemy.EnemyBodyRadius, + SeparationIterations = enemy.SeparationIterations, + TargetType = enemy.TargetType, + State = enemy.State + }; + } + + private static EnemySimData ConvertToEnemySimData(in EnemyJobOutputData enemy) + { + return new EnemySimData + { + EntityId = enemy.EntityId, + Position = enemy.Position, + Forward = enemy.Forward, + Rotation = enemy.Rotation, + Speed = enemy.Speed, + AttackRange = enemy.AttackRange, + AvoidEnemyOverlap = enemy.AvoidEnemyOverlap, + EnemyBodyRadius = enemy.EnemyBodyRadius, + SeparationIterations = enemy.SeparationIterations, + TargetType = enemy.TargetType, + State = enemy.State + }; + } + + private static ProjectileJobInputData ConvertToProjectileJobInput(in ProjectileSimData projectile) + { + return new ProjectileJobInputData + { + EntityId = projectile.EntityId, + OwnerEntityId = projectile.OwnerEntityId, + Position = projectile.Position, + Forward = projectile.Forward, + Speed = projectile.Speed, + RemainingLifetime = projectile.RemainingLifetime, + State = projectile.State + }; + } + + private static ProjectileJobOutputData ConvertToProjectileJobOutput(in ProjectileSimData projectile) + { + return new ProjectileJobOutputData + { + EntityId = projectile.EntityId, + OwnerEntityId = projectile.OwnerEntityId, + Position = projectile.Position, + Forward = projectile.Forward, + Speed = projectile.Speed, + RemainingLifetime = projectile.RemainingLifetime, + State = projectile.State + }; + } + + private static ProjectileSimData ConvertToProjectileSimData(in ProjectileJobOutputData projectile) + { + return new ProjectileSimData + { + EntityId = projectile.EntityId, + OwnerEntityId = projectile.OwnerEntityId, + Position = projectile.Position, + Forward = projectile.Forward, + Speed = projectile.Speed, + RemainingLifetime = projectile.RemainingLifetime, + State = projectile.State + }; + } + } +} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs.meta b/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs.meta new file mode 100644 index 0000000..0922578 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20926de0e6e14c7b818779593f1f87bc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs index 70f5954..b20f92e 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs @@ -78,6 +78,7 @@ namespace Simulation base.Awake(); _entitySync = new EntitySync(this); _presentation = new Presentation(this); + InitializeJobDataChannels(); } private void Start() @@ -90,6 +91,7 @@ namespace Simulation _entitySync?.OnDestroy(); _entitySync = null; _presentation = null; + DisposeJobDataChannels(); } private void LateUpdate() @@ -282,12 +284,10 @@ namespace Simulation if (_useJobSimulation) { - // Checkpoint 1: the switch is in place; Checkpoint 3+ will replace this with jobified path. using (CustomProfilerMarker.TickEnemies.Auto()) { - TickEnemies(in context); + TickEnemiesJobified(in context); } - return; } @@ -304,6 +304,7 @@ namespace Simulation _pickups.Clear(); _enemySeparationAgents.Clear(); _enemyTickWorkItems.Clear(); + ClearJobDataChannels(); EnemyBinding.Clear(); ProjectileBinding.Clear(); diff --git a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs index 444d9be..d6ba655 100644 --- a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs +++ b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs @@ -38,6 +38,9 @@ namespace Simulation.Tests.Editor private static readonly MethodInfo SetUseSimulationMovementMethod = SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance); + private static readonly MethodInfo SetUseJobSimulationMethod = + SimulationWorldType?.GetMethod("SetUseJobSimulation", PublicInstance); + private static readonly MethodInfo UseGridBucketSolverMethod = EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic); @@ -65,6 +68,7 @@ namespace Simulation.Tests.Editor Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed."); Assert.NotNull(TickMethod, "Tick reflection lookup failed."); Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement reflection lookup failed."); + Assert.NotNull(SetUseJobSimulationMethod, "SetUseJobSimulation reflection lookup failed."); Assert.NotNull(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed."); Assert.NotNull(EnemiesProperty, "Enemies property reflection lookup failed."); @@ -143,6 +147,23 @@ namespace Simulation.Tests.Editor Assert.That((int)GetField(GetEnemyAt(1), "EntityId"), Is.EqualTo(2003)); } + [Test] + public void TickEnemies_ChasesPlayer_WhenJobSimulationChannelEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + UpsertEnemy(CreateEnemy(entityId: 1101, position: Vector3.zero, speed: 2f, attackRange: 1f)); + + InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(10f, 0f, 0f)); + + object enemy = GetEnemyAt(0); + Assert.That((int)GetField(enemy, "State"), Is.EqualTo(1)); + Vector3 position = (Vector3)GetField(enemy, "Position"); + Vector3 forward = (Vector3)GetField(enemy, "Forward"); + Assert.That(position.x, Is.EqualTo(2f).Within(0.0001f)); + Assert.That(position.z, Is.EqualTo(0f).Within(0.0001f)); + Assert.That(forward.x, Is.EqualTo(1f).Within(0.0001f)); + } + private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange) { object enemy = System.Activator.CreateInstance(EnemySimDataType); diff --git a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs index 7aeb397..6c8513e 100644 --- a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs +++ b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs @@ -40,6 +40,9 @@ namespace Simulation.Tests.PlayMode private static readonly MethodInfo SetUseSimulationMovementMethod = SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance); + private static readonly MethodInfo SetUseJobSimulationMethod = + SimulationWorldType?.GetMethod("SetUseJobSimulation", PublicInstance); + private static readonly MethodInfo UseGridBucketSolverMethod = EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic); @@ -67,6 +70,7 @@ namespace Simulation.Tests.PlayMode Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed."); Assert.NotNull(TickMethod, "Tick reflection lookup failed."); Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement reflection lookup failed."); + Assert.NotNull(SetUseJobSimulationMethod, "SetUseJobSimulation reflection lookup failed."); Assert.NotNull(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed."); Assert.NotNull(EnemiesProperty, "Enemies property reflection lookup failed."); @@ -155,6 +159,24 @@ namespace Simulation.Tests.PlayMode yield break; } + [UnityTest] + public IEnumerator TickEnemies_ChasesPlayer_WhenJobSimulationChannelEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + UpsertEnemy(CreateEnemy(entityId: 3201, position: Vector3.zero, speed: 2f, attackRange: 1f)); + + InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(10f, 0f, 0f)); + + object enemy = GetEnemyAt(0); + Assert.That((int)GetField(enemy, "State"), Is.EqualTo(1)); + Vector3 position = (Vector3)GetField(enemy, "Position"); + Vector3 forward = (Vector3)GetField(enemy, "Forward"); + Assert.That(position.x, Is.EqualTo(2f).Within(0.0001f)); + Assert.That(position.z, Is.EqualTo(0f).Within(0.0001f)); + Assert.That(forward.x, Is.EqualTo(1f).Within(0.0001f)); + yield break; + } + private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange) { object enemy = System.Activator.CreateInstance(EnemySimDataType); diff --git a/docs/TodoList.md b/docs/TodoList.md index 07e12e9..5d113bf 100644 --- a/docs/TodoList.md +++ b/docs/TodoList.md @@ -123,13 +123,13 @@ - 约束:默认可一键回退到 P1.5 路径,避免全量切换导致定位困难。 - 完成标准:Editor/Development Build 均可编译运行;关闭开关时行为与 P1.5 一致。 -- [ ] Checkpoint 2:Simulation 与 Job 数据通道打通(仅建通道,不改行为) +- [x] Checkpoint 2:Simulation 与 Job 数据通道打通(仅建通道,不改行为) - 为敌人/投射物建立 Job 输入输出结构(纯数据,不含 `Transform`/托管引用)。 - 建立 `SimulationWorld -> NativeContainer -> SimulationWorld` 的拷贝与回写流程。 - 统一生命周期:`Allocator.Persistent` 分配、集中 `Dispose`,避免泄漏。 - 完成标准:战斗循环可稳定运行,且该通道持续帧无新增 GC Alloc 热点。 -- [ ] Checkpoint 3:敌人移动与朝向 Job 化(第一优先) +- [x] Checkpoint 3:敌人移动与朝向 Job 化(第一优先) - 将敌人移动、朝向更新迁移至 `IJobParallelFor`。 - 输入最少包含:`position/forward/speed/targetPosition/deltaTime/state`。 - 输出最少包含:`nextPosition/nextForward/isMoving`。 @@ -241,4 +241,4 @@ ## 测试命令 - PlayMode: `Unity -batchmode -nographics -projectPath . -runTests -testPlatform PlayMode -testResults Logs/playmode-test-results.xml -logFile Logs/playmode-tests.log` -- EditMode: `Unity -batchmode -nographics -projectPath . -runTests -testPlatform EditMode -testResults Logs/editmode-test-results.xml -logFile Logs/editmode-tests.log` \ No newline at end of file +- EditMode: `Unity -batchmode -nographics -projectPath . -runTests -testPlatform EditMode -testResults Logs/editmode-test-results.xml -logFile Logs/editmode-tests.log`