Checkpoint 2 & Checkpoint 3

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 后验证追踪与朝向)
This commit is contained in:
SepComet 2026-02-22 10:47:42 +08:00
parent 76ed9a3e53
commit 34b1424bb6
8 changed files with 663 additions and 6 deletions

View File

@ -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<EnemyJobInputData> Inputs;
public NativeArray<EnemyJobOutputData> 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<EnemyJobInputData> Inputs;
public NativeArray<EnemyJobOutputData> 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<EnemyJobInputData> inputArray = _enemyJobInputs.AsArray();
NativeArray<EnemyJobOutputData> 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<EnemyJobInputData> inputs,
NativeArray<EnemyJobOutputData> 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
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 99b885bb5cfe48e7bd9dba74fe683149
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<EnemyJobInputData> _enemyJobInputs;
private NativeList<EnemyJobOutputData> _enemyJobOutputs;
private NativeList<ProjectileJobInputData> _projectileJobInputs;
private NativeList<ProjectileJobOutputData> _projectileJobOutputs;
private void InitializeJobDataChannels()
{
if (AreJobDataChannelsUsable())
{
return;
}
DisposeJobDataChannels();
_enemyJobInputs = new NativeList<EnemyJobInputData>(64, Allocator.Persistent);
_enemyJobOutputs = new NativeList<EnemyJobOutputData>(64, Allocator.Persistent);
_projectileJobInputs = new NativeList<ProjectileJobInputData>(64, Allocator.Persistent);
_projectileJobOutputs = new NativeList<ProjectileJobOutputData>(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<T>(ref NativeList<T> nativeList, int targetCount) where T : unmanaged
{
if (!nativeList.IsCreated || targetCount <= 0)
{
return;
}
if (nativeList.Capacity < targetCount)
{
nativeList.Capacity = targetCount;
}
}
private static bool IsNativeListUsable<T>(NativeList<T> 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
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 20926de0e6e14c7b818779593f1f87bc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -78,6 +78,7 @@ namespace Simulation
base.Awake(); base.Awake();
_entitySync = new EntitySync(this); _entitySync = new EntitySync(this);
_presentation = new Presentation(this); _presentation = new Presentation(this);
InitializeJobDataChannels();
} }
private void Start() private void Start()
@ -90,6 +91,7 @@ namespace Simulation
_entitySync?.OnDestroy(); _entitySync?.OnDestroy();
_entitySync = null; _entitySync = null;
_presentation = null; _presentation = null;
DisposeJobDataChannels();
} }
private void LateUpdate() private void LateUpdate()
@ -282,12 +284,10 @@ namespace Simulation
if (_useJobSimulation) if (_useJobSimulation)
{ {
// Checkpoint 1: the switch is in place; Checkpoint 3+ will replace this with jobified path.
using (CustomProfilerMarker.TickEnemies.Auto()) using (CustomProfilerMarker.TickEnemies.Auto())
{ {
TickEnemies(in context); TickEnemiesJobified(in context);
} }
return; return;
} }
@ -304,6 +304,7 @@ namespace Simulation
_pickups.Clear(); _pickups.Clear();
_enemySeparationAgents.Clear(); _enemySeparationAgents.Clear();
_enemyTickWorkItems.Clear(); _enemyTickWorkItems.Clear();
ClearJobDataChannels();
EnemyBinding.Clear(); EnemyBinding.Clear();
ProjectileBinding.Clear(); ProjectileBinding.Clear();

View File

