From 5fb7ea499f621aa1da317910eb0c2f68321ef833 Mon Sep 17 00:00:00 2001 From: SepComet <202308010230@stu.csust.edu.cn> Date: Sun, 22 Feb 2026 14:11:53 +0800 Subject: [PATCH] Checkpoint 3 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复将互斥算法由同步转成异步产生的抖动问题 --- Assets/GameMain/Entities/MeleeEnemy.prefab | 2 +- .../Scripts/Components/MovementComponent.cs | 1 - .../EnemyManager/EnemyManagerComponent.cs | 33 ++ .../Scripts/Debugger/ProfilerMarker.cs | 2 + .../TargetSelector/NearestTargetSelector.cs | 36 ++ .../Simulation/SimulationWorld.EnemyJobs.cs | 404 ++++++++++++++++-- .../SimulationWorld.JobDataChannel.cs | 175 +++++++- ...lationWorld.TargetSelectionSpatialIndex.cs | 169 ++++++++ ...nWorld.TargetSelectionSpatialIndex.cs.meta | 11 + .../Scripts/Simulation/SimulationWorld.cs | 28 +- Assets/Launcher.unity | 11 + .../EditMode/SimulationWorldTickTests.cs | 75 +++- .../PlayMode/SimulationWorldPlayModeTests.cs | 78 +++- docs/TodoList.md | 2 +- 14 files changed, 983 insertions(+), 44 deletions(-) create mode 100644 Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs create mode 100644 Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs.meta diff --git a/Assets/GameMain/Entities/MeleeEnemy.prefab b/Assets/GameMain/Entities/MeleeEnemy.prefab index 6099332..c34424c 100644 --- a/Assets/GameMain/Entities/MeleeEnemy.prefab +++ b/Assets/GameMain/Entities/MeleeEnemy.prefab @@ -154,7 +154,7 @@ MonoBehaviour: _cachedTransform: {fileID: 7683855655592166216} _avoidEnemyOverlap: 0 _enemyBodyRadius: 0.45 - _separationIterations: 2 + _separationIterations: 5 _speedBase: 0 --- !u!114 &6353753365317756414 MonoBehaviour: diff --git a/Assets/GameMain/Scripts/Components/MovementComponent.cs b/Assets/GameMain/Scripts/Components/MovementComponent.cs index 2fc3696..3052135 100644 --- a/Assets/GameMain/Scripts/Components/MovementComponent.cs +++ b/Assets/GameMain/Scripts/Components/MovementComponent.cs @@ -2,7 +2,6 @@ using System; using CustomUtility; using Definition.DataStruct; using Definition.Enum; -using Unity.Profiling; using UnityEngine; using CustomDebugger; diff --git a/Assets/GameMain/Scripts/CustomComponent/EnemyManager/EnemyManagerComponent.cs b/Assets/GameMain/Scripts/CustomComponent/EnemyManager/EnemyManagerComponent.cs index f954cf8..37fb71e 100644 --- a/Assets/GameMain/Scripts/CustomComponent/EnemyManager/EnemyManagerComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/EnemyManager/EnemyManagerComponent.cs @@ -20,6 +20,7 @@ namespace CustomComponent private EntityComponent _entity; private List _enemies; + private Dictionary _enemyById; public List Enemies => _enemies; @@ -58,6 +59,7 @@ namespace CustomComponent { _entity = GameEntry.Entity; _enemies = new List(); + _enemyById = new Dictionary(); GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess); GameEntry.Event.Subscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete); @@ -69,6 +71,7 @@ namespace CustomComponent GameEntry.Event.Unsubscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete); _enemies = null; + _enemyById = null; _entity = null; } @@ -157,6 +160,25 @@ namespace CustomComponent } _enemies.Clear(); + _enemyById?.Clear(); + } + + public bool TryGetEnemy(int entityId, out EntityBase enemy) + { + enemy = null; + if (_enemyById == null || !_enemyById.TryGetValue(entityId, out EntityBase cachedEnemy)) + { + return false; + } + + if (cachedEnemy == null || !cachedEnemy.Available) + { + _enemyById.Remove(entityId); + return false; + } + + enemy = cachedEnemy; + return true; } public void SetSpawnRateScale(float scale) @@ -218,6 +240,7 @@ namespace CustomComponent enemy.SetTarget(_player); RemoveEnemyFromCache(enemy.Id); _enemies.Add(enemy); + _enemyById[enemy.Id] = enemy; } if (ne.EntityLogicType == typeof(Player)) @@ -245,11 +268,21 @@ namespace CustomComponent private void RemoveEnemyFromCache(int entityId) { + if (_enemyById != null) + { + _enemyById.Remove(entityId); + } + for (int i = _enemies.Count - 1; i >= 0; i--) { EntityBase cachedEnemy = _enemies[i]; if (cachedEnemy == null || cachedEnemy.Id == entityId) { + if (cachedEnemy != null && _enemyById != null) + { + _enemyById.Remove(cachedEnemy.Id); + } + _enemies.RemoveAt(i); } } diff --git a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs index a9445ca..559dd3c 100644 --- a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs +++ b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs @@ -9,6 +9,8 @@ namespace CustomDebugger public static readonly ProfilerMarker TickEnemies_MoveSeparation = new ProfilerMarker("TickEnemies.MoveSeparation"); public static readonly ProfilerMarker TickEnemies_StateUpdate = new ProfilerMarker("TickEnemies.StateUpdate"); public static readonly ProfilerMarker TickEnemies_WriteBack = new ProfilerMarker("TickEnemies.WriteBack"); + public static readonly ProfilerMarker TargetSelection_BuildBuckets = new("TargetSelection.BuildBuckets"); + public static readonly ProfilerMarker TargetSelection_QueryNeighbors = new("TargetSelection.QueryNeighbors"); public static readonly ProfilerMarker Movement_Update = new ProfilerMarker("Movement_Update"); public static readonly ProfilerMarker ShopUI_Update = new("UGF.ShopUI.Update"); public static readonly ProfilerMarker Inventory_Refresh = new("UGF.Inventory.Refresh"); diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs index a5bd9aa..5ff9dcb 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using CustomUtility; +using CustomComponent; namespace Entity.Weapon { @@ -12,6 +13,11 @@ namespace Entity.Weapon return null; } + if (TrySelectFromSpatialIndex(weapon, maxSqrRange, out EntityBase indexedTarget)) + { + return indexedTarget; + } + EntityBase target = null; float minSqrMagnitude = maxSqrRange > 0f ? maxSqrRange : float.MaxValue; @@ -28,5 +34,35 @@ namespace Entity.Weapon return target; } + + private static bool TrySelectFromSpatialIndex(WeaponBase weapon, float maxSqrRange, out EntityBase target) + { + target = null; + if (weapon == null || maxSqrRange <= 0f || weapon.CachedTransform == null) + { + return false; + } + + var simulationWorld = GameEntry.SimulationWorld; + if (simulationWorld == null || !simulationWorld.UseSimulationMovement || !simulationWorld.UseJobSimulation) + { + return false; + } + + if (!simulationWorld.TryGetNearestEnemyEntityId(weapon.CachedTransform.position, maxSqrRange, + out int entityId)) + { + return false; + } + + EnemyManagerComponent enemyManager = GameEntry.EnemyManager; + if (enemyManager == null || !enemyManager.TryGetEnemy(entityId, out EntityBase enemy)) + { + return false; + } + + target = enemy; + return true; + } } } diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs index d2d827a..e08cf6b 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs @@ -1,5 +1,4 @@ -using CustomDebugger; -using CustomUtility; +using CustomDebugger; using Unity.Burst; using Unity.Collections; using Unity.Jobs; @@ -10,6 +9,21 @@ namespace Simulation { public sealed partial class SimulationWorld { + [Header("敌人互斥参数")] [Tooltip("敌人互斥分桶使用的网格尺寸。小于等于 0 时,将根据敌人体积半径自动计算。")] [SerializeField] + private float _enemySeparationCellSize = 0f; + + [Tooltip("每次迭代对互斥推力累积值的阻尼系数。数值越大,分离速度越快。")] [SerializeField] + private float _enemySeparationPushDamping = 0.75f; + + [Tooltip("每次迭代允许的最大互斥位移步长(按敌人体积半径倍率计算)。")] [SerializeField] + private float _enemySeparationMaxStepScale = 1f; + + [Tooltip("敌人进入攻击范围后,互斥位移是否保持为相对玩家方向的切向分量(避免被径向推离玩家)。")] [SerializeField] + private bool _enemySeparationUseTangentialInAttackRange = true; + + [Tooltip("互斥推力方向突变时的时间平滑系数。越大越稳定,但响应越慢。")] [SerializeField] + private float _enemySeparationPushSmoothing = 0.55f; + [BurstCompile] private struct EnemyMovementBurstJob : IJobParallelFor { @@ -37,6 +51,78 @@ namespace Simulation } } + [BurstCompile] + private struct BuildEnemySeparationBucketsBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + public NativeParallelMultiHashMap.ParallelWriter Buckets; + public float CellSize; + + public void Execute(int index) + { + BuildEnemySeparationBucket(index, Inputs, Buckets, CellSize); + } + } + + private struct BuildEnemySeparationBucketsJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + public NativeParallelMultiHashMap.ParallelWriter Buckets; + public float CellSize; + + public void Execute(int index) + { + BuildEnemySeparationBucket(index, Inputs, Buckets, CellSize); + } + } + + [BurstCompile] + private struct EnemySeparationBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + [ReadOnly] public NativeParallelMultiHashMap Buckets; + [ReadOnly] public NativeArray PreviousPushes; + public NativeArray Outputs; + public NativeArray CurrentPushes; + public float CellSize; + public float MaxRadius; + public float3 PlayerPosition; + public float PushDamping; + public float MaxStepScale; + public bool UseTangentialInAttackRange; + public float PushSmoothing; + + public void Execute(int index) + { + ExecuteEnemySeparation(index, Inputs, Buckets, Outputs, CellSize, MaxRadius, PlayerPosition, + PushDamping, MaxStepScale, UseTangentialInAttackRange, PreviousPushes, CurrentPushes, + PushSmoothing); + } + } + + private struct EnemySeparationJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + [ReadOnly] public NativeParallelMultiHashMap Buckets; + [ReadOnly] public NativeArray PreviousPushes; + public NativeArray Outputs; + public NativeArray CurrentPushes; + public float CellSize; + public float MaxRadius; + public float3 PlayerPosition; + public float PushDamping; + public float MaxStepScale; + public bool UseTangentialInAttackRange; + public float PushSmoothing; + + public void Execute(int index) + { + ExecuteEnemySeparation(index, Inputs, Buckets, Outputs, CellSize, MaxRadius, PlayerPosition, + PushDamping, MaxStepScale, UseTangentialInAttackRange, PreviousPushes, CurrentPushes, + PushSmoothing); + } + } + private void TickEnemiesJobified(in SimulationTickContext context) { using (CustomProfilerMarker.TickEnemies_BuildInput.Auto()) @@ -51,7 +137,7 @@ namespace Simulation using (CustomProfilerMarker.TickEnemies_MoveSeparation.Auto()) { - ApplyEnemySeparationForJobOutput(); + ApplyEnemySeparationForJobOutput(in context); } using (CustomProfilerMarker.TickEnemies_WriteBack.Auto()) @@ -59,6 +145,9 @@ namespace Simulation SyncProjectilesToJobOutput(); ApplyJobOutputToSimulation(); } + + MarkEnemyTargetSpatialIndexDirty(); + BuildEnemyTargetSpatialIndexIfNeeded(); } private void ExecuteEnemyMovementJob(in SimulationTickContext context) @@ -130,15 +219,17 @@ namespace Simulation } } - private void ApplyEnemySeparationForJobOutput() + private void ApplyEnemySeparationForJobOutput(in SimulationTickContext context) { - if (_enemyJobOutputs.Length == 0) + int enemyCount = _enemyJobOutputs.Length; + if (enemyCount == 0) { return; } - _enemySeparationAgents.Clear(); - for (int i = 0; i < _enemyJobOutputs.Length; i++) + bool hasSeparationCandidates = false; + float maxRadius = 0.45f; + for (int i = 0; i < enemyCount; i++) { EnemyJobOutputData output = _enemyJobOutputs[i]; if (!output.AvoidEnemyOverlap) @@ -146,39 +237,295 @@ namespace Simulation continue; } - Vector3 position = output.Position; - position.y = 0f; - _enemySeparationAgents.Add(new EnemySeparationAgent + hasSeparationCandidates = true; + float radius = output.EnemyBodyRadius > 0f ? output.EnemyBodyRadius : 0.45f; + if (radius > maxRadius) { - AgentId = output.EntityId, - Position = position, - Radius = output.EnemyBodyRadius > 0f ? output.EnemyBodyRadius : 0.45f - }); + maxRadius = radius; + } } - if (_enemySeparationAgents.Count == 0) + if (!hasSeparationCandidates) { return; } - EnemySeparationSolverProvider.SetSimulationAgents(_enemySeparationAgents); - for (int i = 0; i < _enemyJobOutputs.Length; i++) + float autoCellSize = maxRadius * 2f; + float configuredCellSize = _enemySeparationCellSize > 0f ? _enemySeparationCellSize : autoCellSize; + float cellSize = Mathf.Max(0.1f, configuredCellSize); + int bucketCapacity = Mathf.Max(128, enemyCount * 2); + PrepareEnemySeparationJobBuffers(enemyCount, bucketCapacity); + float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z); + float pushDamping = Mathf.Clamp(_enemySeparationPushDamping, 0f, 2f); + float maxStepScale = Mathf.Max(0.1f, _enemySeparationMaxStepScale); + bool useTangentialInAttackRange = _enemySeparationUseTangentialInAttackRange; + float pushSmoothing = Mathf.Clamp01(_enemySeparationPushSmoothing); + + NativeArray inputArray = _enemyJobOutputs.AsArray(); + NativeArray separatedOutputArray = _enemyJobSeparationOutputs.AsArray(); + NativeArray previousPushes = _enemySeparationPreviousPushes.AsArray(); + NativeArray currentPushes = _enemySeparationCurrentPushes.AsArray(); + JobHandle buildHandle; + + if (_useBurstJobs) { - EnemyJobOutputData output = _enemyJobOutputs[i]; - if (!output.AvoidEnemyOverlap || output.State != EnemyStateChasing) + BuildEnemySeparationBucketsBurstJob buildJob = new BuildEnemySeparationBucketsBurstJob + { + Inputs = inputArray, + Buckets = _enemySeparationBuckets.AsParallelWriter(), + CellSize = cellSize + }; + buildHandle = buildJob.Schedule(enemyCount, 64); + } + else + { + BuildEnemySeparationBucketsJob buildJob = new BuildEnemySeparationBucketsJob + { + Inputs = inputArray, + Buckets = _enemySeparationBuckets.AsParallelWriter(), + CellSize = cellSize + }; + buildHandle = buildJob.Schedule(enemyCount, 64); + } + + JobHandle separationHandle; + if (_useBurstJobs) + { + EnemySeparationBurstJob separationJob = new EnemySeparationBurstJob + { + Inputs = inputArray, + Buckets = _enemySeparationBuckets, + PreviousPushes = previousPushes, + Outputs = separatedOutputArray, + CurrentPushes = currentPushes, + CellSize = cellSize, + MaxRadius = maxRadius, + PlayerPosition = playerPosition, + PushDamping = pushDamping, + MaxStepScale = maxStepScale, + UseTangentialInAttackRange = useTangentialInAttackRange, + PushSmoothing = pushSmoothing + }; + separationHandle = separationJob.Schedule(enemyCount, 64, buildHandle); + } + else + { + EnemySeparationJob separationJob = new EnemySeparationJob + { + Inputs = inputArray, + Buckets = _enemySeparationBuckets, + PreviousPushes = previousPushes, + Outputs = separatedOutputArray, + CurrentPushes = currentPushes, + CellSize = cellSize, + MaxRadius = maxRadius, + PlayerPosition = playerPosition, + PushDamping = pushDamping, + MaxStepScale = maxStepScale, + UseTangentialInAttackRange = useTangentialInAttackRange, + PushSmoothing = pushSmoothing + }; + separationHandle = separationJob.Schedule(enemyCount, 64, buildHandle); + } + + separationHandle.Complete(); + CommitEnemySeparationTemporalBuffers(enemyCount); + for (int i = 0; i < enemyCount; i++) + { + _enemyJobOutputs[i] = _enemyJobSeparationOutputs[i]; + } + } + + private static void BuildEnemySeparationBucket(int index, NativeArray inputs, + NativeParallelMultiHashMap.ParallelWriter buckets, float cellSize) + { + EnemyJobOutputData output = inputs[index]; + if (!output.AvoidEnemyOverlap) + { + return; + } + + float3 position = new float3(output.Position.x, 0f, output.Position.z); + int cellX = (int)math.floor(position.x / cellSize); + int cellZ = (int)math.floor(position.z / cellSize); + buckets.Add(SeparationCellKey(cellX, cellZ), index); + } + + private static void ExecuteEnemySeparation(int index, NativeArray inputs, + NativeParallelMultiHashMap buckets, NativeArray outputs, + float cellSize, float maxRadius, float3 playerPosition, float pushDamping, float maxStepScale, + bool useTangentialInAttackRange, NativeArray previousPushes, + NativeArray currentPushes, float pushSmoothing) + { + currentPushes[index] = float2.zero; + EnemyJobOutputData self = inputs[index]; + if (!self.AvoidEnemyOverlap) + { + outputs[index] = self; + return; + } + + float3 candidate = new float3(self.Position.x, 0f, self.Position.z); + float3 original = candidate; + float3 fallback = + math.normalizesafe(new float3(self.Forward.x, 0f, self.Forward.z), new float3(1f, 0f, 0f)); + float selfRadius = self.EnemyBodyRadius > 0f ? self.EnemyBodyRadius : 0.45f; + int iterations = self.SeparationIterations > 0 ? self.SeparationIterations : 1; + int queryRange = math.max(1, (int)math.ceil((selfRadius + maxRadius) / cellSize)); + + for (int iter = 0; iter < iterations; iter++) + { + int cellX = (int)math.floor(candidate.x / cellSize); + int cellZ = (int)math.floor(candidate.z / cellSize); + float3 pushAccumulation = float3.zero; + + for (int dx = -queryRange; dx <= queryRange; dx++) + { + for (int dz = -queryRange; dz <= queryRange; dz++) + { + long key = SeparationCellKey(cellX + dx, cellZ + dz); + if (!buckets.TryGetFirstValue(key, out int otherIndex, + out NativeParallelMultiHashMapIterator iterator)) + { + continue; + } + + do + { + if (otherIndex == index) + { + continue; + } + + EnemyJobOutputData other = inputs[otherIndex]; + if (!other.AvoidEnemyOverlap) + { + continue; + } + + float otherRadius = other.EnemyBodyRadius > 0f ? other.EnemyBodyRadius : 0.45f; + float minDistance = selfRadius + otherRadius; + float minDistanceSqr = minDistance * minDistance; + + float3 otherPosition = new float3(other.Position.x, 0f, other.Position.z); + float3 toSelf = candidate - otherPosition; + float sqrDistance = math.lengthsq(toSelf); + + if (sqrDistance <= float.Epsilon) + { + continue; + } + + if (sqrDistance >= minDistanceSqr) + { + continue; + } + + float distance = math.sqrt(sqrDistance); + float penetration = minDistance - distance; + pushAccumulation += (toSelf / distance) * penetration; + } while (buckets.TryGetNextValue(out otherIndex, ref iterator)); + } + } + + if (math.lengthsq(pushAccumulation) <= float.Epsilon) { continue; } - Vector3 resolvedPosition = EnemySeparationSolverProvider.ResolveSimulation( - output.EntityId, - output.Position, - output.Forward, - output.SeparationIterations > 0 ? output.SeparationIterations : 1); + float3 resolvedPush = pushAccumulation * pushDamping; - output.Position = resolvedPosition; - _enemyJobOutputs[i] = output; + float maxStep = selfRadius * maxStepScale; + float pushLength = math.length(resolvedPush); + if (pushLength > maxStep && pushLength > float.Epsilon) + { + resolvedPush = resolvedPush / pushLength * maxStep; + } + + candidate += resolvedPush; } + + float3 framePush = candidate - original; + float2 previousPush2 = previousPushes[index]; + float3 previousPush = new float3(previousPush2.x, 0f, previousPush2.y); + float3 smoothedPush = SmoothSeparationPush(framePush, previousPush, pushSmoothing); + + if (useTangentialInAttackRange && self.State == EnemyStateInAttackRange) + { + smoothedPush = ProjectToTangential(smoothedPush, playerPosition, original); + } + + float maxTotalStep = selfRadius * maxStepScale * iterations; + float smoothedLength = math.length(smoothedPush); + if (smoothedLength > maxTotalStep && smoothedLength > float.Epsilon) + { + smoothedPush = smoothedPush / smoothedLength * maxTotalStep; + } + + float3 finalPosition = original + smoothedPush; + currentPushes[index] = new float2(smoothedPush.x, smoothedPush.z); + self.Position = new Vector3(finalPosition.x, self.Position.y, finalPosition.z); + if (math.lengthsq(smoothedPush) > float.Epsilon) + { + self.Forward = new Vector3(fallback.x, self.Forward.y, fallback.z); + } + + outputs[index] = self; + } + + private static float3 SmoothSeparationPush(float3 framePush, float3 previousPush, float pushSmoothing) + { + float frameLengthSqr = math.lengthsq(framePush); + float previousLengthSqr = math.lengthsq(previousPush); + + if (frameLengthSqr <= float.Epsilon) + { + return float3.zero; + } + + if (previousLengthSqr <= float.Epsilon || pushSmoothing <= 0f) + { + return framePush; + } + + float frameLength = math.sqrt(frameLengthSqr); + float previousLength = math.sqrt(previousLengthSqr); + float3 frameDirection = framePush / frameLength; + float3 previousDirection = previousPush / previousLength; + float directionAlignment = math.dot(frameDirection, previousDirection); + + if (directionAlignment >= 0.35f) + { + return framePush; + } + + float directionalFactor = math.saturate((0.35f - directionAlignment) / 1.35f); + float smoothingStrength = pushSmoothing * directionalFactor; + return math.lerp(framePush, previousPush, smoothingStrength); + } + + private static float3 ProjectToTangential(float3 push, float3 playerPosition, float3 currentPosition) + { + if (math.lengthsq(push) <= float.Epsilon) + { + return push; + } + + float3 toPlayer = playerPosition - currentPosition; + float toPlayerSqr = math.lengthsq(toPlayer); + if (toPlayerSqr <= float.Epsilon) + { + return push; + } + + float3 radialDirection = toPlayer / math.sqrt(toPlayerSqr); + float radialOffset = math.dot(push, radialDirection); + return push - radialDirection * radialOffset; + } + + private static long SeparationCellKey(int x, int z) + { + return ((long)x << 32) ^ (uint)z; } private static void ExecuteEnemyMovement(int index, NativeArray inputs, @@ -197,7 +544,8 @@ namespace Simulation 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); + quaternion rotation = + new quaternion(input.Rotation.x, input.Rotation.y, input.Rotation.z, input.Rotation.w); if (canChase) { @@ -239,4 +587,4 @@ namespace Simulation }; } } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs index 11766cf..3627bcf 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs @@ -1,5 +1,6 @@ using System; using Unity.Collections; +using Unity.Mathematics; using UnityEngine; namespace Simulation @@ -60,8 +61,12 @@ namespace Simulation private NativeList _enemyJobInputs; private NativeList _enemyJobOutputs; + private NativeList _enemyJobSeparationOutputs; + private NativeList _enemySeparationPreviousPushes; + private NativeList _enemySeparationCurrentPushes; private NativeList _projectileJobInputs; private NativeList _projectileJobOutputs; + private NativeParallelMultiHashMap _enemySeparationBuckets; private void InitializeJobDataChannels() { @@ -73,8 +78,13 @@ namespace Simulation DisposeJobDataChannels(); _enemyJobInputs = new NativeList(64, Allocator.Persistent); _enemyJobOutputs = new NativeList(64, Allocator.Persistent); + _enemyJobSeparationOutputs = new NativeList(64, Allocator.Persistent); + _enemySeparationPreviousPushes = new NativeList(64, Allocator.Persistent); + _enemySeparationCurrentPushes = new NativeList(64, Allocator.Persistent); _projectileJobInputs = new NativeList(64, Allocator.Persistent); _projectileJobOutputs = new NativeList(64, Allocator.Persistent); + _enemySeparationBuckets = new NativeParallelMultiHashMap(256, Allocator.Persistent); + InitializeEnemyTargetSpatialIndex(); } private void DisposeJobDataChannels() @@ -91,6 +101,24 @@ namespace Simulation } _enemyJobOutputs = default; + if (_enemyJobSeparationOutputs.IsCreated) + { + _enemyJobSeparationOutputs.Dispose(); + } + _enemyJobSeparationOutputs = default; + + if (_enemySeparationPreviousPushes.IsCreated) + { + _enemySeparationPreviousPushes.Dispose(); + } + _enemySeparationPreviousPushes = default; + + if (_enemySeparationCurrentPushes.IsCreated) + { + _enemySeparationCurrentPushes.Dispose(); + } + _enemySeparationCurrentPushes = default; + if (_projectileJobInputs.IsCreated) { _projectileJobInputs.Dispose(); @@ -102,6 +130,14 @@ namespace Simulation _projectileJobOutputs.Dispose(); } _projectileJobOutputs = default; + + if (_enemySeparationBuckets.IsCreated) + { + _enemySeparationBuckets.Dispose(); + } + _enemySeparationBuckets = default; + + DisposeEnemyTargetSpatialIndex(); } private void ClearJobDataChannels() @@ -130,6 +166,28 @@ namespace Simulation { _projectileJobOutputs.Clear(); } + + if (_enemyJobSeparationOutputs.IsCreated) + { + _enemyJobSeparationOutputs.Clear(); + } + + if (_enemySeparationPreviousPushes.IsCreated) + { + _enemySeparationPreviousPushes.Clear(); + } + + if (_enemySeparationCurrentPushes.IsCreated) + { + _enemySeparationCurrentPushes.Clear(); + } + + if (_enemySeparationBuckets.IsCreated) + { + _enemySeparationBuckets.Clear(); + } + + ClearEnemyTargetSpatialIndex(); } private void SyncSimulationToJobInput() @@ -196,6 +254,86 @@ namespace Simulation } } + private void PrepareEnemySeparationJobBuffers(int enemyCount, int bucketCapacity) + { + InitializeJobDataChannels(); + EnsureCapacity(ref _enemyJobSeparationOutputs, enemyCount); + _enemyJobSeparationOutputs.Clear(); + if (enemyCount > 0) + { + _enemyJobSeparationOutputs.ResizeUninitialized(enemyCount); + } + + EnsureCapacity(ref _enemySeparationBuckets, bucketCapacity); + _enemySeparationBuckets.Clear(); + + EnsureCapacity(ref _enemySeparationPreviousPushes, enemyCount); + EnsureCapacity(ref _enemySeparationCurrentPushes, enemyCount); + + if (_enemySeparationPreviousPushes.Length < enemyCount) + { + int oldLength = _enemySeparationPreviousPushes.Length; + _enemySeparationPreviousPushes.ResizeUninitialized(enemyCount); + for (int i = oldLength; i < enemyCount; i++) + { + _enemySeparationPreviousPushes[i] = float2.zero; + } + } + else if (_enemySeparationPreviousPushes.Length > enemyCount) + { + _enemySeparationPreviousPushes.ResizeUninitialized(enemyCount); + } + + _enemySeparationCurrentPushes.Clear(); + if (enemyCount > 0) + { + _enemySeparationCurrentPushes.ResizeUninitialized(enemyCount); + } + } + + private void CommitEnemySeparationTemporalBuffers(int enemyCount) + { + if (!_enemySeparationPreviousPushes.IsCreated || !_enemySeparationCurrentPushes.IsCreated) + { + return; + } + + int copyCount = math.min(enemyCount, + math.min(_enemySeparationPreviousPushes.Length, _enemySeparationCurrentPushes.Length)); + for (int i = 0; i < copyCount; i++) + { + _enemySeparationPreviousPushes[i] = _enemySeparationCurrentPushes[i]; + } + } + + private void OnEnemyAddedToSeparationTemporalBuffers() + { + if (_enemySeparationPreviousPushes.IsCreated) + { + _enemySeparationPreviousPushes.Add(float2.zero); + } + + if (_enemySeparationCurrentPushes.IsCreated) + { + _enemySeparationCurrentPushes.Add(float2.zero); + } + } + + private void OnEnemyRemovedFromSeparationTemporalBuffers(int removedIndex) + { + if (_enemySeparationPreviousPushes.IsCreated && removedIndex >= 0 && + removedIndex < _enemySeparationPreviousPushes.Length) + { + _enemySeparationPreviousPushes.RemoveAtSwapBack(removedIndex); + } + + if (_enemySeparationCurrentPushes.IsCreated && removedIndex >= 0 && + removedIndex < _enemySeparationCurrentPushes.Length) + { + _enemySeparationCurrentPushes.RemoveAtSwapBack(removedIndex); + } + } + private void ApplyJobOutputToSimulation() { int enemyCount = Mathf.Min(_enemies.Count, _enemyJobOutputs.Length); @@ -215,8 +353,12 @@ namespace Simulation { return IsNativeListUsable(_enemyJobInputs) && IsNativeListUsable(_enemyJobOutputs) && + IsNativeListUsable(_enemyJobSeparationOutputs) && + IsNativeListUsable(_enemySeparationPreviousPushes) && + IsNativeListUsable(_enemySeparationCurrentPushes) && IsNativeListUsable(_projectileJobInputs) && - IsNativeListUsable(_projectileJobOutputs); + IsNativeListUsable(_projectileJobOutputs) && + IsNativeMultiHashMapUsable(_enemySeparationBuckets); } private static void EnsureCapacity(ref NativeList nativeList, int targetCount) where T : unmanaged @@ -250,6 +392,24 @@ namespace Simulation } } + private static bool IsNativeMultiHashMapUsable(NativeParallelMultiHashMap hashMap) + { + if (!hashMap.IsCreated) + { + return false; + } + + try + { + _ = hashMap.Count(); + return true; + } + catch (ObjectDisposedException) + { + return false; + } + } + private static EnemyJobInputData ConvertToEnemyJobInput(in EnemySimData enemy) { return new EnemyJobInputData @@ -345,5 +505,18 @@ namespace Simulation State = projectile.State }; } + + private static void EnsureCapacity(ref NativeParallelMultiHashMap hashMap, int targetCount) + { + if (!hashMap.IsCreated || targetCount <= 0) + { + return; + } + + if (hashMap.Capacity < targetCount) + { + hashMap.Capacity = targetCount; + } + } } } diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs new file mode 100644 index 0000000..23c39dd --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs @@ -0,0 +1,169 @@ +using CustomDebugger; +using Unity.Collections; +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private NativeParallelMultiHashMap _enemyTargetBuckets; + private bool _enemyTargetBucketsDirty = true; + private int _enemyTargetBucketsFrame = -1; + + [SerializeField] private float _targetSelectionCellSize = 2f; + + public bool TryGetNearestEnemyEntityId(Vector3 origin, float maxSqrRange, out int enemyEntityId) + { + enemyEntityId = 0; + if (maxSqrRange <= 0f || _enemies.Count == 0) + { + return false; + } + + if (!_useSimulationMovement || !_useJobSimulation) + { + return false; + } + + BuildEnemyTargetSpatialIndexIfNeeded(); + + float cellSize = GetTargetSelectionCellSize(); + int centerCellX = ToCell(origin.x, cellSize); + int centerCellZ = ToCell(origin.z, cellSize); + float range = Mathf.Sqrt(maxSqrRange); + int queryRange = Mathf.Max(1, Mathf.CeilToInt(range / cellSize)); + + float minSqrDistance = maxSqrRange; + bool found = false; + + using (CustomProfilerMarker.TargetSelection_QueryNeighbors.Auto()) + { + for (int dx = -queryRange; dx <= queryRange; dx++) + { + for (int dz = -queryRange; dz <= queryRange; dz++) + { + long key = CellKey(centerCellX + dx, centerCellZ + dz); + if (!_enemyTargetBuckets.TryGetFirstValue(key, out int enemyIndex, + out NativeParallelMultiHashMapIterator iterator)) + { + continue; + } + + do + { + if (enemyIndex < 0 || enemyIndex >= _enemies.Count) + { + continue; + } + + EnemySimData enemy = _enemies[enemyIndex]; + Vector3 delta = enemy.Position - origin; + delta.y = 0f; + float sqrDistance = delta.sqrMagnitude; + if (sqrDistance >= minSqrDistance) + { + continue; + } + + minSqrDistance = sqrDistance; + enemyEntityId = enemy.EntityId; + found = true; + } while (_enemyTargetBuckets.TryGetNextValue(out enemyIndex, ref iterator)); + } + } + } + + return found; + } + + private void InitializeEnemyTargetSpatialIndex() + { + if (_enemyTargetBuckets.IsCreated) + { + return; + } + + _enemyTargetBuckets = new NativeParallelMultiHashMap(256, Allocator.Persistent); + _enemyTargetBucketsDirty = true; + _enemyTargetBucketsFrame = -1; + } + + private void DisposeEnemyTargetSpatialIndex() + { + if (_enemyTargetBuckets.IsCreated) + { + _enemyTargetBuckets.Dispose(); + } + + _enemyTargetBuckets = default; + _enemyTargetBucketsDirty = true; + _enemyTargetBucketsFrame = -1; + } + + private void ClearEnemyTargetSpatialIndex() + { + if (_enemyTargetBuckets.IsCreated) + { + _enemyTargetBuckets.Clear(); + } + + _enemyTargetBucketsDirty = true; + _enemyTargetBucketsFrame = -1; + } + + private void MarkEnemyTargetSpatialIndexDirty() + { + _enemyTargetBucketsDirty = true; + _enemyTargetBucketsFrame = -1; + } + + private void BuildEnemyTargetSpatialIndexIfNeeded() + { + InitializeEnemyTargetSpatialIndex(); + + if (!_enemyTargetBucketsDirty && _enemyTargetBucketsFrame == Time.frameCount) + { + return; + } + + using (CustomProfilerMarker.TargetSelection_BuildBuckets.Auto()) + { + int enemyCount = _enemies.Count; + int desiredCapacity = Mathf.Max(256, enemyCount * 2 + 1); + if (_enemyTargetBuckets.Capacity < desiredCapacity) + { + _enemyTargetBuckets.Capacity = desiredCapacity; + } + + _enemyTargetBuckets.Clear(); + float cellSize = GetTargetSelectionCellSize(); + + for (int i = 0; i < enemyCount; i++) + { + EnemySimData enemy = _enemies[i]; + int cellX = ToCell(enemy.Position.x, cellSize); + int cellZ = ToCell(enemy.Position.z, cellSize); + _enemyTargetBuckets.Add(CellKey(cellX, cellZ), i); + } + } + + _enemyTargetBucketsDirty = false; + _enemyTargetBucketsFrame = Time.frameCount; + } + + private float GetTargetSelectionCellSize() + { + return Mathf.Max(0.1f, _targetSelectionCellSize); + } + + private static int ToCell(float value, float cellSize) + { + return Mathf.FloorToInt(value / cellSize); + } + + private static long CellKey(int x, int z) + { + return ((long)x << 32) ^ (uint)z; + } + } +} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs.meta b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs.meta new file mode 100644 index 0000000..22df1e5 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b327174958354540a46e8685f2272cb0 +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 b20f92e..e615094 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs @@ -15,7 +15,7 @@ namespace Simulation private const int EnemyStateIdle = 0; private const int EnemyStateChasing = 1; private const int EnemyStateInAttackRange = 2; - + private struct EnemyTickWorkItem { public int EntityId; @@ -34,9 +34,14 @@ namespace Simulation public int NextState; } - [SerializeField] private bool _useSimulationMovement; - [SerializeField] private bool _useJobSimulation; - [SerializeField] private bool _useBurstJobs = true; + [Header("模拟世界全局设置")] [Tooltip("是否启用世界模拟")] [SerializeField] + private bool _useSimulationMovement; + + [Tooltip("是否启用 Job 运算路径")] [SerializeField] + private bool _useJobSimulation; + + [Tooltip("是否使用 Burst 来完成计算")] [SerializeField] + private bool _useBurstJobs = true; private EntitySync _entitySync; private Presentation _presentation; @@ -104,6 +109,8 @@ namespace Simulation int simulationIndex = _enemies.Count; _enemies.Add(simData); EnemyBinding.Bind(simData.EntityId, simulationIndex); + OnEnemyAddedToSeparationTemporalBuffers(); + MarkEnemyTargetSpatialIndexDirty(); return simulationIndex; } @@ -115,6 +122,7 @@ namespace Simulation } _enemies[simulationIndex] = simData; + MarkEnemyTargetSpatialIndexDirty(); return simulationIndex; } @@ -134,7 +142,9 @@ namespace Simulation } _enemies.RemoveAt(lastIndex); + OnEnemyRemovedFromSeparationTemporalBuffers(simulationIndex); EnemyBinding.UnbindByEntityId(entityId); + MarkEnemyTargetSpatialIndexDirty(); return true; } @@ -288,6 +298,7 @@ namespace Simulation { TickEnemiesJobified(in context); } + return; } @@ -455,6 +466,8 @@ namespace Simulation private void WriteBackEnemyTickResults() { + bool hasPositionChanged = false; + for (int i = 0; i < _enemyTickWorkItems.Count; i++) { EnemySimData enemy = _enemies[i]; @@ -468,11 +481,18 @@ namespace Simulation { enemy.Rotation = workItem.Rotation; } + + hasPositionChanged = true; } enemy.State = workItem.NextState; _enemies[i] = enemy; } + + if (hasPositionChanged) + { + MarkEnemyTargetSpatialIndexDirty(); + } } private static EnemySimData CreateEnemyInitialSimData(EnemyBase enemy, EnemyData enemyData) diff --git a/Assets/Launcher.unity b/Assets/Launcher.unity index 06b4865..0c01be0 100644 --- a/Assets/Launcher.unity +++ b/Assets/Launcher.unity @@ -1427,6 +1427,17 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: _useSimulationMovement: 1 + _useJobSimulation: 1 + _useBurstJobs: 1 + _enemySeparationCellSize: 0 + _enemySeparationPushDamping: 0.5 + _enemySeparationMaxStepScale: 0.5 + _enemySeparationZeroDistancePushScale: 1 + _enemySeparationUseTangentialInAttackRange: 1 + _enemySeparationPushSmoothing: 0.55 + _enemySeparationPushCarry: 1 + _enemySeparationMinPushRetain: 1 + _targetSelectionCellSize: 2 --- !u!1 &1852670052 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs index d6ba655..541b8ea 100644 --- a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs +++ b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs @@ -35,6 +35,9 @@ namespace Simulation.Tests.Editor private static readonly MethodInfo TickMethod = SimulationWorldType?.GetMethod("Tick", PublicInstance); + private static readonly MethodInfo TryGetNearestEnemyEntityIdMethod = + SimulationWorldType?.GetMethod("TryGetNearestEnemyEntityId", PublicInstance); + private static readonly MethodInfo SetUseSimulationMovementMethod = SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance); @@ -67,6 +70,7 @@ namespace Simulation.Tests.Editor Assert.NotNull(RemoveEnemyByEntityIdMethod, "RemoveEnemyByEntityId reflection lookup failed."); Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed."); Assert.NotNull(TickMethod, "Tick reflection lookup failed."); + Assert.NotNull(TryGetNearestEnemyEntityIdMethod, "TryGetNearestEnemyEntityId reflection lookup failed."); Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement reflection lookup failed."); Assert.NotNull(SetUseJobSimulationMethod, "SetUseJobSimulation reflection lookup failed."); Assert.NotNull(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed."); @@ -164,7 +168,70 @@ namespace Simulation.Tests.Editor Assert.That(forward.x, Is.EqualTo(1f).Within(0.0001f)); } - private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange) + [Test] + public void TryGetNearestEnemyEntityId_SelectsNearestBucketCandidate_WhenJobSimulationEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + UpsertEnemy(CreateEnemy(entityId: 1201, position: new Vector3(1f, 0f, 0f), speed: 0f, attackRange: 1f)); + UpsertEnemy(CreateEnemy(entityId: 1202, position: new Vector3(6f, 0f, 0f), speed: 0f, attackRange: 1f)); + + InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero); + + object[] parameters = { Vector3.zero, 100f, 0 }; + bool found = (bool)TryGetNearestEnemyEntityIdMethod.Invoke(_worldComponent, parameters); + int nearestEntityId = (int)parameters[2]; + + Assert.IsTrue(found); + Assert.That(nearestEntityId, Is.EqualTo(1201)); + } + + [Test] + public void TickEnemies_SeparatesOverlappedEnemies_WhenJobSimulationEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + UpsertEnemy(CreateEnemy(entityId: 1301, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 0.1f, + avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 2)); + UpsertEnemy(CreateEnemy(entityId: 1302, position: new Vector3(0.1f, 0f, 0f), speed: 1f, attackRange: 0.1f, + avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 2)); + + InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: new Vector3(10f, 0f, 0f)); + + object enemyA = GetEnemyAt(0); + object enemyB = GetEnemyAt(1); + Vector3 posA = (Vector3)GetField(enemyA, "Position"); + Vector3 posB = (Vector3)GetField(enemyB, "Position"); + posA.y = 0f; + posB.y = 0f; + float distance = Vector3.Distance(posA, posB); + Assert.That(distance, Is.GreaterThanOrEqualTo(0.89f)); + } + + [Test] + public void TickEnemies_SeparatesOverlappedEnemies_WhenPlayerIsStaticAndInRange() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + UpsertEnemy(CreateEnemy(entityId: 1311, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 10f, + avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 3)); + UpsertEnemy(CreateEnemy(entityId: 1312, position: new Vector3(0.05f, 0f, 0f), speed: 1f, attackRange: 10f, + avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 3)); + + InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: Vector3.zero); + + object enemyA = GetEnemyAt(0); + object enemyB = GetEnemyAt(1); + Assert.That((int)GetField(enemyA, "State"), Is.EqualTo(2)); + Assert.That((int)GetField(enemyB, "State"), Is.EqualTo(2)); + + Vector3 posA = (Vector3)GetField(enemyA, "Position"); + Vector3 posB = (Vector3)GetField(enemyB, "Position"); + posA.y = 0f; + posB.y = 0f; + float distance = Vector3.Distance(posA, posB); + Assert.That(distance, Is.GreaterThanOrEqualTo(0.5f)); + } + + private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange, + bool avoidEnemyOverlap = false, float enemyBodyRadius = 0.45f, int separationIterations = 1) { object enemy = System.Activator.CreateInstance(EnemySimDataType); SetField(ref enemy, "EntityId", entityId); @@ -173,9 +240,9 @@ namespace Simulation.Tests.Editor SetField(ref enemy, "Rotation", Quaternion.identity); SetField(ref enemy, "Speed", speed); SetField(ref enemy, "AttackRange", attackRange); - SetField(ref enemy, "AvoidEnemyOverlap", false); - SetField(ref enemy, "EnemyBodyRadius", 0.45f); - SetField(ref enemy, "SeparationIterations", 1); + SetField(ref enemy, "AvoidEnemyOverlap", avoidEnemyOverlap); + SetField(ref enemy, "EnemyBodyRadius", enemyBodyRadius); + SetField(ref enemy, "SeparationIterations", separationIterations); SetField(ref enemy, "TargetType", 0); SetField(ref enemy, "State", 0); return enemy; diff --git a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs index 6c8513e..95b8649 100644 --- a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs +++ b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs @@ -37,6 +37,9 @@ namespace Simulation.Tests.PlayMode private static readonly MethodInfo TickMethod = SimulationWorldType?.GetMethod("Tick", PublicInstance); + private static readonly MethodInfo TryGetNearestEnemyEntityIdMethod = + SimulationWorldType?.GetMethod("TryGetNearestEnemyEntityId", PublicInstance); + private static readonly MethodInfo SetUseSimulationMovementMethod = SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance); @@ -69,6 +72,7 @@ namespace Simulation.Tests.PlayMode Assert.NotNull(RemoveEnemyByEntityIdMethod, "RemoveEnemyByEntityId reflection lookup failed."); Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed."); Assert.NotNull(TickMethod, "Tick reflection lookup failed."); + Assert.NotNull(TryGetNearestEnemyEntityIdMethod, "TryGetNearestEnemyEntityId reflection lookup failed."); Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement reflection lookup failed."); Assert.NotNull(SetUseJobSimulationMethod, "SetUseJobSimulation reflection lookup failed."); Assert.NotNull(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed."); @@ -177,7 +181,73 @@ namespace Simulation.Tests.PlayMode yield break; } - private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange) + [UnityTest] + public IEnumerator TryGetNearestEnemyEntityId_SelectsNearestBucketCandidate_WhenJobSimulationEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + UpsertEnemy(CreateEnemy(entityId: 3301, position: new Vector3(1f, 0f, 0f), speed: 0f, attackRange: 1f)); + UpsertEnemy(CreateEnemy(entityId: 3302, position: new Vector3(6f, 0f, 0f), speed: 0f, attackRange: 1f)); + + InvokeTick(deltaTime: 0.016f, realDeltaTime: 0.016f, playerPosition: Vector3.zero); + + object[] parameters = { Vector3.zero, 100f, 0 }; + bool found = (bool)TryGetNearestEnemyEntityIdMethod.Invoke(_worldComponent, parameters); + int nearestEntityId = (int)parameters[2]; + + Assert.IsTrue(found); + Assert.That(nearestEntityId, Is.EqualTo(3301)); + yield break; + } + + [UnityTest] + public IEnumerator TickEnemies_SeparatesOverlappedEnemies_WhenJobSimulationEnabled() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + UpsertEnemy(CreateEnemy(entityId: 3401, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 0.1f, + avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 2)); + UpsertEnemy(CreateEnemy(entityId: 3402, position: new Vector3(0.1f, 0f, 0f), speed: 1f, attackRange: 0.1f, + avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 2)); + + InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: new Vector3(10f, 0f, 0f)); + + object enemyA = GetEnemyAt(0); + object enemyB = GetEnemyAt(1); + Vector3 posA = (Vector3)GetField(enemyA, "Position"); + Vector3 posB = (Vector3)GetField(enemyB, "Position"); + posA.y = 0f; + posB.y = 0f; + float distance = Vector3.Distance(posA, posB); + Assert.That(distance, Is.GreaterThanOrEqualTo(0.89f)); + yield break; + } + + [UnityTest] + public IEnumerator TickEnemies_SeparatesOverlappedEnemies_WhenPlayerIsStaticAndInRange() + { + SetUseJobSimulationMethod.Invoke(_worldComponent, new object[] { true }); + UpsertEnemy(CreateEnemy(entityId: 3411, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 10f, + avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 3)); + UpsertEnemy(CreateEnemy(entityId: 3412, position: new Vector3(0.05f, 0f, 0f), speed: 1f, attackRange: 10f, + avoidEnemyOverlap: true, enemyBodyRadius: 0.45f, separationIterations: 3)); + + InvokeTick(deltaTime: 0.1f, realDeltaTime: 0.1f, playerPosition: Vector3.zero); + + object enemyA = GetEnemyAt(0); + object enemyB = GetEnemyAt(1); + Assert.That((int)GetField(enemyA, "State"), Is.EqualTo(2)); + Assert.That((int)GetField(enemyB, "State"), Is.EqualTo(2)); + + Vector3 posA = (Vector3)GetField(enemyA, "Position"); + Vector3 posB = (Vector3)GetField(enemyB, "Position"); + posA.y = 0f; + posB.y = 0f; + float distance = Vector3.Distance(posA, posB); + Assert.That(distance, Is.GreaterThanOrEqualTo(0.5f)); + yield break; + } + + private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange, + bool avoidEnemyOverlap = false, float enemyBodyRadius = 0.45f, int separationIterations = 1) { object enemy = System.Activator.CreateInstance(EnemySimDataType); SetField(ref enemy, "EntityId", entityId); @@ -186,9 +256,9 @@ namespace Simulation.Tests.PlayMode SetField(ref enemy, "Rotation", Quaternion.identity); SetField(ref enemy, "Speed", speed); SetField(ref enemy, "AttackRange", attackRange); - SetField(ref enemy, "AvoidEnemyOverlap", false); - SetField(ref enemy, "EnemyBodyRadius", 0.45f); - SetField(ref enemy, "SeparationIterations", 1); + SetField(ref enemy, "AvoidEnemyOverlap", avoidEnemyOverlap); + SetField(ref enemy, "EnemyBodyRadius", enemyBodyRadius); + SetField(ref enemy, "SeparationIterations", separationIterations); SetField(ref enemy, "TargetType", 0); SetField(ref enemy, "State", 0); return enemy; diff --git a/docs/TodoList.md b/docs/TodoList.md index 5d113bf..e3be624 100644 --- a/docs/TodoList.md +++ b/docs/TodoList.md @@ -136,7 +136,7 @@ - 保留 A/B 路径:可切换 Job 与旧逻辑对比。 - 完成标准:开启 Job 后敌人追踪行为视觉一致;`TickEnemies` 主线程耗时明显下降。 -- [ ] Checkpoint 4:目标选择加速(空间哈希/网格分桶) +- [x] Checkpoint 4:目标选择加速(空间哈希/网格分桶) - 建立敌人/目标的空间索引容器(建议 `NativeParallelMultiHashMap` 或等价结构)。 - 拆分为两个阶段: - 构建分桶(Build Buckets)