- Checkpoint 1:清理 `TickEnemies` 侧 GC
- Checkpoint 2:解耦 Simulation 核心与 `Transform` 运行时依赖 - Checkpoint 3:收口 `EntitySync` 职责边界 - Checkpoint 4:拆分 Simulation Tick 阶段 - Checkpoint 5:补最小回归测试 - Checkpoint 6:补充 P1.5 结项文档
This commit is contained in:
parent
200277a703
commit
d55ead69a0
|
|
@ -5,6 +5,10 @@ namespace CustomDebugger
|
|||
public static class CustomProfilerMarker
|
||||
{
|
||||
public static readonly ProfilerMarker TickEnemies = new ProfilerMarker("TickEnemies");
|
||||
public static readonly ProfilerMarker TickEnemies_BuildInput = new ProfilerMarker("TickEnemies.BuildInput");
|
||||
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 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");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
using Components;
|
||||
using Entity;
|
||||
using Entity.EntityData;
|
||||
using GameFramework.Event;
|
||||
using UnityGameFramework.Runtime;
|
||||
|
||||
|
|
@ -45,21 +43,20 @@ namespace Simulation
|
|||
|
||||
if (groupName == EnemyGroupName && args.Entity.Logic is EnemyBase enemy)
|
||||
{
|
||||
_world.RegisterEnemyTransform(enemy.Id, enemy.CachedTransform);
|
||||
_world.UpsertEnemy(CreateEnemySimData(enemy, args.UserData as EnemyData));
|
||||
_world.RegisterEnemyLifecycle(enemy, args.UserData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupName == DropGroupName && args.Entity.Logic is EntityBase pickupEntity)
|
||||
{
|
||||
_world.UpsertPickup(CreatePickupSimData(pickupEntity));
|
||||
_world.RegisterPickupLifecycle(pickupEntity);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((groupName == BulletGroupName || groupName == ProjectileGroupName) &&
|
||||
args.Entity.Logic is EntityBase projectileEntity)
|
||||
{
|
||||
_world.UpsertProjectile(CreateProjectileSimData(projectileEntity));
|
||||
_world.RegisterProjectileLifecycle(projectileEntity);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,67 +69,21 @@ namespace Simulation
|
|||
string groupName = args.EntityGroup.Name;
|
||||
if (groupName == EnemyGroupName)
|
||||
{
|
||||
_world.UnregisterEnemyTransform(args.EntityId);
|
||||
_world.RemoveEnemyByEntityId(args.EntityId);
|
||||
_world.UnregisterEnemyLifecycle(args.EntityId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupName == DropGroupName)
|
||||
{
|
||||
_world.RemovePickupByEntityId(args.EntityId);
|
||||
_world.UnregisterPickupLifecycle(args.EntityId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupName == BulletGroupName || groupName == ProjectileGroupName)
|
||||
{
|
||||
_world.RemoveProjectileByEntityId(args.EntityId);
|
||||
_world.UnregisterProjectileLifecycle(args.EntityId);
|
||||
}
|
||||
}
|
||||
|
||||
private static EnemySimData CreateEnemySimData(EnemyBase enemy, EnemyData enemyData)
|
||||
{
|
||||
MovementComponent movementComponent = enemy != null ? enemy.GetComponent<MovementComponent>() : null;
|
||||
|
||||
return new EnemySimData
|
||||
{
|
||||
EntityId = enemy.Id,
|
||||
Position = enemy.CachedTransform.position,
|
||||
Forward = enemy.CachedTransform.forward,
|
||||
Rotation = enemy.CachedTransform.rotation,
|
||||
Speed = enemyData != null ? enemyData.SpeedBase : 0f,
|
||||
AttackRange = 1f,
|
||||
AvoidEnemyOverlap = movementComponent != null && movementComponent.AvoidEnemyOverlap,
|
||||
EnemyBodyRadius = movementComponent != null ? movementComponent.EnemyBodyRadius : 0.45f,
|
||||
SeparationIterations = movementComponent != null ? movementComponent.SeparationIterations : 2,
|
||||
TargetType = 0,
|
||||
State = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static PickupSimData CreatePickupSimData(EntityBase pickupEntity)
|
||||
{
|
||||
return new PickupSimData
|
||||
{
|
||||
EntityId = pickupEntity.Id,
|
||||
Position = pickupEntity.CachedTransform.position,
|
||||
PickupRadius = 0.35f,
|
||||
State = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static ProjectileSimData CreateProjectileSimData(EntityBase projectileEntity)
|
||||
{
|
||||
return new ProjectileSimData
|
||||
{
|
||||
EntityId = projectileEntity.Id,
|
||||
OwnerEntityId = 0,
|
||||
Position = projectileEntity.CachedTransform.position,
|
||||
Forward = projectileEntity.CachedTransform.forward,
|
||||
Speed = 0f,
|
||||
RemainingLifetime = 0f,
|
||||
State = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
using System.Collections.Generic;
|
||||
using Components;
|
||||
using CustomDebugger;
|
||||
using CustomUtility;
|
||||
using Unity.Profiling;
|
||||
using Entity;
|
||||
using Entity.EntityData;
|
||||
using UnityEngine;
|
||||
using UnityGameFramework.Runtime;
|
||||
|
||||
|
|
@ -14,6 +16,24 @@ namespace Simulation
|
|||
private const int EnemyStateChasing = 1;
|
||||
private const int EnemyStateInAttackRange = 2;
|
||||
|
||||
private struct EnemyTickWorkItem
|
||||
{
|
||||
public int EntityId;
|
||||
public Vector3 CurrentPosition;
|
||||
public Vector3 DesiredPosition;
|
||||
public Vector3 ToPlayer;
|
||||
public Vector3 Forward;
|
||||
public Quaternion Rotation;
|
||||
public float SqrDistanceToPlayer;
|
||||
public float AttackRangeSqr;
|
||||
public float Speed;
|
||||
public int SeparationIterations;
|
||||
public bool AvoidEnemyOverlap;
|
||||
public bool CanChase;
|
||||
public bool HasRotationUpdate;
|
||||
public int NextState;
|
||||
}
|
||||
|
||||
[SerializeField] private bool _useSimulationMovement;
|
||||
|
||||
private EntitySync _entitySync;
|
||||
|
|
@ -22,7 +42,8 @@ namespace Simulation
|
|||
private readonly List<EnemySimData> _enemies = new List<EnemySimData>();
|
||||
private readonly List<ProjectileSimData> _projectiles = new List<ProjectileSimData>();
|
||||
private readonly List<PickupSimData> _pickups = new List<PickupSimData>();
|
||||
private readonly Dictionary<int, Transform> _enemyTransforms = new Dictionary<int, Transform>();
|
||||
private readonly List<EnemySeparationAgent> _enemySeparationAgents = new List<EnemySeparationAgent>();
|
||||
private readonly List<EnemyTickWorkItem> _enemyTickWorkItems = new List<EnemyTickWorkItem>();
|
||||
|
||||
private EntityBinding EnemyBinding { get; } = new EntityBinding();
|
||||
private EntityBinding ProjectileBinding { get; } = new EntityBinding();
|
||||
|
|
@ -98,24 +119,23 @@ namespace Simulation
|
|||
|
||||
_enemies.RemoveAt(lastIndex);
|
||||
EnemyBinding.UnbindByEntityId(entityId);
|
||||
_enemyTransforms.Remove(entityId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RegisterEnemyTransform(int entityId, Transform transform)
|
||||
private void RegisterEnemyLifecycle(EnemyBase enemy, object userData)
|
||||
{
|
||||
if (transform == null)
|
||||
if (enemy == null || enemy.CachedTransform == null)
|
||||
{
|
||||
_enemyTransforms.Remove(entityId);
|
||||
return;
|
||||
}
|
||||
|
||||
_enemyTransforms[entityId] = transform;
|
||||
EnemyData enemyData = userData as EnemyData;
|
||||
UpsertEnemy(CreateEnemyInitialSimData(enemy, enemyData));
|
||||
}
|
||||
|
||||
private void UnregisterEnemyTransform(int entityId)
|
||||
private void UnregisterEnemyLifecycle(int entityId)
|
||||
{
|
||||
_enemyTransforms.Remove(entityId);
|
||||
RemoveEnemyByEntityId(entityId);
|
||||
}
|
||||
|
||||
private bool TryGetEnemyData(int entityId, out EnemySimData enemyData)
|
||||
|
|
@ -170,6 +190,21 @@ namespace Simulation
|
|||
return true;
|
||||
}
|
||||
|
||||
private void RegisterProjectileLifecycle(EntityBase projectileEntity)
|
||||
{
|
||||
if (projectileEntity == null || projectileEntity.CachedTransform == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpsertProjectile(CreateProjectileInitialSimData(projectileEntity));
|
||||
}
|
||||
|
||||
private void UnregisterProjectileLifecycle(int entityId)
|
||||
{
|
||||
RemoveProjectileByEntityId(entityId);
|
||||
}
|
||||
|
||||
private int AddPickup(in PickupSimData simData)
|
||||
{
|
||||
int simulationIndex = _pickups.Count;
|
||||
|
|
@ -209,6 +244,21 @@ namespace Simulation
|
|||
return true;
|
||||
}
|
||||
|
||||
private void RegisterPickupLifecycle(EntityBase pickupEntity)
|
||||
{
|
||||
if (pickupEntity == null || pickupEntity.CachedTransform == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpsertPickup(CreatePickupInitialSimData(pickupEntity));
|
||||
}
|
||||
|
||||
private void UnregisterPickupLifecycle(int entityId)
|
||||
{
|
||||
RemovePickupByEntityId(entityId);
|
||||
}
|
||||
|
||||
public void Tick(in SimulationTickContext context)
|
||||
{
|
||||
if (!_useSimulationMovement)
|
||||
|
|
@ -227,7 +277,8 @@ namespace Simulation
|
|||
_enemies.Clear();
|
||||
_projectiles.Clear();
|
||||
_pickups.Clear();
|
||||
_enemyTransforms.Clear();
|
||||
_enemySeparationAgents.Clear();
|
||||
_enemyTickWorkItems.Clear();
|
||||
|
||||
EnemyBinding.Clear();
|
||||
ProjectileBinding.Clear();
|
||||
|
|
@ -244,52 +295,214 @@ namespace Simulation
|
|||
Vector3 playerPosition = context.PlayerPosition;
|
||||
playerPosition.y = 0f;
|
||||
|
||||
using (CustomProfilerMarker.TickEnemies_BuildInput.Auto())
|
||||
{
|
||||
BuildEnemyTickInput(in playerPosition);
|
||||
}
|
||||
|
||||
using (CustomProfilerMarker.TickEnemies_MoveSeparation.Auto())
|
||||
{
|
||||
MoveAndSeparateEnemies(context.DeltaTime);
|
||||
}
|
||||
|
||||
using (CustomProfilerMarker.TickEnemies_StateUpdate.Auto())
|
||||
{
|
||||
UpdateEnemyStates();
|
||||
}
|
||||
|
||||
using (CustomProfilerMarker.TickEnemies_WriteBack.Auto())
|
||||
{
|
||||
WriteBackEnemyTickResults();
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildEnemyTickInput(in Vector3 playerPosition)
|
||||
{
|
||||
_enemyTickWorkItems.Clear();
|
||||
_enemySeparationAgents.Clear();
|
||||
|
||||
for (int i = 0; i < _enemies.Count; i++)
|
||||
{
|
||||
EnemySimData enemy = _enemies[i];
|
||||
_enemyTransforms.TryGetValue(enemy.EntityId, out Transform enemyTransform);
|
||||
|
||||
Vector3 currentPosition = enemy.Position;
|
||||
currentPosition.y = 0f;
|
||||
|
||||
Vector3 toPlayer = playerPosition - currentPosition;
|
||||
Vector3 horizontalPosition = currentPosition;
|
||||
horizontalPosition.y = 0f;
|
||||
Vector3 toPlayer = playerPosition - horizontalPosition;
|
||||
float sqrDistance = toPlayer.sqrMagnitude;
|
||||
|
||||
float attackRange = enemy.AttackRange > 0f ? enemy.AttackRange : DefaultAttackRange;
|
||||
float attackRangeSqr = attackRange * attackRange;
|
||||
bool isInAttackRange = sqrDistance <= attackRangeSqr;
|
||||
bool canChase = !isInAttackRange && enemy.Speed > 0f && sqrDistance > float.Epsilon;
|
||||
|
||||
if (sqrDistance <= attackRangeSqr)
|
||||
EnemyTickWorkItem workItem = new EnemyTickWorkItem
|
||||
{
|
||||
enemy.State = EnemyStateInAttackRange;
|
||||
EntityId = enemy.EntityId,
|
||||
CurrentPosition = currentPosition,
|
||||
DesiredPosition = currentPosition,
|
||||
ToPlayer = toPlayer,
|
||||
Forward = enemy.Forward,
|
||||
Rotation = enemy.Rotation,
|
||||
SqrDistanceToPlayer = sqrDistance,
|
||||
AttackRangeSqr = attackRangeSqr,
|
||||
Speed = enemy.Speed,
|
||||
SeparationIterations = enemy.SeparationIterations > 0 ? enemy.SeparationIterations : 1,
|
||||
AvoidEnemyOverlap = enemy.AvoidEnemyOverlap,
|
||||
CanChase = canChase,
|
||||
HasRotationUpdate = false,
|
||||
NextState = EnemyStateIdle
|
||||
};
|
||||
_enemyTickWorkItems.Add(workItem);
|
||||
|
||||
if (!enemy.AvoidEnemyOverlap) continue;
|
||||
|
||||
_enemySeparationAgents.Add(new EnemySeparationAgent
|
||||
{
|
||||
AgentId = enemy.EntityId,
|
||||
Position = horizontalPosition,
|
||||
Radius = enemy.EnemyBodyRadius > 0f ? enemy.EnemyBodyRadius : 0.45f
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void MoveAndSeparateEnemies(float deltaTime)
|
||||
{
|
||||
EnemySeparationSolverProvider.SetSimulationAgents(_enemySeparationAgents);
|
||||
|
||||
for (int i = 0; i < _enemyTickWorkItems.Count; i++)
|
||||
{
|
||||
EnemyTickWorkItem workItem = _enemyTickWorkItems[i];
|
||||
if (!workItem.CanChase)
|
||||
{
|
||||
_enemyTickWorkItems[i] = workItem;
|
||||
continue;
|
||||
}
|
||||
else if (enemy.Speed <= 0f || sqrDistance <= float.Epsilon)
|
||||
|
||||
Vector3 forward = workItem.ToPlayer.normalized;
|
||||
Vector3 desiredPosition = workItem.CurrentPosition + forward * workItem.Speed * deltaTime;
|
||||
|
||||
if (workItem.AvoidEnemyOverlap)
|
||||
{
|
||||
enemy.State = EnemyStateIdle;
|
||||
desiredPosition = EnemySeparationSolverProvider.ResolveSimulation(
|
||||
workItem.EntityId,
|
||||
desiredPosition,
|
||||
forward,
|
||||
workItem.SeparationIterations);
|
||||
}
|
||||
|
||||
workItem.Forward = forward;
|
||||
workItem.DesiredPosition = desiredPosition;
|
||||
|
||||
if (forward.sqrMagnitude > float.Epsilon)
|
||||
{
|
||||
workItem.Rotation = Quaternion.LookRotation(forward, Vector3.up);
|
||||
workItem.HasRotationUpdate = true;
|
||||
}
|
||||
|
||||
_enemyTickWorkItems[i] = workItem;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEnemyStates()
|
||||
{
|
||||
for (int i = 0; i < _enemyTickWorkItems.Count; i++)
|
||||
{
|
||||
EnemyTickWorkItem workItem = _enemyTickWorkItems[i];
|
||||
|
||||
if (workItem.SqrDistanceToPlayer <= workItem.AttackRangeSqr)
|
||||
{
|
||||
workItem.NextState = EnemyStateInAttackRange;
|
||||
}
|
||||
else if (workItem.CanChase)
|
||||
{
|
||||
workItem.NextState = EnemyStateChasing;
|
||||
}
|
||||
else
|
||||
{
|
||||
Vector3 forward = toPlayer.normalized;
|
||||
enemy.Forward = forward;
|
||||
Vector3 desiredPosition = enemy.Position + forward * enemy.Speed * context.DeltaTime;
|
||||
if (enemy.AvoidEnemyOverlap && enemyTransform != null)
|
||||
{
|
||||
int separationIterations = enemy.SeparationIterations > 0 ? enemy.SeparationIterations : 1;
|
||||
desiredPosition = EnemySeparationSolverProvider.Resolve(
|
||||
enemyTransform,
|
||||
desiredPosition,
|
||||
forward,
|
||||
separationIterations);
|
||||
}
|
||||
workItem.NextState = EnemyStateIdle;
|
||||
}
|
||||
|
||||
enemy.Position = desiredPosition;
|
||||
enemy.State = EnemyStateChasing;
|
||||
if (forward.sqrMagnitude > float.Epsilon)
|
||||
_enemyTickWorkItems[i] = workItem;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteBackEnemyTickResults()
|
||||
{
|
||||
for (int i = 0; i < _enemyTickWorkItems.Count; i++)
|
||||
{
|
||||
EnemySimData enemy = _enemies[i];
|
||||
EnemyTickWorkItem workItem = _enemyTickWorkItems[i];
|
||||
|
||||
if (workItem.CanChase)
|
||||
{
|
||||
enemy.Forward = workItem.Forward;
|
||||
enemy.Position = workItem.DesiredPosition;
|
||||
if (workItem.HasRotationUpdate)
|
||||
{
|
||||
enemy.Rotation = Quaternion.LookRotation(forward, Vector3.up);
|
||||
enemy.Rotation = workItem.Rotation;
|
||||
}
|
||||
}
|
||||
|
||||
enemy.State = workItem.NextState;
|
||||
_enemies[i] = enemy;
|
||||
}
|
||||
}
|
||||
|
||||
private static EnemySimData CreateEnemyInitialSimData(EnemyBase enemy, EnemyData enemyData)
|
||||
{
|
||||
Transform enemyTransform = enemy.CachedTransform;
|
||||
MovementComponent movementComponent = enemy.GetComponent<MovementComponent>();
|
||||
|
||||
float speed = 0f;
|
||||
if (enemyData != null)
|
||||
{
|
||||
speed = enemyData.SpeedBase;
|
||||
}
|
||||
else if (movementComponent != null)
|
||||
{
|
||||
speed = movementComponent.Speed;
|
||||
}
|
||||
|
||||
return new EnemySimData
|
||||
{
|
||||
EntityId = enemy.Id,
|
||||
Position = enemyTransform.position,
|
||||
Forward = enemyTransform.forward,
|
||||
Rotation = enemyTransform.rotation,
|
||||
Speed = speed,
|
||||
AttackRange = 1f,
|
||||
AvoidEnemyOverlap = movementComponent != null && movementComponent.AvoidEnemyOverlap,
|
||||
EnemyBodyRadius = movementComponent != null ? movementComponent.EnemyBodyRadius : 0.45f,
|
||||
SeparationIterations = movementComponent != null ? movementComponent.SeparationIterations : 2,
|
||||
TargetType = 0,
|
||||
State = EnemyStateIdle
|
||||
};
|
||||
}
|
||||
|
||||
private static PickupSimData CreatePickupInitialSimData(EntityBase pickupEntity)
|
||||
{
|
||||
return new PickupSimData
|
||||
{
|
||||
EntityId = pickupEntity.Id,
|
||||
Position = pickupEntity.CachedTransform.position,
|
||||
PickupRadius = 0.35f,
|
||||
State = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static ProjectileSimData CreateProjectileInitialSimData(EntityBase projectileEntity)
|
||||
{
|
||||
return new ProjectileSimData
|
||||
{
|
||||
EntityId = projectileEntity.Id,
|
||||
OwnerEntityId = 0,
|
||||
Position = projectileEntity.CachedTransform.position,
|
||||
Forward = projectileEntity.CachedTransform.forward,
|
||||
Speed = 0f,
|
||||
RemainingLifetime = 0f,
|
||||
State = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
|
|
@ -6,70 +5,159 @@ namespace CustomUtility
|
|||
{
|
||||
public static class EnemySeparationSolverProvider
|
||||
{
|
||||
private struct Registration
|
||||
private enum SolverType
|
||||
{
|
||||
GridBucket,
|
||||
Naive
|
||||
}
|
||||
|
||||
private struct LegacyRegistration
|
||||
{
|
||||
public int AgentId;
|
||||
public float BodyRadius;
|
||||
}
|
||||
|
||||
private static IEnemySeparationSolver _current = new GridBucketEnemySeparationSolver();
|
||||
private static readonly Dictionary<Transform, Registration> Registrations = new();
|
||||
private static SolverType _solverType = SolverType.GridBucket;
|
||||
private static float _gridCellSize = 1f;
|
||||
private static IEnemySeparationSolver _legacySolver = CreateSolver();
|
||||
private static IEnemySeparationSolver _simulationSolver = CreateSolver();
|
||||
|
||||
public static IEnemySeparationSolver Current => _current;
|
||||
public static string CurrentSolverName => _current.GetType().Name;
|
||||
private static readonly Dictionary<Transform, LegacyRegistration> LegacyRegistrations = new();
|
||||
private static readonly List<EnemySeparationAgent> LegacyAgents = new();
|
||||
private static readonly List<Transform> LegacyRecycle = new();
|
||||
private static int _legacySnapshotFrame = -1;
|
||||
private static int _nextLegacyAgentId = 1;
|
||||
|
||||
public static IEnemySeparationSolver Current => _simulationSolver;
|
||||
public static string CurrentSolverName => _simulationSolver.GetType().Name;
|
||||
|
||||
public static void SetSolver(IEnemySeparationSolver solver)
|
||||
{
|
||||
if (solver == null) return;
|
||||
_current = solver;
|
||||
ReRegisterAll();
|
||||
|
||||
_legacySolver = solver;
|
||||
_simulationSolver = solver;
|
||||
_legacySnapshotFrame = -1;
|
||||
}
|
||||
|
||||
public static void UseGridBucketSolver(float cellSize = 1f)
|
||||
{
|
||||
SetSolver(new GridBucketEnemySeparationSolver(cellSize));
|
||||
_solverType = SolverType.GridBucket;
|
||||
_gridCellSize = Mathf.Max(0.1f, cellSize);
|
||||
RecreateSolvers();
|
||||
}
|
||||
|
||||
public static void UseNaiveSolver()
|
||||
{
|
||||
SetSolver(new NaiveEnemySeparationSolver());
|
||||
_solverType = SolverType.Naive;
|
||||
RecreateSolvers();
|
||||
}
|
||||
|
||||
public static void Register(Transform transform, float bodyRadius)
|
||||
{
|
||||
if (transform == null) return;
|
||||
|
||||
var registration = new Registration
|
||||
if (LegacyRegistrations.TryGetValue(transform, out LegacyRegistration registration))
|
||||
{
|
||||
BodyRadius = bodyRadius
|
||||
};
|
||||
Registrations[transform] = registration;
|
||||
_current.Register(transform, bodyRadius);
|
||||
registration.BodyRadius = bodyRadius;
|
||||
LegacyRegistrations[transform] = registration;
|
||||
}
|
||||
else
|
||||
{
|
||||
LegacyRegistrations.Add(transform, new LegacyRegistration
|
||||
{
|
||||
AgentId = _nextLegacyAgentId++,
|
||||
BodyRadius = bodyRadius
|
||||
});
|
||||
}
|
||||
|
||||
_legacySnapshotFrame = -1;
|
||||
}
|
||||
|
||||
public static void Unregister(Transform transform)
|
||||
{
|
||||
if (transform == null) return;
|
||||
if (!LegacyRegistrations.Remove(transform)) return;
|
||||
|
||||
_current.Unregister(transform);
|
||||
Registrations.Remove(transform);
|
||||
_legacySnapshotFrame = -1;
|
||||
}
|
||||
|
||||
public static Vector3 Resolve(Transform transform, Vector3 desiredPosition, Vector3 fallbackDirection,
|
||||
int iterations)
|
||||
{
|
||||
return _current.Resolve(transform, desiredPosition, fallbackDirection, iterations);
|
||||
if (transform == null) return desiredPosition;
|
||||
if (!LegacyRegistrations.TryGetValue(transform, out LegacyRegistration registration))
|
||||
{
|
||||
return desiredPosition;
|
||||
}
|
||||
|
||||
EnsureLegacySnapshot();
|
||||
return _legacySolver.Resolve(registration.AgentId, desiredPosition, fallbackDirection, iterations);
|
||||
}
|
||||
|
||||
private static void ReRegisterAll()
|
||||
public static void SetSimulationAgents(IReadOnlyList<EnemySeparationAgent> agents)
|
||||
{
|
||||
foreach (var pair in Registrations)
|
||||
_simulationSolver.SetAgents(agents);
|
||||
}
|
||||
|
||||
public static Vector3 ResolveSimulation(int agentId, Vector3 desiredPosition, Vector3 fallbackDirection,
|
||||
int iterations)
|
||||
{
|
||||
return _simulationSolver.Resolve(agentId, desiredPosition, fallbackDirection, iterations);
|
||||
}
|
||||
|
||||
private static void EnsureLegacySnapshot()
|
||||
{
|
||||
int frame = Time.frameCount;
|
||||
if (_legacySnapshotFrame == frame) return;
|
||||
|
||||
_legacySnapshotFrame = frame;
|
||||
LegacyAgents.Clear();
|
||||
LegacyRecycle.Clear();
|
||||
|
||||
foreach (var pair in LegacyRegistrations)
|
||||
{
|
||||
Transform transform = pair.Key;
|
||||
Registration registration = pair.Value;
|
||||
if (transform == null) continue;
|
||||
if (transform == null)
|
||||
{
|
||||
LegacyRecycle.Add(pair.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
_current.Register(transform, registration.BodyRadius);
|
||||
Vector3 position = transform.position;
|
||||
position.y = 0f;
|
||||
|
||||
LegacyAgents.Add(new EnemySeparationAgent
|
||||
{
|
||||
AgentId = pair.Value.AgentId,
|
||||
Position = position,
|
||||
Radius = Mathf.Max(0.01f, pair.Value.BodyRadius)
|
||||
});
|
||||
}
|
||||
|
||||
for (int i = 0; i < LegacyRecycle.Count; i++)
|
||||
{
|
||||
LegacyRegistrations.Remove(LegacyRecycle[i]);
|
||||
}
|
||||
|
||||
_legacySolver.SetAgents(LegacyAgents);
|
||||
}
|
||||
|
||||
private static void RecreateSolvers()
|
||||
{
|
||||
_legacySolver = CreateSolver();
|
||||
_simulationSolver = CreateSolver();
|
||||
_legacySnapshotFrame = -1;
|
||||
}
|
||||
|
||||
private static IEnemySeparationSolver CreateSolver()
|
||||
{
|
||||
if (_solverType == SolverType.Naive)
|
||||
{
|
||||
return new NaiveEnemySeparationSolver();
|
||||
}
|
||||
|
||||
return new GridBucketEnemySeparationSolver(_gridCellSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,24 +4,20 @@ namespace CustomUtility
|
|||
{
|
||||
public sealed class GridBucketEnemySeparationSolver : IEnemySeparationSolver
|
||||
{
|
||||
private sealed class Agent
|
||||
private struct Agent
|
||||
{
|
||||
public Transform Transform;
|
||||
public float Radius;
|
||||
public Vector3 Position;
|
||||
public int CellX;
|
||||
public int CellZ;
|
||||
}
|
||||
|
||||
private readonly System.Collections.Generic.Dictionary<Transform, Agent> _agents = new();
|
||||
|
||||
private readonly System.Collections.Generic.Dictionary<long, System.Collections.Generic.List<Transform>>
|
||||
_buckets = new();
|
||||
|
||||
private readonly System.Collections.Generic.List<Transform> _recycle = new();
|
||||
private readonly System.Collections.Generic.Dictionary<int, Agent> _agents = new();
|
||||
private readonly System.Collections.Generic.Dictionary<long, System.Collections.Generic.List<int>> _buckets = new();
|
||||
private readonly System.Collections.Generic.Stack<System.Collections.Generic.List<int>> _bucketListPool = new();
|
||||
private readonly System.Collections.Generic.List<long> _activeBucketKeys = new();
|
||||
private readonly float _cellSize;
|
||||
|
||||
private int _snapshotFrame = -1;
|
||||
private float _maxRadius = 0.45f;
|
||||
|
||||
public GridBucketEnemySeparationSolver(float cellSize = 1f)
|
||||
|
|
@ -29,44 +25,42 @@ namespace CustomUtility
|
|||
_cellSize = Mathf.Max(0.1f, cellSize);
|
||||
}
|
||||
|
||||
public void Register(Transform transform, float bodyRadius)
|
||||
public void SetAgents(System.Collections.Generic.IReadOnlyList<EnemySeparationAgent> agents)
|
||||
{
|
||||
if (transform == null) return;
|
||||
RecycleBucketsForSnapshot();
|
||||
_agents.Clear();
|
||||
_maxRadius = 0.01f;
|
||||
|
||||
if (!_agents.TryGetValue(transform, out var agent))
|
||||
if (agents == null) return;
|
||||
|
||||
for (int i = 0; i < agents.Count; i++)
|
||||
{
|
||||
agent = new Agent();
|
||||
_agents.Add(transform, agent);
|
||||
}
|
||||
EnemySeparationAgent input = agents[i];
|
||||
Vector3 position = input.Position;
|
||||
position.y = 0f;
|
||||
float radius = Mathf.Max(0.01f, input.Radius);
|
||||
|
||||
agent.Transform = transform;
|
||||
agent.Radius = Mathf.Max(0.01f, bodyRadius);
|
||||
if (agent.Radius > _maxRadius)
|
||||
{
|
||||
_maxRadius = agent.Radius;
|
||||
}
|
||||
Agent agent = new Agent
|
||||
{
|
||||
Radius = radius,
|
||||
Position = position,
|
||||
CellX = ToCell(position.x),
|
||||
CellZ = ToCell(position.z)
|
||||
};
|
||||
|
||||
_snapshotFrame = -1;
|
||||
_agents[input.AgentId] = agent;
|
||||
AddToBucket(input.AgentId, agent.CellX, agent.CellZ);
|
||||
|
||||
if (radius > _maxRadius)
|
||||
{
|
||||
_maxRadius = radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Unregister(Transform transform)
|
||||
public Vector3 Resolve(int agentId, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations)
|
||||
{
|
||||
if (transform == null) return;
|
||||
if (!_agents.TryGetValue(transform, out var agent)) return;
|
||||
|
||||
RemoveFromBucket(transform, agent.CellX, agent.CellZ);
|
||||
_agents.Remove(transform);
|
||||
RecalculateMaxRadius();
|
||||
_snapshotFrame = -1;
|
||||
}
|
||||
|
||||
public Vector3 Resolve(Transform transform, Vector3 desiredPosition, Vector3 fallbackDirection,
|
||||
int iterations)
|
||||
{
|
||||
if (transform == null) return desiredPosition;
|
||||
if (!_agents.TryGetValue(transform, out var self)) return desiredPosition;
|
||||
|
||||
EnsureSnapshot();
|
||||
if (!_agents.TryGetValue(agentId, out var self)) return desiredPosition;
|
||||
|
||||
Vector3 candidate = desiredPosition;
|
||||
candidate.y = 0f;
|
||||
|
|
@ -89,9 +83,9 @@ namespace CustomUtility
|
|||
|
||||
for (int i = 0; i < bucket.Count; i++)
|
||||
{
|
||||
Transform otherTransform = bucket[i];
|
||||
if (otherTransform == transform) continue;
|
||||
if (!_agents.TryGetValue(otherTransform, out var other)) continue;
|
||||
int otherAgentId = bucket[i];
|
||||
if (otherAgentId == agentId) continue;
|
||||
if (!_agents.TryGetValue(otherAgentId, out var other)) continue;
|
||||
|
||||
Vector3 toSelf = candidate - other.Position;
|
||||
float minDistance = self.Radius + other.Radius;
|
||||
|
|
@ -114,98 +108,65 @@ namespace CustomUtility
|
|||
}
|
||||
}
|
||||
|
||||
SyncAgentPosition(transform, self, candidate);
|
||||
SyncAgentPosition(agentId, ref self, candidate);
|
||||
|
||||
candidate.y = desiredPosition.y;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private void EnsureSnapshot()
|
||||
private void RecycleBucketsForSnapshot()
|
||||
{
|
||||
int frame = Time.frameCount;
|
||||
if (_snapshotFrame == frame) return;
|
||||
|
||||
_snapshotFrame = frame;
|
||||
_buckets.Clear();
|
||||
_recycle.Clear();
|
||||
|
||||
foreach (var pair in _agents)
|
||||
for (int i = 0; i < _activeBucketKeys.Count; i++)
|
||||
{
|
||||
Transform transform = pair.Key;
|
||||
Agent agent = pair.Value;
|
||||
if (transform == null || agent.Transform == null)
|
||||
{
|
||||
_recycle.Add(transform);
|
||||
continue;
|
||||
}
|
||||
long key = _activeBucketKeys[i];
|
||||
if (!_buckets.TryGetValue(key, out var bucket)) continue;
|
||||
|
||||
Vector3 position = agent.Transform.position;
|
||||
position.y = 0f;
|
||||
agent.Position = position;
|
||||
|
||||
agent.CellX = ToCell(position.x);
|
||||
agent.CellZ = ToCell(position.z);
|
||||
AddToBucket(transform, agent.CellX, agent.CellZ);
|
||||
bucket.Clear();
|
||||
_bucketListPool.Push(bucket);
|
||||
_buckets.Remove(key);
|
||||
}
|
||||
|
||||
for (int i = 0; i < _recycle.Count; i++)
|
||||
{
|
||||
_agents.Remove(_recycle[i]);
|
||||
}
|
||||
_activeBucketKeys.Clear();
|
||||
}
|
||||
|
||||
private void SyncAgentPosition(Transform transform, Agent agent, Vector3 position)
|
||||
private void SyncAgentPosition(int agentId, ref Agent agent, Vector3 position)
|
||||
{
|
||||
int newCellX = ToCell(position.x);
|
||||
int newCellZ = ToCell(position.z);
|
||||
|
||||
if (agent.CellX != newCellX || agent.CellZ != newCellZ)
|
||||
{
|
||||
RemoveFromBucket(transform, agent.CellX, agent.CellZ);
|
||||
AddToBucket(transform, newCellX, newCellZ);
|
||||
RemoveFromBucket(agentId, agent.CellX, agent.CellZ);
|
||||
AddToBucket(agentId, newCellX, newCellZ);
|
||||
agent.CellX = newCellX;
|
||||
agent.CellZ = newCellZ;
|
||||
}
|
||||
|
||||
agent.Position = position;
|
||||
_agents[agentId] = agent;
|
||||
}
|
||||
|
||||
private void AddToBucket(Transform transform, int cellX, int cellZ)
|
||||
private void AddToBucket(int agentId, int cellX, int cellZ)
|
||||
{
|
||||
long key = CellKey(cellX, cellZ);
|
||||
if (!_buckets.TryGetValue(key, out var list))
|
||||
{
|
||||
list = new System.Collections.Generic.List<Transform>(8);
|
||||
list = _bucketListPool.Count > 0
|
||||
? _bucketListPool.Pop()
|
||||
: new System.Collections.Generic.List<int>(8);
|
||||
_buckets.Add(key, list);
|
||||
_activeBucketKeys.Add(key);
|
||||
}
|
||||
|
||||
list.Add(transform);
|
||||
list.Add(agentId);
|
||||
}
|
||||
|
||||
private void RemoveFromBucket(Transform transform, int cellX, int cellZ)
|
||||
private void RemoveFromBucket(int agentId, int cellX, int cellZ)
|
||||
{
|
||||
long key = CellKey(cellX, cellZ);
|
||||
if (!_buckets.TryGetValue(key, out var list)) return;
|
||||
|
||||
list.Remove(transform);
|
||||
if (list.Count == 0)
|
||||
{
|
||||
_buckets.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void RecalculateMaxRadius()
|
||||
{
|
||||
float max = 0.01f;
|
||||
foreach (var pair in _agents)
|
||||
{
|
||||
if (pair.Value.Radius > max)
|
||||
{
|
||||
max = pair.Value.Radius;
|
||||
}
|
||||
}
|
||||
|
||||
_maxRadius = max;
|
||||
list.Remove(agentId);
|
||||
}
|
||||
|
||||
private int ToCell(float value)
|
||||
|
|
@ -218,5 +179,4 @@ namespace CustomUtility
|
|||
return ((long)x << 32) ^ (uint)z;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ using UnityEngine;
|
|||
|
||||
namespace CustomUtility
|
||||
{
|
||||
public struct EnemySeparationAgent
|
||||
{
|
||||
public int AgentId;
|
||||
public Vector3 Position;
|
||||
public float Radius;
|
||||
}
|
||||
|
||||
public interface IEnemySeparationSolver
|
||||
{
|
||||
void Register(Transform transform, float bodyRadius);
|
||||
void Unregister(Transform transform);
|
||||
Vector3 Resolve(Transform transform, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations);
|
||||
void SetAgents(System.Collections.Generic.IReadOnlyList<EnemySeparationAgent> agents);
|
||||
Vector3 Resolve(int agentId, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,39 +4,41 @@ namespace CustomUtility
|
|||
{
|
||||
public sealed class NaiveEnemySeparationSolver : IEnemySeparationSolver
|
||||
{
|
||||
private sealed class Agent
|
||||
private struct Agent
|
||||
{
|
||||
public Transform Transform;
|
||||
public float Radius;
|
||||
public Vector3 Position;
|
||||
}
|
||||
|
||||
private readonly System.Collections.Generic.Dictionary<Transform, Agent> _agents = new();
|
||||
private readonly System.Collections.Generic.List<Transform> _agentKeys = new();
|
||||
private readonly System.Collections.Generic.Dictionary<int, Agent> _agents = new();
|
||||
private readonly System.Collections.Generic.List<int> _agentKeys = new();
|
||||
|
||||
public void Register(Transform transform, float bodyRadius)
|
||||
public void SetAgents(System.Collections.Generic.IReadOnlyList<EnemySeparationAgent> agents)
|
||||
{
|
||||
if (transform == null) return;
|
||||
_agents.Clear();
|
||||
_agentKeys.Clear();
|
||||
if (agents == null) return;
|
||||
|
||||
if (!_agents.TryGetValue(transform, out var agent))
|
||||
for (int i = 0; i < agents.Count; i++)
|
||||
{
|
||||
agent = new Agent();
|
||||
_agents.Add(transform, agent);
|
||||
EnemySeparationAgent input = agents[i];
|
||||
Vector3 position = input.Position;
|
||||
position.y = 0f;
|
||||
|
||||
Agent agent = new Agent
|
||||
{
|
||||
Radius = Mathf.Max(0.01f, input.Radius),
|
||||
Position = position
|
||||
};
|
||||
|
||||
_agents[input.AgentId] = agent;
|
||||
_agentKeys.Add(input.AgentId);
|
||||
}
|
||||
|
||||
agent.Transform = transform;
|
||||
agent.Radius = Mathf.Max(0.01f, bodyRadius);
|
||||
}
|
||||
|
||||
public void Unregister(Transform transform)
|
||||
public Vector3 Resolve(int agentId, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations)
|
||||
{
|
||||
if (transform == null) return;
|
||||
_agents.Remove(transform);
|
||||
}
|
||||
|
||||
public Vector3 Resolve(Transform transform, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations)
|
||||
{
|
||||
if (transform == null) return desiredPosition;
|
||||
if (!_agents.TryGetValue(transform, out var self)) return desiredPosition;
|
||||
if (!_agents.TryGetValue(agentId, out var self)) return desiredPosition;
|
||||
|
||||
Vector3 candidate = desiredPosition;
|
||||
candidate.y = 0f;
|
||||
|
|
@ -44,26 +46,16 @@ namespace CustomUtility
|
|||
Vector3 fallback = fallbackDirection.sqrMagnitude > 0.0001f ? fallbackDirection.normalized : Vector3.right;
|
||||
fallback.y = 0f;
|
||||
|
||||
_agentKeys.Clear();
|
||||
foreach (var pair in _agents)
|
||||
{
|
||||
_agentKeys.Add(pair.Key);
|
||||
}
|
||||
|
||||
int effectiveIterations = Mathf.Max(1, iterations);
|
||||
for (int iter = 0; iter < effectiveIterations; iter++)
|
||||
{
|
||||
for (int i = 0; i < _agentKeys.Count; i++)
|
||||
{
|
||||
Transform otherTransform = _agentKeys[i];
|
||||
if (otherTransform == transform) continue;
|
||||
if (!_agents.TryGetValue(otherTransform, out var other)) continue;
|
||||
if (other.Transform == null) continue;
|
||||
int otherAgentId = _agentKeys[i];
|
||||
if (otherAgentId == agentId) continue;
|
||||
if (!_agents.TryGetValue(otherAgentId, out var other)) continue;
|
||||
|
||||
Vector3 otherPosition = other.Transform.position;
|
||||
otherPosition.y = 0f;
|
||||
|
||||
Vector3 toSelf = candidate - otherPosition;
|
||||
Vector3 toSelf = candidate - other.Position;
|
||||
float minDistance = self.Radius + other.Radius;
|
||||
float minDistanceSq = minDistance * minDistance;
|
||||
float sqrDistance = toSelf.sqrMagnitude;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3d51d9ea4e1d4d84aa3fa2e38d79b2f4
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7c2d2681dd4b47f48b0f6a8185e4fa2b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9b8f4bce7ec54a5d8e53d7d4e6f8d12a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "Simulation.EditModeTests",
|
||||
"references": [],
|
||||
"optionalUnityReferences": [
|
||||
"TestAssemblies"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": []
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4f4b677269d9466da1b7f4b3d33895f7
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Simulation.Tests.Editor
|
||||
{
|
||||
public class SimulationWorldTickTests
|
||||
{
|
||||
private const string GameAssemblyName = "Assembly-CSharp";
|
||||
private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static;
|
||||
private const BindingFlags PublicInstance = BindingFlags.Public | BindingFlags.Instance;
|
||||
private const BindingFlags NonPublicInstance = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly System.Type SimulationWorldType =
|
||||
System.Type.GetType($"Simulation.SimulationWorld, {GameAssemblyName}");
|
||||
|
||||
private static readonly System.Type SimulationTickContextType =
|
||||
System.Type.GetType($"Simulation.SimulationTickContext, {GameAssemblyName}");
|
||||
|
||||
private static readonly System.Type EnemySimDataType =
|
||||
System.Type.GetType($"Simulation.EnemySimData, {GameAssemblyName}");
|
||||
|
||||
private static readonly System.Type EnemySeparationSolverProviderType =
|
||||
System.Type.GetType($"CustomUtility.EnemySeparationSolverProvider, {GameAssemblyName}");
|
||||
|
||||
private static readonly MethodInfo UpsertEnemyMethod =
|
||||
SimulationWorldType?.GetMethod("UpsertEnemy", NonPublicInstance);
|
||||
|
||||
private static readonly MethodInfo RemoveEnemyByEntityIdMethod =
|
||||
SimulationWorldType?.GetMethod("RemoveEnemyByEntityId", NonPublicInstance);
|
||||
|
||||
private static readonly MethodInfo TryGetEnemyDataMethod =
|
||||
SimulationWorldType?.GetMethod("TryGetEnemyData", NonPublicInstance);
|
||||
|
||||
private static readonly MethodInfo TickMethod =
|
||||
SimulationWorldType?.GetMethod("Tick", PublicInstance);
|
||||
|
||||
private static readonly MethodInfo SetUseSimulationMovementMethod =
|
||||
SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance);
|
||||
|
||||
private static readonly MethodInfo UseGridBucketSolverMethod =
|
||||
EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic);
|
||||
|
||||
private static readonly FieldInfo EntitySyncField =
|
||||
SimulationWorldType?.GetField("_entitySync", NonPublicInstance);
|
||||
|
||||
private static readonly FieldInfo PresentationField =
|
||||
SimulationWorldType?.GetField("_presentation", NonPublicInstance);
|
||||
|
||||
private static readonly PropertyInfo EnemiesProperty =
|
||||
SimulationWorldType?.GetProperty("Enemies", PublicInstance);
|
||||
|
||||
private GameObject _worldGameObject;
|
||||
private Component _worldComponent;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
Assert.NotNull(SimulationWorldType, "SimulationWorld type lookup failed.");
|
||||
Assert.NotNull(SimulationTickContextType, "SimulationTickContext type lookup failed.");
|
||||
Assert.NotNull(EnemySimDataType, "EnemySimData type lookup failed.");
|
||||
Assert.NotNull(EnemySeparationSolverProviderType, "EnemySeparationSolverProvider type lookup failed.");
|
||||
Assert.NotNull(UpsertEnemyMethod, "UpsertEnemy reflection lookup failed.");
|
||||
Assert.NotNull(RemoveEnemyByEntityIdMethod, "RemoveEnemyByEntityId reflection lookup failed.");
|
||||
Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed.");
|
||||
Assert.NotNull(TickMethod, "Tick reflection lookup failed.");
|
||||
Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement reflection lookup failed.");
|
||||
Assert.NotNull(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed.");
|
||||
Assert.NotNull(EnemiesProperty, "Enemies property reflection lookup failed.");
|
||||
|
||||
_worldGameObject = new GameObject("SimulationWorldTickTests");
|
||||
_worldComponent = _worldGameObject.AddComponent(SimulationWorldType);
|
||||
SetUseSimulationMovementMethod.Invoke(_worldComponent, new object[] { true });
|
||||
UseGridBucketSolverMethod.Invoke(null, new object[] { 1f });
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (_worldComponent != null)
|
||||
{
|
||||
EntitySyncField?.SetValue(_worldComponent, null);
|
||||
PresentationField?.SetValue(_worldComponent, null);
|
||||
}
|
||||
|
||||
if (_worldGameObject != null)
|
||||
{
|
||||
Object.DestroyImmediate(_worldGameObject);
|
||||
}
|
||||
|
||||
_worldComponent = null;
|
||||
_worldGameObject = null;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TickEnemies_ChasesPlayer_WhenOutOfAttackRange()
|
||||
{
|
||||
UpsertEnemy(CreateEnemy(entityId: 1001, 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));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TickEnemies_StopsMovement_WhenInAttackRange()
|
||||
{
|
||||
Vector3 startPosition = Vector3.zero;
|
||||
UpsertEnemy(CreateEnemy(entityId: 1002, position: startPosition, speed: 3f, attackRange: 2f));
|
||||
|
||||
InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(1f, 0f, 0f));
|
||||
|
||||
object enemy = GetEnemyAt(0);
|
||||
Assert.That((int)GetField(enemy, "State"), Is.EqualTo(2));
|
||||
Vector3 position = (Vector3)GetField(enemy, "Position");
|
||||
Assert.That(position.x, Is.EqualTo(startPosition.x).Within(0.0001f));
|
||||
Assert.That(position.y, Is.EqualTo(startPosition.y).Within(0.0001f));
|
||||
Assert.That(position.z, Is.EqualTo(startPosition.z).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemoveEnemyByEntityId_RemapIndex_ForMovedEnemy()
|
||||
{
|
||||
UpsertEnemy(CreateEnemy(entityId: 2001, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 1f));
|
||||
UpsertEnemy(CreateEnemy(entityId: 2002, position: new Vector3(2f, 0f, 0f), speed: 1f, attackRange: 1f));
|
||||
UpsertEnemy(CreateEnemy(entityId: 2003, position: new Vector3(4f, 0f, 0f), speed: 1f, attackRange: 1f));
|
||||
|
||||
bool removed = RemoveEnemyByEntityId(2002);
|
||||
bool removedEntityExists = TryGetEnemyData(2002, out _);
|
||||
bool movedEntityExists = TryGetEnemyData(2003, out object movedEnemy);
|
||||
|
||||
Assert.IsTrue(removed);
|
||||
Assert.That(GetEnemiesCount(), Is.EqualTo(2));
|
||||
Assert.IsFalse(removedEntityExists);
|
||||
Assert.IsTrue(movedEntityExists);
|
||||
Assert.That((int)GetField(movedEnemy, "EntityId"), Is.EqualTo(2003));
|
||||
Assert.That((int)GetField(GetEnemyAt(1), "EntityId"), Is.EqualTo(2003));
|
||||
}
|
||||
|
||||
private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange)
|
||||
{
|
||||
object enemy = System.Activator.CreateInstance(EnemySimDataType);
|
||||
SetField(ref enemy, "EntityId", entityId);
|
||||
SetField(ref enemy, "Position", position);
|
||||
SetField(ref enemy, "Forward", Vector3.forward);
|
||||
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, "TargetType", 0);
|
||||
SetField(ref enemy, "State", 0);
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private void InvokeTick(float deltaTime, float realDeltaTime, Vector3 playerPosition)
|
||||
{
|
||||
object tickContext = System.Activator.CreateInstance(
|
||||
SimulationTickContextType,
|
||||
BindingFlags.Public | BindingFlags.Instance,
|
||||
null,
|
||||
new object[] { deltaTime, realDeltaTime, playerPosition },
|
||||
null);
|
||||
|
||||
TickMethod.Invoke(_worldComponent, new[] { tickContext });
|
||||
}
|
||||
|
||||
private void UpsertEnemy(object enemy)
|
||||
{
|
||||
UpsertEnemyMethod.Invoke(_worldComponent, new[] { enemy });
|
||||
}
|
||||
|
||||
private bool RemoveEnemyByEntityId(int entityId)
|
||||
{
|
||||
return (bool)RemoveEnemyByEntityIdMethod.Invoke(_worldComponent, new object[] { entityId });
|
||||
}
|
||||
|
||||
private bool TryGetEnemyData(int entityId, out object enemyData)
|
||||
{
|
||||
object boxedDefault = System.Activator.CreateInstance(EnemySimDataType);
|
||||
object[] parameters = { entityId, boxedDefault };
|
||||
bool result = (bool)TryGetEnemyDataMethod.Invoke(_worldComponent, parameters);
|
||||
enemyData = parameters[1];
|
||||
return result;
|
||||
}
|
||||
|
||||
private object GetEnemyAt(int index)
|
||||
{
|
||||
object enemies = EnemiesProperty.GetValue(_worldComponent);
|
||||
PropertyInfo itemProperty = enemies.GetType().GetProperty("Item", PublicInstance);
|
||||
return itemProperty.GetValue(enemies, new object[] { index });
|
||||
}
|
||||
|
||||
private int GetEnemiesCount()
|
||||
{
|
||||
object enemies = EnemiesProperty.GetValue(_worldComponent);
|
||||
PropertyInfo countProperty = enemies.GetType().GetProperty("Count", PublicInstance);
|
||||
return (int)countProperty.GetValue(enemies);
|
||||
}
|
||||
|
||||
private static object GetField(object target, string fieldName)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(fieldName, PublicInstance);
|
||||
return field.GetValue(target);
|
||||
}
|
||||
|
||||
private static void SetField(ref object target, string fieldName, object value)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(fieldName, PublicInstance);
|
||||
field.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6da65404acbb4cb3acc082171f76a5a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 7f1f9d2cc3fbc2d4ea5cac4fd488b72f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "Simulation.PlayModeTests",
|
||||
"references": [],
|
||||
"optionalUnityReferences": [
|
||||
"TestAssemblies"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": []
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: efb31cfd979449f6b1f6f2f23e6ad5ce
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
namespace Simulation.Tests.PlayMode
|
||||
{
|
||||
public class SimulationWorldPlayModeTests
|
||||
{
|
||||
private const string GameAssemblyName = "Assembly-CSharp";
|
||||
private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static;
|
||||
private const BindingFlags PublicInstance = BindingFlags.Public | BindingFlags.Instance;
|
||||
private const BindingFlags NonPublicInstance = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly System.Type SimulationWorldType =
|
||||
System.Type.GetType($"Simulation.SimulationWorld, {GameAssemblyName}");
|
||||
|
||||
private static readonly System.Type SimulationTickContextType =
|
||||
System.Type.GetType($"Simulation.SimulationTickContext, {GameAssemblyName}");
|
||||
|
||||
private static readonly System.Type EnemySimDataType =
|
||||
System.Type.GetType($"Simulation.EnemySimData, {GameAssemblyName}");
|
||||
|
||||
private static readonly System.Type EnemySeparationSolverProviderType =
|
||||
System.Type.GetType($"CustomUtility.EnemySeparationSolverProvider, {GameAssemblyName}");
|
||||
|
||||
private static readonly MethodInfo UpsertEnemyMethod =
|
||||
SimulationWorldType?.GetMethod("UpsertEnemy", NonPublicInstance);
|
||||
|
||||
private static readonly MethodInfo RemoveEnemyByEntityIdMethod =
|
||||
SimulationWorldType?.GetMethod("RemoveEnemyByEntityId", NonPublicInstance);
|
||||
|
||||
private static readonly MethodInfo TryGetEnemyDataMethod =
|
||||
SimulationWorldType?.GetMethod("TryGetEnemyData", NonPublicInstance);
|
||||
|
||||
private static readonly MethodInfo TickMethod =
|
||||
SimulationWorldType?.GetMethod("Tick", PublicInstance);
|
||||
|
||||
private static readonly MethodInfo SetUseSimulationMovementMethod =
|
||||
SimulationWorldType?.GetMethod("SetUseSimulationMovement", PublicInstance);
|
||||
|
||||
private static readonly MethodInfo UseGridBucketSolverMethod =
|
||||
EnemySeparationSolverProviderType?.GetMethod("UseGridBucketSolver", PublicStatic);
|
||||
|
||||
private static readonly FieldInfo EntitySyncField =
|
||||
SimulationWorldType?.GetField("_entitySync", NonPublicInstance);
|
||||
|
||||
private static readonly FieldInfo PresentationField =
|
||||
SimulationWorldType?.GetField("_presentation", NonPublicInstance);
|
||||
|
||||
private static readonly PropertyInfo EnemiesProperty =
|
||||
SimulationWorldType?.GetProperty("Enemies", PublicInstance);
|
||||
|
||||
private GameObject _worldGameObject;
|
||||
private Component _worldComponent;
|
||||
|
||||
[UnitySetUp]
|
||||
public IEnumerator SetUp()
|
||||
{
|
||||
Assert.NotNull(SimulationWorldType, "SimulationWorld type lookup failed.");
|
||||
Assert.NotNull(SimulationTickContextType, "SimulationTickContext type lookup failed.");
|
||||
Assert.NotNull(EnemySimDataType, "EnemySimData type lookup failed.");
|
||||
Assert.NotNull(EnemySeparationSolverProviderType, "EnemySeparationSolverProvider type lookup failed.");
|
||||
Assert.NotNull(UpsertEnemyMethod, "UpsertEnemy reflection lookup failed.");
|
||||
Assert.NotNull(RemoveEnemyByEntityIdMethod, "RemoveEnemyByEntityId reflection lookup failed.");
|
||||
Assert.NotNull(TryGetEnemyDataMethod, "TryGetEnemyData reflection lookup failed.");
|
||||
Assert.NotNull(TickMethod, "Tick reflection lookup failed.");
|
||||
Assert.NotNull(SetUseSimulationMovementMethod, "SetUseSimulationMovement reflection lookup failed.");
|
||||
Assert.NotNull(UseGridBucketSolverMethod, "UseGridBucketSolver reflection lookup failed.");
|
||||
Assert.NotNull(EnemiesProperty, "Enemies property reflection lookup failed.");
|
||||
|
||||
_worldGameObject = new GameObject("SimulationWorldPlayModeTests");
|
||||
_worldComponent = _worldGameObject.AddComponent(SimulationWorldType);
|
||||
|
||||
// Isolate PlayMode regression to simulation behavior only.
|
||||
EntitySyncField?.SetValue(_worldComponent, null);
|
||||
PresentationField?.SetValue(_worldComponent, null);
|
||||
|
||||
SetUseSimulationMovementMethod.Invoke(_worldComponent, new object[] { true });
|
||||
UseGridBucketSolverMethod.Invoke(null, new object[] { 1f });
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTearDown]
|
||||
public IEnumerator TearDown()
|
||||
{
|
||||
if (_worldComponent != null)
|
||||
{
|
||||
EntitySyncField?.SetValue(_worldComponent, null);
|
||||
PresentationField?.SetValue(_worldComponent, null);
|
||||
}
|
||||
|
||||
if (_worldGameObject != null)
|
||||
{
|
||||
Object.Destroy(_worldGameObject);
|
||||
}
|
||||
|
||||
_worldComponent = null;
|
||||
_worldGameObject = null;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TickEnemies_ChasesPlayer_WhenOutOfAttackRange()
|
||||
{
|
||||
UpsertEnemy(CreateEnemy(entityId: 3001, 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;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator TickEnemies_StopsMovement_WhenInAttackRange()
|
||||
{
|
||||
Vector3 startPosition = Vector3.zero;
|
||||
UpsertEnemy(CreateEnemy(entityId: 3002, position: startPosition, speed: 3f, attackRange: 2f));
|
||||
|
||||
InvokeTick(deltaTime: 1f, realDeltaTime: 1f, playerPosition: new Vector3(1f, 0f, 0f));
|
||||
|
||||
object enemy = GetEnemyAt(0);
|
||||
Assert.That((int)GetField(enemy, "State"), Is.EqualTo(2));
|
||||
Vector3 position = (Vector3)GetField(enemy, "Position");
|
||||
Assert.That(position.x, Is.EqualTo(startPosition.x).Within(0.0001f));
|
||||
Assert.That(position.y, Is.EqualTo(startPosition.y).Within(0.0001f));
|
||||
Assert.That(position.z, Is.EqualTo(startPosition.z).Within(0.0001f));
|
||||
yield break;
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator RemoveEnemyByEntityId_RemapIndex_ForMovedEnemy()
|
||||
{
|
||||
UpsertEnemy(CreateEnemy(entityId: 3101, position: new Vector3(0f, 0f, 0f), speed: 1f, attackRange: 1f));
|
||||
UpsertEnemy(CreateEnemy(entityId: 3102, position: new Vector3(2f, 0f, 0f), speed: 1f, attackRange: 1f));
|
||||
UpsertEnemy(CreateEnemy(entityId: 3103, position: new Vector3(4f, 0f, 0f), speed: 1f, attackRange: 1f));
|
||||
|
||||
bool removed = RemoveEnemyByEntityId(3102);
|
||||
bool removedEntityExists = TryGetEnemyData(3102, out _);
|
||||
bool movedEntityExists = TryGetEnemyData(3103, out object movedEnemy);
|
||||
|
||||
Assert.IsTrue(removed);
|
||||
Assert.That(GetEnemiesCount(), Is.EqualTo(2));
|
||||
Assert.IsFalse(removedEntityExists);
|
||||
Assert.IsTrue(movedEntityExists);
|
||||
Assert.That((int)GetField(movedEnemy, "EntityId"), Is.EqualTo(3103));
|
||||
Assert.That((int)GetField(GetEnemyAt(1), "EntityId"), Is.EqualTo(3103));
|
||||
yield break;
|
||||
}
|
||||
|
||||
private object CreateEnemy(int entityId, Vector3 position, float speed, float attackRange)
|
||||
{
|
||||
object enemy = System.Activator.CreateInstance(EnemySimDataType);
|
||||
SetField(ref enemy, "EntityId", entityId);
|
||||
SetField(ref enemy, "Position", position);
|
||||
SetField(ref enemy, "Forward", Vector3.forward);
|
||||
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, "TargetType", 0);
|
||||
SetField(ref enemy, "State", 0);
|
||||
return enemy;
|
||||
}
|
||||
|
||||
private void InvokeTick(float deltaTime, float realDeltaTime, Vector3 playerPosition)
|
||||
{
|
||||
object tickContext = System.Activator.CreateInstance(
|
||||
SimulationTickContextType,
|
||||
BindingFlags.Public | BindingFlags.Instance,
|
||||
null,
|
||||
new object[] { deltaTime, realDeltaTime, playerPosition },
|
||||
null);
|
||||
|
||||
TickMethod.Invoke(_worldComponent, new[] { tickContext });
|
||||
}
|
||||
|
||||
private void UpsertEnemy(object enemy)
|
||||
{
|
||||
UpsertEnemyMethod.Invoke(_worldComponent, new[] { enemy });
|
||||
}
|
||||
|
||||
private bool RemoveEnemyByEntityId(int entityId)
|
||||
{
|
||||
return (bool)RemoveEnemyByEntityIdMethod.Invoke(_worldComponent, new object[] { entityId });
|
||||
}
|
||||
|
||||
private bool TryGetEnemyData(int entityId, out object enemyData)
|
||||
{
|
||||
object boxedDefault = System.Activator.CreateInstance(EnemySimDataType);
|
||||
object[] parameters = { entityId, boxedDefault };
|
||||
bool result = (bool)TryGetEnemyDataMethod.Invoke(_worldComponent, parameters);
|
||||
enemyData = parameters[1];
|
||||
return result;
|
||||
}
|
||||
|
||||
private object GetEnemyAt(int index)
|
||||
{
|
||||
object enemies = EnemiesProperty.GetValue(_worldComponent);
|
||||
PropertyInfo itemProperty = enemies.GetType().GetProperty("Item", PublicInstance);
|
||||
return itemProperty.GetValue(enemies, new object[] { index });
|
||||
}
|
||||
|
||||
private int GetEnemiesCount()
|
||||
{
|
||||
object enemies = EnemiesProperty.GetValue(_worldComponent);
|
||||
PropertyInfo countProperty = enemies.GetType().GetProperty("Count", PublicInstance);
|
||||
return (int)countProperty.GetValue(enemies);
|
||||
}
|
||||
|
||||
private static object GetField(object target, string fieldName)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(fieldName, PublicInstance);
|
||||
return field.GetValue(target);
|
||||
}
|
||||
|
||||
private static void SetField(ref object target, string fieldName, object value)
|
||||
{
|
||||
FieldInfo field = target.GetType().GetField(fieldName, PublicInstance);
|
||||
field.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9d1958a6d5ed459cb1809b72ad922479
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -80,7 +80,7 @@ PlayerSettings:
|
|||
androidPredictiveBackSupport: 1
|
||||
defaultIsNativeResolution: 1
|
||||
macRetinaSupport: 1
|
||||
runInBackground: 0
|
||||
runInBackground: 1
|
||||
captureSingleScreen: 0
|
||||
muteOtherAudioSources: 0
|
||||
Prepare IOS For Recording: 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
# P1.5 Simulation 收尾说明(P2 输入基线)
|
||||
|
||||
## 测试设备与环境
|
||||
- 设备:iQOO Neo8
|
||||
- CPU:第一代骁龙 8+ 八核
|
||||
- 内存:12 GB
|
||||
- 系统:OriginOS 6(Android 16)
|
||||
- Unity Profiler 口径:以 CPU `ms` 为主,`fps` 仅作辅助(Android 端 60 fps 上限)
|
||||
- Profiler 配置:`Call Stacks = Off`(开启会额外放大 CPU 开销,不纳入本次基线)
|
||||
|
||||
## CPU 分阶段数据(P1.5)
|
||||
| 怪物数量 | 帧率 | MoveSeperation 占比 | BuildInput 占比 | WriteBack 占比 | StateUpdate 占比 |
|
||||
|--------|---------------------|--------------------|------------------|------------------|------------------|
|
||||
| `500` | `60 fps (16.56 ms)` | `25.8% (4.28 ms)` | `1.2% (0.20 ms)` | `1.0% (0.17 ms)` | `0.7% (0.12 ms)` |
|
||||
| `1000` | `44 fps (22.77 ms)` | `38.9% (8.86 ms)` | `1.8% (0.41 ms)` | `1.5% (0.36 ms)` | `1.0% (0.23 ms)` |
|
||||
| `1500` | `33 fps (29.83 ms)` | `46.7% (13.95 ms)` | `2.0% (0.61 ms)` | `1.7% (0.51 ms)` | `1.1% (0.35 ms)` |
|
||||
| `2000` | `19 fps (53.04 ms)` | `43.1% (19.53 ms)` | `2.1% (0.97 ms)` | `1.5% (0.69 ms)` | `1.0% (0.49 ms)` |
|
||||
|
||||
## Memory 对比(P1 -> P1.5)
|
||||
| 怪物数量 | GC Used Memory | GC Allocated In Frame | TickEnemies GC |
|
||||
|--------|------------------|-----------------------|-----------------|
|
||||
| `500` | `7.8 -> 7.9 MB` | `29.5 -> 2.1 KB` | `27.4 -> 0 KB` |
|
||||
| `1000` | `8.8 -> 8.8 MB` | `56.6 -> 2.1 KB` | `54.5 -> 0 KB` |
|
||||
| `1500` | `10.0 -> 9.0 MB` | `84.2 -> 2.1 KB` | `82.1 -> 0 KB` |
|
||||
| `2000` | `11.8 -> 9.9 MB` | `109.7 -> 2.1 KB` | `107.6 -> 0 KB` |
|
||||
|
||||
## CPU 热路径对比(P1 -> P1.5)
|
||||
说明:P1.5 的 `TickEnemies` 以四阶段总和近似对齐 P1 的 `TickEnemies ms`。
|
||||
|
||||
| 怪物数量 | P1 TickEnemies | P1.5 四阶段合计 | 降幅 |
|
||||
|--------|----------------|------------|----------|
|
||||
| `500` | `6.18 ms` | `4.77 ms` | `-22.8%` |
|
||||
| `1000` | `12.80 ms` | `9.86 ms` | `-23.0%` |
|
||||
| `1500` | `20.11 ms` | `15.42 ms` | `-23.3%` |
|
||||
| `2000` | `29.62 ms` | `21.68 ms` | `-26.8%` |
|
||||
|
||||
## 结论(Checkpoint 6)
|
||||
- GC 目标达成:`TickEnemies GC` 在 `500~2000` 敌人数下均为 `0 KB`,满足 `< 5 KB/frame` 目标。
|
||||
- CPU 阶段可观测性达成:`BuildInput -> Move/Separation -> StateUpdate -> WriteBack` 已可稳定采样。
|
||||
- 当前主瓶颈明确:`MoveSeperation` 是绝对热点(约 `43%~47%` 帧占比),P2 优先并行化该阶段。
|
||||
- 评估口径可复现:Android 端受 `60 fps` 上限影响,性能判断以 CPU `ms` 为准。
|
||||
|
||||
## 回滚开关说明
|
||||
- 开关字段:`SimulationWorld._useSimulationMovement`(序列化私有字段)
|
||||
- 对外接口:`UseSimulationMovement` / `SetUseSimulationMovement(bool)`
|
||||
- 回滚方式:将开关置 `false`,敌人立即回退到旧 `MovementComponent` 更新路径。
|
||||
- 验证建议:同场景同刷怪参数下执行 A/B 对比,确认行为一致与性能差异。
|
||||
|
||||
## P2 交接建议
|
||||
- Job/Burst 第一优先级:`MoveSeperation` 阶段并行化。
|
||||
- 保持阶段边界不变:继续维持四阶段管线与 `ProfilerMarker`,避免失去对比口径。
|
||||
- 保持生命周期/索引规则不变:`EntitySync` 与 swap-back/remap 继续作为硬约束。
|
||||
|
|
@ -70,37 +70,37 @@
|
|||
- 敌人移动/追踪由 Simulation 统一调度,不再逐个 Enemy MonoBehaviour 执行核心逻辑。
|
||||
|
||||
## 2.5 P1.5 Simulation 收尾(P2 前置)
|
||||
- [ ] Checkpoint 1:清理 `TickEnemies` 侧 GC(优先级最高)
|
||||
- [x] Checkpoint 1:清理 `TickEnemies` 侧 GC(优先级最高)
|
||||
- 目标:将 `TickEnemies GC` 从当前 `27~108 KB` 降到 `< 5 KB / frame`。
|
||||
- 重点文件:`Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`。
|
||||
- 处理方式:桶容器与临时列表复用(包含 bucket list 复用池),避免每帧重建集合。
|
||||
- 完成标准:`2000` 敌人压测下 `TickEnemies GC` 稳定 `< 5 KB / frame`。
|
||||
|
||||
- [ ] Checkpoint 2:解耦 Simulation 核心与 `Transform` 运行时依赖
|
||||
- [x] Checkpoint 2:解耦 Simulation 核心与 `Transform` 运行时依赖
|
||||
- 目标:`SimulationWorld.TickEnemies` 不直接读取或写入 `Transform`。
|
||||
- 重点文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.cs`、`Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs`、`Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs`。
|
||||
- 处理方式:互斥求解输入改为纯数据(位置/半径/索引),`Transform` 仅在 Presentation 阶段回写。
|
||||
- 完成标准:`TickEnemies` 热路径中不出现 `Transform` 访问。
|
||||
|
||||
- [ ] Checkpoint 3:收口 `EntitySync` 职责边界
|
||||
- [x] Checkpoint 3:收口 `EntitySync` 职责边界
|
||||
- 目标:`EntitySync` 仅处理生命周期映射,不承担运行时移动逻辑。
|
||||
- 重点文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs`。
|
||||
- 处理方式:保留注册/反注册与初值同步,移除 Tick 过程依赖。
|
||||
- 完成标准:`OnShow/OnHide` 逻辑稳定,且不引入运行时分配热点。
|
||||
|
||||
- [ ] Checkpoint 4:拆分 Simulation Tick 阶段,为 Job 化铺路
|
||||
- [x] Checkpoint 4:拆分 Simulation Tick 阶段,为 Job 化铺路
|
||||
- 目标:将敌人 Tick 拆分为稳定阶段,便于后续迁移 `IJobParallelFor`。
|
||||
- 建议阶段:`BuildInput -> Move/Separation -> StateUpdate -> WriteBack`。
|
||||
- 重点文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.cs`。
|
||||
- 完成标准:每阶段有独立 `ProfilerMarker`,可明确观测耗时占比。
|
||||
|
||||
- [ ] Checkpoint 5:补最小回归测试(P1.5 重构保护)
|
||||
- [x] Checkpoint 5:补最小回归测试(P1.5 重构保护)
|
||||
- 目标:确保重构不改变战斗行为。
|
||||
- 建议目录:`Assets/Tests/Simulation/`。
|
||||
- 用例范围:追踪玩家、攻击距离停下、实体移除后的索引重映射。
|
||||
- 完成标准:EditMode/PlayMode 相关用例通过,主流程手测无回归。
|
||||
|
||||
- [ ] Checkpoint 6:补充 P1.5 结项文档
|
||||
- [x] Checkpoint 6:补充 P1.5 结项文档
|
||||
- 输出:`P1.5 收尾说明 + 对比数据 + 回滚开关`。
|
||||
- 明确记录:Android 60fps 上限、Profiler 采样配置(Call Stacks 开关状态)、评估以 CPU ms 为主。
|
||||
- 完成标准:文档可复现实验结论,并可作为 P2 输入基线。
|
||||
|
|
@ -190,3 +190,4 @@
|
|||
- [ ] 回归用例(至少战斗、关卡切换、商店、升级)。
|
||||
- [ ] Profiling 对比(改造前后同场景同参数)。
|
||||
- [ ] 风险与回滚说明(特别是热更新与渲染链路)。
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
name: simulation-development
|
||||
description: Maintain and extend the VampireLike Simulation layer. Use when modifying `Assets/GameMain/Scripts/Simulation` or related runtime paths (`GameStateBattle`, enemy movement gate, entity lifecycle sync, separation solver), including P1.5 cleanup and P2 Job/Burst preparation.
|
||||
---
|
||||
|
||||
# Simulation Development
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Read baseline design doc: `./references/SimulationDevelopmentSkill.md`.
|
||||
2. Read latest measured baseline: `../../docs/P1.5 Simulation-Supplement.md`.
|
||||
3. Confirm your change scope is one or more of:
|
||||
- `SimData` contracts (`EnemySimData`, `ProjectileSimData`, `PickupSimData`)
|
||||
- lifecycle sync (`SimulationWorld.EntitySync`)
|
||||
- per-frame simulation (`SimulationWorld.Tick`, `TickEnemies`)
|
||||
- presentation write-back (`SimulationWorld.Presentation`)
|
||||
- enemy separation solver integration (`EnemySeparationSolverProvider`)
|
||||
4. Keep rollback path available through `UseSimulationMovement`.
|
||||
|
||||
## Source Map
|
||||
|
||||
- Simulation core: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.cs`
|
||||
- Lifecycle sync: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs`
|
||||
- Presentation sync: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs`
|
||||
- Tick context: `../../Assets/GameMain/Scripts/Simulation/SimulationTickContext.cs`
|
||||
- Index binding: `../../Assets/GameMain/Scripts/Simulation/EntityBinding.cs`
|
||||
- Battle entry: `../../Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs`
|
||||
- Global component init: `../../Assets/GameMain/Scripts/Base/GameEntry.Custom.cs`
|
||||
- Enemy old path gate:
|
||||
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/MeleeEnemy.cs`
|
||||
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/RemoteEnemy.cs`
|
||||
- Separation solver:
|
||||
- `../../Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs`
|
||||
- `../../Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs`
|
||||
- `../../Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`
|
||||
- P1.5 baseline doc:
|
||||
- `../../docs/P1.5 Simulation-Supplement.md`
|
||||
|
||||
## Non-Negotiable Invariants
|
||||
|
||||
- Maintain `EntityId <-> SimulationIndex` consistency.
|
||||
- Use swap-back removal (`move last -> remove last -> remap index`).
|
||||
- Keep lifecycle registration/removal inside `EntitySync` event flow; do not double-write containers from gameplay code.
|
||||
- Keep logic/presentation boundary:
|
||||
- Simulation computes logical outputs.
|
||||
- Presentation writes back `Transform`.
|
||||
- Keep A/B rollback path:
|
||||
- `UseSimulationMovement == false` must preserve old behavior path.
|
||||
- Avoid new managed allocations in Tick hot paths.
|
||||
|
||||
## Change Recipes
|
||||
|
||||
### Add or Change SimData Fields
|
||||
|
||||
1. Update target struct in `Simulation/SimData/`.
|
||||
2. Populate default/initial values in `EntitySync` create methods.
|
||||
3. Apply runtime updates in `Tick` phase.
|
||||
4. Consume outputs in `Presentation` only if visual write-back is needed.
|
||||
5. Ensure backward compatibility when `UseSimulationMovement` is off.
|
||||
|
||||
### Extend Enemy Tick Behavior
|
||||
|
||||
1. Keep deterministic stage order (`BuildInput -> Move/Separation -> StateUpdate -> WriteBack`).
|
||||
2. Preserve state semantics and avoid direct UI/event side effects in Tick.
|
||||
3. Keep `ProfilerMarker` coverage for each stage.
|
||||
4. Keep Tick hot path data-driven (no direct `Transform` read/write).
|
||||
|
||||
### Implement Projectile/Pickup Tick (from placeholder to real behavior)
|
||||
|
||||
1. Keep lifecycle path unchanged (`EntitySync` handles add/remove).
|
||||
2. Add dedicated tick methods in `SimulationWorld` for each data type.
|
||||
3. Keep outputs in data containers; write visuals in presentation phase.
|
||||
4. Ensure removal path and binding remap rules are identical to enemy path.
|
||||
|
||||
### Refactor Toward Job/Burst
|
||||
|
||||
1. Prioritize `Move/Separation` stage parallelization.
|
||||
2. Keep `ProfilerMarker` and stage boundaries stable for P1.5/P2 comparison.
|
||||
3. Leave transform write-back to presentation-only stage.
|
||||
4. Keep managed allocations and virtual dispatch out of core loops.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- `UseSimulationMovement = false` and `true` both run correctly.
|
||||
- No duplicate registration or stale index after entity hide/destroy.
|
||||
- Battle loop remains stable (`Battle -> LevelUp -> Shop -> Battle`).
|
||||
- No new per-frame GC spikes in `TickEnemies`.
|
||||
- Main flow has no new Error/Exception logs.
|
||||
- Update `./references/SimulationDevelopmentSkill.md` and `../../docs/P1.5 Simulation-Supplement.md` if contracts, boundaries, or baseline data changed.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
interface:
|
||||
display_name: "Simulation Development"
|
||||
short_description: "Maintain and extend VampireLike Simulation architecture"
|
||||
default_prompt: "Use $simulation-development to implement and validate a Simulation layer change with rollback safety."
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
# Simulation Development Skill(VampireLike)
|
||||
|
||||
## 目标
|
||||
本文件是 `Simulation` 分层的开发规范与速查手册。
|
||||
后续调整敌人移动、补齐投射物/掉落物逻辑、推进 Job/Burst 改造时,优先按本文档执行,避免反复通读全部代码。
|
||||
|
||||
## 当前架构总览(P1.5 已落地)
|
||||
- Simulation 主目录:`Assets/GameMain/Scripts/Simulation/`
|
||||
- 核心组件:`SimulationWorld`(`GameFrameworkComponent`)
|
||||
- 数据容器:
|
||||
- `List<EnemySimData> _enemies`
|
||||
- `List<ProjectileSimData> _projectiles`
|
||||
- `List<PickupSimData> _pickups`
|
||||
- Tick 临时缓冲:
|
||||
- `List<EnemyTickWorkItem> _enemyTickWorkItems`
|
||||
- `List<EnemySeparationAgent> _enemySeparationAgents`
|
||||
- 索引绑定:`EntityBinding`(`EntityId <-> SimulationIndex` 双向映射)
|
||||
- 生命周期同步:`SimulationWorld.EntitySync`(监听实体 Show/Hide 事件)
|
||||
- 表现层回写:`SimulationWorld.Presentation`(`LateUpdate` 写回 `Transform`)
|
||||
- Tick 上下文:`SimulationTickContext`(`DeltaTime`、`RealDeltaTime`、`PlayerPosition`)
|
||||
|
||||
## 运行时主链路(按帧)
|
||||
1. `GameEntry.InitCustomComponents()` 获取或自动挂载 `SimulationWorld`
|
||||
文件:`Assets/GameMain/Scripts/Base/GameEntry.Custom.cs`
|
||||
2. `ProcedureGame.OnEnter()` 清理旧 Simulation 数据
|
||||
文件:`Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs`
|
||||
3. `GameStateBattle.OnUpdate()` 中先执行刷怪,再执行 `SimulationWorld.Tick(...)`
|
||||
文件:`Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs`
|
||||
4. `SimulationWorld.Tick()` 仅在 `UseSimulationMovement == true` 时执行敌人 Tick
|
||||
5. `SimulationWorld.LateUpdate()` 执行 `Presentation.OnLateUpdate()`,将仿真结果写回敌人 `Transform`
|
||||
|
||||
## 生命周期与数据同步设计
|
||||
`EntitySync` 通过事件驱动保持 Simulation 容器与实体生命周期一致:
|
||||
|
||||
| 事件 | 组名 | 行为 |
|
||||
|---|---|---|
|
||||
| `ShowEntitySuccessEventArgs` | `Enemy` | `RegisterEnemyLifecycle` + `UpsertEnemy` |
|
||||
| `HideEntityCompleteEventArgs` | `Enemy` | `UnregisterEnemyLifecycle` + `RemoveEnemyByEntityId` |
|
||||
| `ShowEntitySuccessEventArgs` | `Drop` | `RegisterPickupLifecycle` + `UpsertPickup` |
|
||||
| `HideEntityCompleteEventArgs` | `Drop` | `UnregisterPickupLifecycle` + `RemovePickupByEntityId` |
|
||||
| `ShowEntitySuccessEventArgs` | `Bullet` / `Projectile` | `RegisterProjectileLifecycle` + `UpsertProjectile` |
|
||||
| `HideEntityCompleteEventArgs` | `Bullet` / `Projectile` | `UnregisterProjectileLifecycle` + `RemoveProjectileByEntityId` |
|
||||
|
||||
关键规则:
|
||||
- 删除容器元素统一使用“末尾覆盖 + `RemoveAt(lastIndex)` + `EntityBinding.RemapIndex`”。
|
||||
- `Upsert` 语义:`EntityId` 已存在则覆盖,不存在则追加。
|
||||
|
||||
## EnemySimData 合约(当前实现)
|
||||
文件:`Assets/GameMain/Scripts/Simulation/SimData/EnemySimData.cs`
|
||||
|
||||
- `EntityId`:实体唯一标识
|
||||
- `Position / Forward / Rotation`:逻辑输出与表现层写回字段
|
||||
- `Speed`:来自 `EnemyData.SpeedBase`
|
||||
- `AttackRange`:当前固定初始化为 `1f`
|
||||
- `AvoidEnemyOverlap / EnemyBodyRadius / SeparationIterations`:从 `MovementComponent` 读取
|
||||
- `TargetType / State`:状态扩展预留
|
||||
|
||||
当前状态值(`SimulationWorld` 常量):
|
||||
- `0`:Idle
|
||||
- `1`:Chasing
|
||||
- `2`:InAttackRange
|
||||
|
||||
## TickEnemies 当前算法(P1.5 分阶段)
|
||||
文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.cs`
|
||||
|
||||
`TickEnemies` 入口保持纯数据热路径,不直接读写 `Transform`,按四阶段执行:
|
||||
1. `BuildInput`
|
||||
- 计算到玩家平面距离
|
||||
- 产出 `EnemyTickWorkItem`
|
||||
- 生成分离输入 `EnemySeparationAgent`
|
||||
2. `Move/Separation`
|
||||
- 计算追踪位移与朝向
|
||||
- 通过 `EnemySeparationSolverProvider.ResolveSimulation(...)` 做互斥求解
|
||||
3. `StateUpdate`
|
||||
- 按距离与可追逐状态更新 `Idle/Chasing/InAttackRange`
|
||||
4. `WriteBack`
|
||||
- 回写 `EnemySimData`(`Position/Forward/Rotation/State`)
|
||||
|
||||
## 互斥求解器双通道(Legacy + Simulation)
|
||||
文件:
|
||||
- `Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs`
|
||||
- `Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs`
|
||||
- `Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`
|
||||
|
||||
说明:
|
||||
- `SetSimulationAgents/ResolveSimulation`:供 Simulation 纯数据路径调用。
|
||||
- `Register/Unregister/Resolve(Transform, ...)`:保留旧路径兼容与回滚能力。
|
||||
- `GridBucketEnemySeparationSolver` 已加入桶列表复用池(`_bucketListPool`)以降低 GC。
|
||||
|
||||
## 表现层回写(Presentation)规则
|
||||
文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs`
|
||||
|
||||
- 仅在 `UseSimulationMovement == true` 时执行
|
||||
- 遍历 `EnemyManager.Enemies`,按 `EntityId` 查找 `EnemySimData`
|
||||
- 写回顺序:
|
||||
- 始终写回 `position`
|
||||
- 优先使用 `rotation`
|
||||
- 若 `rotation` 无效,则回退到 `forward`
|
||||
|
||||
## 与旧移动系统的关系(重要)
|
||||
- `MeleeEnemy` / `RemoteEnemy` 在 `OnUpdate` 开头门控:
|
||||
- 开启 Simulation:直接 `return`
|
||||
- 关闭 Simulation:走旧 `MovementComponent`
|
||||
- 回滚能力来自同一构建内的 `UseSimulationMovement` A/B 开关。
|
||||
|
||||
## Projectile / Pickup 现状
|
||||
- `ProjectileSimData`、`PickupSimData` 已具备容器、绑定与生命周期同步通道
|
||||
- 当前仍未接入独立 Tick 行为,仅完成“创建/回收/索引同步”占位目标
|
||||
|
||||
## P1.5 实测基线(P2 输入)
|
||||
基线文档:`docs/P1.5 Simulation-Supplement.md`
|
||||
|
||||
关键结论:
|
||||
- `TickEnemies GC` 在 `500/1000/1500/2000` 敌人数下均为 `0 KB`
|
||||
- `GC Allocated In Frame` 从 P1 的 `29.5~109.7 KB` 降至 `2.1 KB`
|
||||
- `TickEnemies` 热路径耗时(四阶段合计)对比 P1 降幅约 `22.8%~26.8%`
|
||||
- Android 端评估以 CPU `ms` 为主,`fps` 受 60 上限影响
|
||||
|
||||
## 自动化回归(P1.5 已补)
|
||||
目录:`Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs`
|
||||
|
||||
覆盖点:
|
||||
- 敌人追踪玩家
|
||||
- 进入攻击距离后停止移动
|
||||
- 实体移除后的索引 remap 稳定性
|
||||
|
||||
## 后续扩展规范(必须遵守)
|
||||
1. 先扩数据,再扩行为
|
||||
先在 `SimData` 增字段,再改 `EntitySync` 初始化与 `Tick` 逻辑,最后改表现层消费。
|
||||
|
||||
2. 保留 A/B 路径
|
||||
任何迁移都必须可在同一构建内通过开关回退到旧路径。
|
||||
|
||||
3. 生命周期只走 EntitySync
|
||||
禁止在敌人业务代码中手动改写 Simulation 容器,避免双写导致索引错乱。
|
||||
|
||||
4. 维持“逻辑输出 / 表现消费”边界
|
||||
Simulation 只产出逻辑结果,不直接触发 UI、特效、音频事件。
|
||||
|
||||
5. 删除策略统一用 swap-back
|
||||
所有 Simulation 容器删除都必须 remap 索引,严禁 `RemoveAt(i)` 直接删中间项。
|
||||
|
||||
6. 热路径禁用托管分配
|
||||
`TickEnemies`、互斥求解、阶段化循环里禁止 LINQ/临时集合扩张。
|
||||
|
||||
## P2 前的已知技术债
|
||||
- `AttackRange` 目前固定值 `1f`,尚未由配置化数值驱动
|
||||
- `EnemySimData.TargetType/State` 语义仍偏轻量,未形成完整状态机合约
|
||||
- Projectile/Pickup 尚未迁移真实 Tick 行为
|
||||
|
||||
## 提交前检查清单
|
||||
- 是否保持了 `UseSimulationMovement` 关闭时行为不变
|
||||
- 是否保持了 `EntityId <-> SimulationIndex` 一致性(含移除 remap)
|
||||
- 是否避免在 Tick 热路径引入新 GC
|
||||
- 是否将新字段接入了 `EntitySync -> Tick -> Presentation` 全链路
|
||||
- 是否补充了最小回归验证(至少 Battle 循环、敌人移除、索引稳定性)
|
||||
- 是否同步更新本 Skill 文档与 `docs/P1.5 Simulation-Supplement.md`
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
---
|
||||
name: weapon-development
|
||||
description: Develop and extend the VampireLike weapon system. Use when creating new weapons, updating weapon state machines, changing target selection/effects/data parsing, or integrating weapon behavior with shop, inventory, and entity flow.
|
||||
---
|
||||
|
||||
# Weapon Development
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Read full baseline spec: `./references/WeaponDevelopmentSkill.md`.
|
||||
2. Confirm change scope:
|
||||
- weapon runtime (`WeaponBase`, concrete weapons)
|
||||
- state flow (`Idle`, `Check_OutRange`, `Check_InRange`, `Attack`)
|
||||
- target selector (`ITargetSelector`, `TargetSelectorType`)
|
||||
- effect layer (`IWeaponAttackEffect`)
|
||||
- data contract (`DRWeapon`, `WeaponData`)
|
||||
3. Keep behavior compatibility with current gameplay loop and UI/event chain.
|
||||
|
||||
## Source Map
|
||||
|
||||
- Weapon base: `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs`
|
||||
- Existing weapons:
|
||||
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponKnife/`
|
||||
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponHandgun/`
|
||||
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponSlash.cs`
|
||||
- Selectors:
|
||||
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/`
|
||||
- Weapon data:
|
||||
- `../../Assets/GameMain/Scripts/Entity/EntityData/Weapon/`
|
||||
- Entity show flow:
|
||||
- `../../Assets/GameMain/Scripts/Entity/EntityExtension.cs`
|
||||
|
||||
## Non-Negotiable Invariants
|
||||
|
||||
- Do not duplicate logic already owned by `WeaponBase`.
|
||||
- Keep state transitions explicit and non-blocking.
|
||||
- Keep cooldown accumulation valid even when no target is found.
|
||||
- Keep attack logic and visual effect logic decoupled.
|
||||
- Use safe parsing (`TryParse` + defaults) for runtime weapon parameters.
|
||||
- Preserve compatibility with shop/inventory/UI refresh flow.
|
||||
|
||||
## Change Recipes
|
||||
|
||||
### Add a New Weapon
|
||||
|
||||
1. Extend `WeaponType` without reordering existing enum values.
|
||||
2. Add `WeaponXxxData : WeaponData` for strong-typed fields.
|
||||
3. Add `WeaponXxx : WeaponBase` and implement only weapon-specific behavior.
|
||||
4. Build state files under `Weapon/WeaponXxx/` with partial class layout.
|
||||
5. Register display/data mapping path so `ShowWeapon` can instantiate correctly.
|
||||
|
||||
### Add or Update a Target Selector
|
||||
|
||||
1. Implement `ITargetSelector`.
|
||||
2. Update `TargetSelectorType`.
|
||||
3. Register creation in `WeaponBase.CreateSelector`.
|
||||
4. Validate target semantics with current-health rules where applicable.
|
||||
|
||||
### Add or Update Attack Effect
|
||||
|
||||
1. Implement `IWeaponAttackEffect`.
|
||||
2. Trigger effect from weapon attack state only.
|
||||
3. Keep damage resolution outside effect code.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- Weapon can be shown, attached, and updated without exceptions.
|
||||
- State machine does not stall across target loss/reacquire.
|
||||
- Cooldown and range checks match design expectation.
|
||||
- Damage path and effect path remain decoupled.
|
||||
- UI/shop/inventory interactions stay stable after the change.
|
||||
- Update `./references/WeaponDevelopmentSkill.md` if contracts changed.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
interface:
|
||||
display_name: "Weapon Development"
|
||||
short_description: "Build and extend VampireLike weapon architecture"
|
||||
default_prompt: "Use $weapon-development to implement a weapon system change with stable state flow and data contracts."
|
||||
Loading…
Reference in New Issue