@ -38,6 +38,9 @@ namespace Simulation.Tests.Editor
private static readonly MethodInfo SetUseSimulationMovementMethod = private static readonly MethodInfo SetUseSimulationMovementMethod =
SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance); SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance);
private static readonly MethodInfo SetUseJobSimulationMethod =
SimulationWorldType?.GetMethod("SetUseJobSimulation", PublicInstance);
private static readonly MethodInfo UseGridBucketSolverMethod = private static readonly MethodInfo UseGridBucketSolverMethod =
EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic); EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic);
@ -65,6 +68,7 @@ namespace Simulation.Tests.Editor
Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed."); Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed.");
Assert.NotNull(TickMethod, "Tick reflection lookup failed."); Assert.NotNull(TickMethod, "Tick reflection lookup failed.");
Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement 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(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed.");
Assert.NotNull(EnemiesProperty, "Enemies property 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)); 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) private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange)
{ {
object enemy = System.Activator.CreateInstance(EnemySimDataType); object enemy = System.Activator.CreateInstance(EnemySimDataType);

View File

@ -40,6 +40,9 @@ namespace Simulation.Tests.PlayMode
private static readonly MethodInfo SetUseSimulationMovementMethod = private static readonly MethodInfo SetUseSimulationMovementMethod =
SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance); SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance);
private static readonly MethodInfo SetUseJobSimulationMethod =
SimulationWorldType?.GetMethod("SetUseJobSimulation", PublicInstance);
private static readonly MethodInfo UseGridBucketSolverMethod = private static readonly MethodInfo UseGridBucketSolverMethod =
EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic); EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic);
@ -67,6 +70,7 @@ namespace Simulation.Tests.PlayMode
Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed."); Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed.");
Assert.NotNull(TickMethod, "Tick reflection lookup failed."); Assert.NotNull(TickMethod, "Tick reflection lookup failed.");
Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement 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(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed.");
Assert.NotNull(EnemiesProperty, "Enemies property reflection lookup failed."); Assert.NotNull(EnemiesProperty, "Enemies property reflection lookup failed.");
@ -155,6 +159,24 @@ namespace Simulation.Tests.PlayMode
yield break; 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) private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange)
{ {
object enemy = System.Activator.CreateInstance(EnemySimDataType); object enemy = System.Activator.CreateInstance(EnemySimDataType);

View File

@ -123,13 +123,13 @@
- 约束:默认可一键回退到 P1.5 路径,避免全量切换导致定位困难。 - 约束:默认可一键回退到 P1.5 路径,避免全量切换导致定位困难。
- 完成标准Editor/Development Build 均可编译运行;关闭开关时行为与 P1.5 一致。 - 完成标准Editor/Development Build 均可编译运行;关闭开关时行为与 P1.5 一致。
- [ ] Checkpoint 2Simulation 与 Job 数据通道打通(仅建通道,不改行为) - [x] Checkpoint 2Simulation 与 Job 数据通道打通(仅建通道,不改行为)
- 为敌人/投射物建立 Job 输入输出结构(纯数据,不含 `Transform`/托管引用)。 - 为敌人/投射物建立 Job 输入输出结构(纯数据,不含 `Transform`/托管引用)。
- 建立 `SimulationWorld -> NativeContainer -> SimulationWorld` 的拷贝与回写流程。 - 建立 `SimulationWorld -> NativeContainer -> SimulationWorld` 的拷贝与回写流程。
- 统一生命周期:`Allocator.Persistent` 分配、集中 `Dispose`,避免泄漏。 - 统一生命周期:`Allocator.Persistent` 分配、集中 `Dispose`,避免泄漏。
- 完成标准:战斗循环可稳定运行,且该通道持续帧无新增 GC Alloc 热点。 - 完成标准:战斗循环可稳定运行,且该通道持续帧无新增 GC Alloc 热点。
- [ ] Checkpoint 3敌人移动与朝向 Job 化(第一优先) - [x] Checkpoint 3敌人移动与朝向 Job 化(第一优先)
- 将敌人移动、朝向更新迁移至 `IJobParallelFor` - 将敌人移动、朝向更新迁移至 `IJobParallelFor`
- 输入最少包含:`position/forward/speed/targetPosition/deltaTime/state`。 - 输入最少包含:`position/forward/speed/targetPosition/deltaTime/state`。
- 输出最少包含:`nextPosition/nextForward/isMoving`。 - 输出最少包含:`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` - 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` - EditMode: `Unity -batchmode -nographics -projectPath . -runTests -testPlatform EditMode -testResults Logs/editmode-test-results.xml -logFile Logs/editmode-tests.log`