diff --git a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs index c1e4241..a9445ca 100644 --- a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs +++ b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs @@ -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"); diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs index fa5bdab..f3344aa 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs @@ -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() : 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 - }; - } } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs index ecb27dd..c263698 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs @@ -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; @@ -13,6 +15,24 @@ namespace Simulation private const int EnemyStateIdle = 0; 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; @@ -22,7 +42,8 @@ namespace Simulation private readonly List _enemies = new List(); private readonly List _projectiles = new List(); private readonly List _pickups = new List(); - private readonly Dictionary _enemyTransforms = new Dictionary(); + private readonly List _enemySeparationAgents = new List(); + private readonly List _enemyTickWorkItems = new List(); 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(); + + 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 + }; + } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs b/Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs index 619b08b..040137f 100644 --- a/Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs +++ b/Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs @@ -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 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 LegacyRegistrations = new(); + private static readonly List LegacyAgents = new(); + private static readonly List 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 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); + } + } } diff --git a/Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs b/Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs index 1b66b03..f83e050 100644 --- a/Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs +++ b/Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs @@ -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 _agents = new(); - - private readonly System.Collections.Generic.Dictionary> - _buckets = new(); - - private readonly System.Collections.Generic.List _recycle = new(); + private readonly System.Collections.Generic.Dictionary _agents = new(); + private readonly System.Collections.Generic.Dictionary> _buckets = new(); + private readonly System.Collections.Generic.Stack> _bucketListPool = new(); + private readonly System.Collections.Generic.List _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 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(8); + list = _bucketListPool.Count > 0 + ? _bucketListPool.Pop() + : new System.Collections.Generic.List(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; } } - } diff --git a/Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs b/Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs index a304a49..0d002f1 100644 --- a/Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs +++ b/Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs @@ -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 agents); + Vector3 Resolve(int agentId, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations); } } diff --git a/Assets/GameMain/Scripts/Utility/EnemySeperator/NaiveEnemySeparationSolver.cs b/Assets/GameMain/Scripts/Utility/EnemySeperator/NaiveEnemySeparationSolver.cs index 63e9256..8d198cf 100644 --- a/Assets/GameMain/Scripts/Utility/EnemySeperator/NaiveEnemySeparationSolver.cs +++ b/Assets/GameMain/Scripts/Utility/EnemySeperator/NaiveEnemySeparationSolver.cs @@ -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 _agents = new(); - private readonly System.Collections.Generic.List _agentKeys = new(); + private readonly System.Collections.Generic.Dictionary _agents = new(); + private readonly System.Collections.Generic.List _agentKeys = new(); - public void Register(Transform transform, float bodyRadius) + public void SetAgents(System.Collections.Generic.IReadOnlyList 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; diff --git a/Assets/Tests.meta b/Assets/Tests.meta new file mode 100644 index 0000000..000ccaf --- /dev/null +++ b/Assets/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3d51d9ea4e1d4d84aa3fa2e38d79b2f4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Simulation.meta b/Assets/Tests/Simulation.meta new file mode 100644 index 0000000..22fa433 --- /dev/null +++ b/Assets/Tests/Simulation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7c2d2681dd4b47f48b0f6a8185e4fa2b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Simulation/EditMode.meta b/Assets/Tests/Simulation/EditMode.meta new file mode 100644 index 0000000..6df1d38 --- /dev/null +++ b/Assets/Tests/Simulation/EditMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9b8f4bce7ec54a5d8e53d7d4e6f8d12a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef b/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef new file mode 100644 index 0000000..36ab93f --- /dev/null +++ b/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef @@ -0,0 +1,11 @@ +{ + "name": "Simulation.EditModeTests", + "references": [], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [] +} diff --git a/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef.meta b/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef.meta new file mode 100644 index 0000000..0f5840a --- /dev/null +++ b/Assets/Tests/Simulation/EditMode/Simulation.EditModeTests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4f4b677269d9466da1b7f4b3d33895f7 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs new file mode 100644 index 0000000..444d9be --- /dev/null +++ b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs @@ -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); + } + } +} diff --git a/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs.meta b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs.meta new file mode 100644 index 0000000..431eec6 --- /dev/null +++ b/Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6da65404acbb4cb3acc082171f76a5a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Simulation/PlayMode.meta b/Assets/Tests/Simulation/PlayMode.meta new file mode 100644 index 0000000..536c5b3 --- /dev/null +++ b/Assets/Tests/Simulation/PlayMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7f1f9d2cc3fbc2d4ea5cac4fd488b72f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef b/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef new file mode 100644 index 0000000..d55509a --- /dev/null +++ b/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef @@ -0,0 +1,9 @@ +{ + "name": "Simulation.PlayModeTests", + "references": [], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "includePlatforms": [], + "excludePlatforms": [] +} diff --git a/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef.meta b/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef.meta new file mode 100644 index 0000000..97ed02a --- /dev/null +++ b/Assets/Tests/Simulation/PlayMode/Simulation.PlayModeTests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: efb31cfd979449f6b1f6f2f23e6ad5ce +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs new file mode 100644 index 0000000..7aeb397 --- /dev/null +++ b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs @@ -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); + } + } +} diff --git a/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs.meta b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs.meta new file mode 100644 index 0000000..bb8cc1a --- /dev/null +++ b/Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d1958a6d5ed459cb1809b72ad922479 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index 30ac6af..ceef918 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -80,7 +80,7 @@ PlayerSettings: androidPredictiveBackSupport: 1 defaultIsNativeResolution: 1 macRetinaSupport: 1 - runInBackground: 0 + runInBackground: 1 captureSingleScreen: 0 muteOtherAudioSources: 0 Prepare IOS For Recording: 0 diff --git a/docs/P1.5 Simulation-Supplement.md b/docs/P1.5 Simulation-Supplement.md new file mode 100644 index 0000000..8bf72ea --- /dev/null +++ b/docs/P1.5 Simulation-Supplement.md @@ -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 继续作为硬约束。 diff --git a/docs/TodoList.md b/docs/TodoList.md index 695c682..cf39345 100644 --- a/docs/TodoList.md +++ b/docs/TodoList.md @@ -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 对比(改造前后同场景同参数)。 - [ ] 风险与回滚说明(特别是热更新与渲染链路)。 + diff --git a/skills/simulation-development/SKILL.md b/skills/simulation-development/SKILL.md new file mode 100644 index 0000000..1a143a3 --- /dev/null +++ b/skills/simulation-development/SKILL.md @@ -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. diff --git a/skills/simulation-development/agents/openai.yaml b/skills/simulation-development/agents/openai.yaml new file mode 100644 index 0000000..f35a135 --- /dev/null +++ b/skills/simulation-development/agents/openai.yaml @@ -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." diff --git a/skills/simulation-development/references/SimulationDevelopmentSkill.md b/skills/simulation-development/references/SimulationDevelopmentSkill.md new file mode 100644 index 0000000..87fb71c --- /dev/null +++ b/skills/simulation-development/references/SimulationDevelopmentSkill.md @@ -0,0 +1,157 @@ +# Simulation Development Skill(VampireLike) + +## 目标 +本文件是 `Simulation` 分层的开发规范与速查手册。 +后续调整敌人移动、补齐投射物/掉落物逻辑、推进 Job/Burst 改造时,优先按本文档执行,避免反复通读全部代码。 + +## 当前架构总览(P1.5 已落地) +- Simulation 主目录:`Assets/GameMain/Scripts/Simulation/` +- 核心组件:`SimulationWorld`(`GameFrameworkComponent`) +- 数据容器: + - `List _enemies` + - `List _projectiles` + - `List _pickups` +- Tick 临时缓冲: + - `List _enemyTickWorkItems` + - `List _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` diff --git a/skills/weapon-development/SKILL.md b/skills/weapon-development/SKILL.md new file mode 100644 index 0000000..4ec3047 --- /dev/null +++ b/skills/weapon-development/SKILL.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. diff --git a/skills/weapon-development/agents/openai.yaml b/skills/weapon-development/agents/openai.yaml new file mode 100644 index 0000000..d8a820e --- /dev/null +++ b/skills/weapon-development/agents/openai.yaml @@ -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." diff --git a/docs/WeaponDevelopmentSkill.md b/skills/weapon-development/references/WeaponDevelopmentSkill.md similarity index 100% rename from docs/WeaponDevelopmentSkill.md rename to skills/weapon-development/references/WeaponDevelopmentSkill.md