Merge pull request #1 from SepComet/P1-Simulation

P1-Simulation
This commit is contained in:
SepComet 2026-02-20 22:20:08 +08:00 committed by GitHub
commit e4b85e61aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1123 additions and 112 deletions

View File

@ -151,7 +151,10 @@ MonoBehaviour:
m_EditorClassIdentifier:
_isMoving: 0
_direction: {x: 0, y: 0, z: 0}
_cachedTransform: {fileID: 0}
_cachedTransform: {fileID: 7683855655592166216}
_avoidEnemyOverlap: 0
_enemyBodyRadius: 0.45
_separationIterations: 2
_speedBase: 0
--- !u!114 &6353753365317756414
MonoBehaviour:

View File

@ -191,8 +191,8 @@ Camera:
near clip plane: 0.3
far clip plane: 100
field of view: 80
orthographic: 0
orthographic size: 5
orthographic: 1
orthographic size: 15
m_Depth: 0
m_CullingMask:
serializedVersion: 2

View File

@ -1,4 +1,4 @@
//------------------------------------------------------------
//------------------------------------------------------------
// Game Framework
// Copyright © 2013-2021 Jiang Yin. All rights reserved.
// Homepage: https://gameframework.cn/
@ -6,6 +6,7 @@
//------------------------------------------------------------
using CustomComponent;
using Simulation;
using StarForce;
using UI;
using UnityEngine;
@ -20,12 +21,15 @@ public partial class GameEntry : MonoBehaviour
public static HPBarComponent HPBar { get; private set; }
public static DamageTextComponent DamageText { get; private set; }
#if UNITY_EDITOR || DEVELOPMENT_BUILD
public static RuntimeDebugPanelComponent RuntimeDebugPanel { get; private set; }
#endif
public static EnemyManagerComponent EnemyManager { get; private set; }
public static SimulationWorld SimulationWorld { get; private set; }
public static SpriteCacheComponent SpriteCache { get; private set; }
public static UIRouterComponent UIRouter { get; private set; }
@ -35,18 +39,17 @@ public partial class GameEntry : MonoBehaviour
BuiltinData = UnityGameFramework.Runtime.GameEntry.GetComponent<BuiltinDataComponent>();
HPBar = UnityGameFramework.Runtime.GameEntry.GetComponent<HPBarComponent>();
DamageText = UnityGameFramework.Runtime.GameEntry.GetComponent<DamageTextComponent>();
if (DamageText == null && Base != null)
{
DamageText = Base.gameObject.AddComponent<DamageTextComponent>();
}
#if UNITY_EDITOR || DEVELOPMENT_BUILD
RuntimeDebugPanel = UnityGameFramework.Runtime.GameEntry.GetComponent<RuntimeDebugPanelComponent>();
if (RuntimeDebugPanel == null && Base != null)
{
RuntimeDebugPanel = Base.gameObject.AddComponent<RuntimeDebugPanelComponent>();
}
#endif
EnemyManager = UnityGameFramework.Runtime.GameEntry.GetComponent<EnemyManagerComponent>();
SimulationWorld = UnityGameFramework.Runtime.GameEntry.GetComponent<SimulationWorld>();
if (SimulationWorld == null && Base != null)
{
SimulationWorld = Base.gameObject.AddComponent<SimulationWorld>();
}
SpriteCache = UnityGameFramework.Runtime.GameEntry.GetComponent<SpriteCacheComponent>();
UIRouter = UnityGameFramework.Runtime.GameEntry.GetComponent<UIRouterComponent>();
}

View File

@ -18,6 +18,9 @@ namespace Components
[SerializeField] private int _separationIterations = 2;
public float Speed => (_speedBase + _movementStat.Value) * _movementStat.Percent;
public bool AvoidEnemyOverlap => _avoidEnemyOverlap;
public float EnemyBodyRadius => _enemyBodyRadius;
public int SeparationIterations => _separationIterations;
[SerializeField] private float _speedBase;
private StatComponent _statComponent;
@ -61,6 +64,8 @@ namespace Components
public void OnReset()
{
Transform transformToUnregister = _cachedTransform;
_speedBase = 0;
_cachedTransform = null;
_direction = Vector3.zero;
@ -77,7 +82,7 @@ namespace Components
_statComponent = null;
UnregisterEnemyMover();
UnregisterEnemyMover(transformToUnregister);
}
private void Move(float deltaTime = 0)
@ -91,7 +96,7 @@ namespace Components
if (_avoidEnemyOverlap)
{
nextPosition = EnemySeparationSolverProvider.Resolve(
this,
_cachedTransform,
nextPosition,
_direction,
_separationIterations);
@ -108,12 +113,12 @@ namespace Components
{
UnregisterEnemyMover();
if (!_avoidEnemyOverlap) return;
EnemySeparationSolverProvider.Register(this, _cachedTransform, _enemyBodyRadius);
EnemySeparationSolverProvider.Register(_cachedTransform, _enemyBodyRadius);
}
private void UnregisterEnemyMover()
private void UnregisterEnemyMover(Transform transform = null)
{
EnemySeparationSolverProvider.Unregister(this);
EnemySeparationSolverProvider.Unregister(transform ?? _cachedTransform);
}
}
}

View File

@ -15,6 +15,7 @@ namespace CustomComponent
public class EnemyManagerComponent : GameFrameworkComponent
{
private const float MinSpawnRateScale = 0.1f;
private const string EnemyGroupName = "Enemy";
private EntityComponent _entity;
@ -209,10 +210,13 @@ namespace CustomComponent
{
if (!(e is ShowEntitySuccessEventArgs ne)) return;
if (ne.Entity.Logic is EnemyBase enemy)
string entityGroupName = ne.Entity?.EntityGroup?.Name;
if (entityGroupName == EnemyGroupName && ne.Entity.Logic is EnemyBase enemy)
{
_currentEnemyCount++;
enemy.SetTarget(_player);
RemoveEnemyFromCache(enemy.Id);
_enemies.Add(enemy);
}
@ -226,13 +230,31 @@ namespace CustomComponent
{
if (e is HideEntityCompleteEventArgs ne)
{
if (ne.EntityGroup.Name == "Enemy")
string entityGroupName = ne.EntityGroup.Name;
if (entityGroupName == EnemyGroupName)
{
_currentEnemyCount--;
if (_currentEnemyCount > 0)
{
_currentEnemyCount--;
}
RemoveEnemyFromCache(ne.EntityId);
}
}
}
private void RemoveEnemyFromCache(int entityId)
{
for (int i = _enemies.Count - 1; i >= 0; i--)
{
EntityBase cachedEnemy = _enemies[i];
if (cachedEnemy == null || cachedEnemy.Id == entityId)
{
_enemies.RemoveAt(i);
}
}
}
#endregion
}
}
}

View File

@ -4,6 +4,7 @@ namespace CustomDebugger
{
public static class CustomProfilerMarker
{
public static readonly ProfilerMarker TickEnemies = new ProfilerMarker("TickEnemies");
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");

View File

@ -9,4 +9,10 @@ public abstract class EnemyBase : TargetableObject
public abstract override ImpactData GetImpactData();
public virtual void SetTarget(Transform target) => _target = target;
}
protected bool IsSimulationMovementEnabled()
{
var simulationWorld = GameEntry.SimulationWorld;
return simulationWorld != null && simulationWorld.UseSimulationMovement;
}
}

View File

@ -54,6 +54,11 @@ namespace Entity
protected override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
if (IsSimulationMovementEnabled())
{
return;
}
base.OnUpdate(elapseSeconds, realElapseSeconds);
if (_target == null)

View File

@ -50,6 +50,11 @@ namespace Entity
protected override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
if (IsSimulationMovementEnabled())
{
return;
}
base.OnUpdate(elapseSeconds, realElapseSeconds);
if (_target == null)

View File

@ -5,7 +5,8 @@ using DataTable;
using Entity;
using GameFramework.Fsm;
using GameFramework.Procedure;
using UnityGameFramework.Runtime;
using Simulation;
using UnityEngine;
namespace Procedure
{
@ -13,15 +14,15 @@ namespace Procedure
{
public override GameStateType GameStateType => GameStateType.Battle;
private EnemyManagerComponent _enemyManager = null;
private EnemyManagerComponent _enemyManager;
private int _currentLevel = 0;
private int _currentLevel;
private float _levelTimeLeft = 0;
private float _levelTimeLeft;
private Player Player => _procedureGame.Player;
private ProcedureGame _procedureGame = null;
private ProcedureGame _procedureGame;
public void AddBattleDuration(float seconds)
{
@ -65,6 +66,13 @@ namespace Procedure
_enemyManager.OnUpdate(elapseSeconds, realElapseSeconds);
SimulationWorld simulationWorld = GameEntry.SimulationWorld;
if (simulationWorld != null)
{
Vector3 playerPosition = Player != null ? Player.CachedTransform.position : Vector3.zero;
simulationWorld.Tick(new SimulationTickContext(elapseSeconds, realElapseSeconds, playerPosition));
}
_levelTimeLeft -= elapseSeconds;
GameEntry.Event.Fire(this, LevelProcessEventArgs.Create((int)_levelTimeLeft));
}

View File

@ -1,5 +1,4 @@
using System.Collections.Generic;
using CustomEvent;
using DataTable;
using Definition.Enum;
using Entity;
@ -24,11 +23,11 @@ namespace Procedure
{
public override bool UseNativeDialog => false;
private HudForm _hudForm = null;
private bool _hudInitialized = false;
private HudForm _hudForm;
private bool _hudInitialized;
private IFsm<IProcedureManager> _procedureOwner = null;
private PlayerData _currentPlayerData = null;
private IFsm<IProcedureManager> _procedureOwner;
private PlayerData _currentPlayerData;
public int CurrentLevel = 1;
private GameStateType _currentGameState = GameStateType.None;
@ -88,6 +87,7 @@ namespace Procedure
base.OnEnter(procedureOwner);
_procedureOwner = procedureOwner;
GameEntry.SimulationWorld?.Clear();
GameEntry.Event.Subscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess);
GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, ShowEntitySuccess);
@ -135,6 +135,7 @@ namespace Procedure
Player = null;
_procedureOwner = null;
GameEntry.SimulationWorld?.Clear();
GameEntry.Event.Unsubscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess);
GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, ShowEntitySuccess);

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7f519a6e4d7b4cb4ab5eabf95fb55e1b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,86 @@
using System.Collections.Generic;
namespace Simulation
{
public sealed class EntityBinding
{
private readonly Dictionary<int, int> _entityIdToSimulationIndex = new Dictionary<int, int>();
private readonly Dictionary<int, int> _simulationIndexToEntityId = new Dictionary<int, int>();
public int Count => _entityIdToSimulationIndex.Count;
public void Bind(int entityId, int simulationIndex)
{
if (_entityIdToSimulationIndex.TryGetValue(entityId, out int oldSimulationIndex))
{
_simulationIndexToEntityId.Remove(oldSimulationIndex);
}
if (_simulationIndexToEntityId.TryGetValue(simulationIndex, out int oldEntityId))
{
_entityIdToSimulationIndex.Remove(oldEntityId);
}
_entityIdToSimulationIndex[entityId] = simulationIndex;
_simulationIndexToEntityId[simulationIndex] = entityId;
}
public void RemapIndex(int entityId, int newSimulationIndex)
{
if (!_entityIdToSimulationIndex.TryGetValue(entityId, out int oldSimulationIndex))
{
return;
}
if (_simulationIndexToEntityId.TryGetValue(newSimulationIndex, out int oldEntityId) &&
oldEntityId != entityId)
{
_entityIdToSimulationIndex.Remove(oldEntityId);
}
_simulationIndexToEntityId.Remove(oldSimulationIndex);
_entityIdToSimulationIndex[entityId] = newSimulationIndex;
_simulationIndexToEntityId[newSimulationIndex] = entityId;
}
public bool TryGetSimulationIndex(int entityId, out int simulationIndex)
{
return _entityIdToSimulationIndex.TryGetValue(entityId, out simulationIndex);
}
public bool TryGetEntityId(int simulationIndex, out int entityId)
{
return _simulationIndexToEntityId.TryGetValue(simulationIndex, out entityId);
}
public bool UnbindByEntityId(int entityId)
{
if (!_entityIdToSimulationIndex.TryGetValue(entityId, out int simulationIndex))
{
return false;
}
_entityIdToSimulationIndex.Remove(entityId);
_simulationIndexToEntityId.Remove(simulationIndex);
return true;
}
public bool UnbindBySimulationIndex(int simulationIndex)
{
if (!_simulationIndexToEntityId.TryGetValue(simulationIndex, out int entityId))
{
return false;
}
_simulationIndexToEntityId.Remove(simulationIndex);
_entityIdToSimulationIndex.Remove(entityId);
return true;
}
public void Clear()
{
_entityIdToSimulationIndex.Clear();
_simulationIndexToEntityId.Clear();
}
}
}

View File

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

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e2d0f6135bae4675af78194b2272e280
timeCreated: 1771590468

View File

@ -0,0 +1,19 @@
using UnityEngine;
namespace Simulation
{
public struct EnemySimData
{
public int EntityId;
public Vector3 Position;
public Vector3 Forward;
public Quaternion Rotation;
public float Speed;
public float AttackRange;
public bool AvoidEnemyOverlap;
public float EnemyBodyRadius;
public int SeparationIterations;
public int TargetType;
public int State;
}
}

View File

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

View File

@ -0,0 +1,12 @@
using UnityEngine;
namespace Simulation
{
public struct PickupSimData
{
public int EntityId;
public Vector3 Position;
public float PickupRadius;
public int State;
}
}

View File

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

View File

@ -0,0 +1,15 @@
using UnityEngine;
namespace Simulation
{
public struct ProjectileSimData
{
public int EntityId;
public int OwnerEntityId;
public Vector3 Position;
public Vector3 Forward;
public float Speed;
public float RemainingLifetime;
public int State;
}
}

View File

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

View File

@ -0,0 +1,18 @@
using UnityEngine;
namespace Simulation
{
public readonly struct SimulationTickContext
{
public SimulationTickContext(float deltaTime, float realDeltaTime, Vector3 playerPosition)
{
DeltaTime = deltaTime;
RealDeltaTime = realDeltaTime;
PlayerPosition = playerPosition;
}
public float DeltaTime { get; }
public float RealDeltaTime { get; }
public Vector3 PlayerPosition { get; }
}
}

View File

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

View File

@ -0,0 +1,138 @@
using Components;
using Entity;
using Entity.EntityData;
using GameFramework.Event;
using UnityGameFramework.Runtime;
namespace Simulation
{
public partial class SimulationWorld
{
public sealed class EntitySync
{
private const string EnemyGroupName = "Enemy";
private const string DropGroupName = "Drop";
private const string BulletGroupName = "Bullet";
private const string ProjectileGroupName = "Projectile";
private readonly SimulationWorld _world;
public EntitySync(SimulationWorld world)
{
_world = world;
}
public void OnStart()
{
GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
GameEntry.Event.Subscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
}
public void OnDestroy()
{
GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, OnShowEntitySuccess);
GameEntry.Event.Unsubscribe(HideEntityCompleteEventArgs.EventId, OnHideEntityComplete);
}
private void OnShowEntitySuccess(object sender, GameEventArgs e)
{
if (e is not ShowEntitySuccessEventArgs args) return;
if (args.Entity == null) return;
if (_world == null) return;
string groupName = args.Entity.EntityGroup?.Name;
if (string.IsNullOrEmpty(groupName)) return;
if (groupName == EnemyGroupName && args.Entity.Logic is EnemyBase enemy)
{
_world.RegisterEnemyTransform(enemy.Id, enemy.CachedTransform);
_world.UpsertEnemy(CreateEnemySimData(enemy, args.UserData as EnemyData));
return;
}
if (groupName == DropGroupName && args.Entity.Logic is EntityBase pickupEntity)
{
_world.UpsertPickup(CreatePickupSimData(pickupEntity));
return;
}
if ((groupName == BulletGroupName || groupName == ProjectileGroupName) &&
args.Entity.Logic is EntityBase projectileEntity)
{
_world.UpsertProjectile(CreateProjectileSimData(projectileEntity));
}
}
private void OnHideEntityComplete(object sender, GameEventArgs e)
{
if (e is not HideEntityCompleteEventArgs args) return;
if (args.EntityGroup == null) return;
if (_world == null) return;
string groupName = args.EntityGroup.Name;
if (groupName == EnemyGroupName)
{
_world.UnregisterEnemyTransform(args.EntityId);
_world.RemoveEnemyByEntityId(args.EntityId);
return;
}
if (groupName == DropGroupName)
{
_world.RemovePickupByEntityId(args.EntityId);
return;
}
if (groupName == BulletGroupName || groupName == ProjectileGroupName)
{
_world.RemoveProjectileByEntityId(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
};
}
}
}
}

View File

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

View File

@ -0,0 +1,69 @@
using UnityEngine;
namespace Simulation
{
public partial class SimulationWorld
{
private sealed class Presentation
{
private readonly SimulationWorld _world;
public Presentation(SimulationWorld world)
{
_world = world;
}
public void OnLateUpdate()
{
if (_world == null || !_world.UseSimulationMovement)
{
return;
}
var enemyManager = GameEntry.EnemyManager;
if (enemyManager == null || enemyManager.Enemies == null)
{
return;
}
var enemies = enemyManager.Enemies;
for (int i = 0; i < enemies.Count; i++)
{
if (enemies[i] is not EnemyBase enemyEntity || !enemyEntity.Available)
{
continue;
}
if (!_world.TryGetEnemyData(enemyEntity.Id, out EnemySimData enemyData))
{
continue;
}
ApplyEnemyPresentation(enemyEntity, enemyData);
}
}
private static void ApplyEnemyPresentation(EnemyBase enemyEntity, in EnemySimData enemyData)
{
Transform enemyTransform = enemyEntity.CachedTransform;
enemyTransform.position = enemyData.Position;
Quaternion rotation = enemyData.Rotation;
float rotationMagnitude = Mathf.Abs(rotation.x) + Mathf.Abs(rotation.y) + Mathf.Abs(rotation.z) +
Mathf.Abs(rotation.w);
if (rotationMagnitude > float.Epsilon)
{
enemyTransform.rotation = rotation;
return;
}
Vector3 forward = enemyData.Forward;
forward.y = 0f;
if (forward.sqrMagnitude > float.Epsilon)
{
enemyTransform.forward = forward.normalized;
}
}
}
}
}

View File

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

View File

@ -0,0 +1,295 @@
using System.Collections.Generic;
using CustomDebugger;
using CustomUtility;
using Unity.Profiling;
using UnityEngine;
using UnityGameFramework.Runtime;
namespace Simulation
{
public sealed partial class SimulationWorld : GameFrameworkComponent
{
private const float DefaultAttackRange = 1f;
private const int EnemyStateIdle = 0;
private const int EnemyStateChasing = 1;
private const int EnemyStateInAttackRange = 2;
[SerializeField] private bool _useSimulationMovement;
private EntitySync _entitySync;
private Presentation _presentation;
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 EntityBinding EnemyBinding { get; } = new EntityBinding();
private EntityBinding ProjectileBinding { get; } = new EntityBinding();
private EntityBinding PickupBinding { get; } = new EntityBinding();
public IReadOnlyList<EnemySimData> Enemies => _enemies;
public IReadOnlyList<ProjectileSimData> Projectiles => _projectiles;
public IReadOnlyList<PickupSimData> Pickups => _pickups;
public bool UseSimulationMovement => _useSimulationMovement;
public void SetUseSimulationMovement(bool enabled)
{
_useSimulationMovement = enabled;
}
protected override void Awake()
{
base.Awake();
_entitySync = new EntitySync(this);
_presentation = new Presentation(this);
}
private void Start()
{
_entitySync?.OnStart();
}
private void OnDestroy()
{
_entitySync?.OnDestroy();
_entitySync = null;
_presentation = null;
}
private void LateUpdate()
{
_presentation?.OnLateUpdate();
}
private int AddEnemy(in EnemySimData simData)
{
int simulationIndex = _enemies.Count;
_enemies.Add(simData);
EnemyBinding.Bind(simData.EntityId, simulationIndex);
return simulationIndex;
}
private int UpsertEnemy(in EnemySimData simData)
{
if (!EnemyBinding.TryGetSimulationIndex(simData.EntityId, out int simulationIndex))
{
return AddEnemy(simData);
}
_enemies[simulationIndex] = simData;
return simulationIndex;
}
private bool RemoveEnemyByEntityId(int entityId)
{
if (!EnemyBinding.TryGetSimulationIndex(entityId, out int simulationIndex))
{
return false;
}
int lastIndex = _enemies.Count - 1;
if (simulationIndex != lastIndex)
{
EnemySimData movedData = _enemies[lastIndex];
_enemies[simulationIndex] = movedData;
EnemyBinding.RemapIndex(movedData.EntityId, simulationIndex);
}
_enemies.RemoveAt(lastIndex);
EnemyBinding.UnbindByEntityId(entityId);
_enemyTransforms.Remove(entityId);
return true;
}
private void RegisterEnemyTransform(int entityId, Transform transform)
{
if (transform == null)
{
_enemyTransforms.Remove(entityId);
return;
}
_enemyTransforms[entityId] = transform;
}
private void UnregisterEnemyTransform(int entityId)
{
_enemyTransforms.Remove(entityId);
}
private bool TryGetEnemyData(int entityId, out EnemySimData enemyData)
{
if (!EnemyBinding.TryGetSimulationIndex(entityId, out int simulationIndex) || simulationIndex < 0 ||
simulationIndex >= _enemies.Count)
{
enemyData = default;
return false;
}
enemyData = _enemies[simulationIndex];
return true;
}
private int AddProjectile(in ProjectileSimData simData)
{
int simulationIndex = _projectiles.Count;
_projectiles.Add(simData);
ProjectileBinding.Bind(simData.EntityId, simulationIndex);
return simulationIndex;
}
private int UpsertProjectile(in ProjectileSimData simData)
{
if (!ProjectileBinding.TryGetSimulationIndex(simData.EntityId, out int simulationIndex))
{
return AddProjectile(simData);
}
_projectiles[simulationIndex] = simData;
return simulationIndex;
}
private bool RemoveProjectileByEntityId(int entityId)
{
if (!ProjectileBinding.TryGetSimulationIndex(entityId, out int simulationIndex))
{
return false;
}
int lastIndex = _projectiles.Count - 1;
if (simulationIndex != lastIndex)
{
ProjectileSimData movedData = _projectiles[lastIndex];
_projectiles[simulationIndex] = movedData;
ProjectileBinding.RemapIndex(movedData.EntityId, simulationIndex);
}
_projectiles.RemoveAt(lastIndex);
ProjectileBinding.UnbindByEntityId(entityId);
return true;
}
private int AddPickup(in PickupSimData simData)
{
int simulationIndex = _pickups.Count;
_pickups.Add(simData);
PickupBinding.Bind(simData.EntityId, simulationIndex);
return simulationIndex;
}
private int UpsertPickup(in PickupSimData simData)
{
if (!PickupBinding.TryGetSimulationIndex(simData.EntityId, out int simulationIndex))
{
return AddPickup(simData);
}
_pickups[simulationIndex] = simData;
return simulationIndex;
}
private bool RemovePickupByEntityId(int entityId)
{
if (!PickupBinding.TryGetSimulationIndex(entityId, out int simulationIndex))
{
return false;
}
int lastIndex = _pickups.Count - 1;
if (simulationIndex != lastIndex)
{
PickupSimData movedData = _pickups[lastIndex];
_pickups[simulationIndex] = movedData;
PickupBinding.RemapIndex(movedData.EntityId, simulationIndex);
}
_pickups.RemoveAt(lastIndex);
PickupBinding.UnbindByEntityId(entityId);
return true;
}
public void Tick(in SimulationTickContext context)
{
if (!_useSimulationMovement)
{
return;
}
using (CustomProfilerMarker.TickEnemies.Auto())
{
TickEnemies(in context);
}
}
public void Clear()
{
_enemies.Clear();
_projectiles.Clear();
_pickups.Clear();
_enemyTransforms.Clear();
EnemyBinding.Clear();
ProjectileBinding.Clear();
PickupBinding.Clear();
}
private void TickEnemies(in SimulationTickContext context)
{
if (_enemies.Count == 0 || context.DeltaTime <= 0f)
{
return;
}
Vector3 playerPosition = context.PlayerPosition;
playerPosition.y = 0f;
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;
float sqrDistance = toPlayer.sqrMagnitude;
float attackRange = enemy.AttackRange > 0f ? enemy.AttackRange : DefaultAttackRange;
float attackRangeSqr = attackRange * attackRange;
if (sqrDistance <= attackRangeSqr)
{
enemy.State = EnemyStateInAttackRange;
}
else if (enemy.Speed <= 0f || sqrDistance <= float.Epsilon)
{
enemy.State = EnemyStateIdle;
}
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);
}
enemy.Position = desiredPosition;
enemy.State = EnemyStateChasing;
if (forward.sqrMagnitude > float.Epsilon)
{
enemy.Rotation = Quaternion.LookRotation(forward, Vector3.up);
}
}
_enemies[i] = enemy;
}
}
}
}

View File

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

View File

@ -1,6 +1,5 @@
using System.Collections.Generic;
using Components;
using UnityEngine;
namespace CustomUtility
@ -9,12 +8,11 @@ namespace CustomUtility
{
private struct Registration
{
public Transform Transform;
public float BodyRadius;
}
private static IEnemySeparationSolver _current = new GridBucketEnemySeparationSolver();
private static readonly Dictionary<MovementComponent, Registration> Registrations = new();
private static readonly Dictionary<Transform, Registration> Registrations = new();
public static IEnemySeparationSolver Current => _current;
public static string CurrentSolverName => _current.GetType().Name;
@ -36,42 +34,41 @@ namespace CustomUtility
SetSolver(new NaiveEnemySeparationSolver());
}
public static void Register(MovementComponent mover, Transform transform, float bodyRadius)
public static void Register(Transform transform, float bodyRadius)
{
if (mover == null || transform == null) return;
if (transform == null) return;
var registration = new Registration
{
Transform = transform,
BodyRadius = bodyRadius
};
Registrations[mover] = registration;
_current.Register(mover, transform, bodyRadius);
Registrations[transform] = registration;
_current.Register(transform, bodyRadius);
}
public static void Unregister(MovementComponent mover)
public static void Unregister(Transform transform)
{
if (mover == null) return;
if (transform == null) return;
_current.Unregister(mover);
Registrations.Remove(mover);
_current.Unregister(transform);
Registrations.Remove(transform);
}
public static Vector3 Resolve(MovementComponent mover, Vector3 desiredPosition, Vector3 fallbackDirection,
public static Vector3 Resolve(Transform transform, Vector3 desiredPosition, Vector3 fallbackDirection,
int iterations)
{
return _current.Resolve(mover, desiredPosition, fallbackDirection, iterations);
return _current.Resolve(transform, desiredPosition, fallbackDirection, iterations);
}
private static void ReRegisterAll()
{
foreach (var pair in Registrations)
{
MovementComponent mover = pair.Key;
Transform transform = pair.Key;
Registration registration = pair.Value;
if (mover == null || registration.Transform == null) continue;
if (transform == null) continue;
_current.Register(mover, registration.Transform, registration.BodyRadius);
_current.Register(transform, registration.BodyRadius);
}
}
}

View File

@ -1,4 +1,3 @@
using Components;
using UnityEngine;
namespace CustomUtility
@ -14,12 +13,12 @@ namespace CustomUtility
public int CellZ;
}
private readonly System.Collections.Generic.Dictionary<MovementComponent, Agent> _agents = new();
private readonly System.Collections.Generic.Dictionary<Transform, Agent> _agents = new();
private readonly System.Collections.Generic.Dictionary<long, System.Collections.Generic.List<MovementComponent>>
private readonly System.Collections.Generic.Dictionary<long, System.Collections.Generic.List<Transform>>
_buckets = new();
private readonly System.Collections.Generic.List<MovementComponent> _recycle = new();
private readonly System.Collections.Generic.List<Transform> _recycle = new();
private readonly float _cellSize;
private int _snapshotFrame = -1;
@ -30,14 +29,14 @@ namespace CustomUtility
_cellSize = Mathf.Max(0.1f, cellSize);
}
public void Register(MovementComponent mover, Transform transform, float bodyRadius)
public void Register(Transform transform, float bodyRadius)
{
if (mover == null || transform == null) return;
if (transform == null) return;
if (!_agents.TryGetValue(mover, out var agent))
if (!_agents.TryGetValue(transform, out var agent))
{
agent = new Agent();
_agents.Add(mover, agent);
_agents.Add(transform, agent);
}
agent.Transform = transform;
@ -50,22 +49,22 @@ namespace CustomUtility
_snapshotFrame = -1;
}
public void Unregister(MovementComponent mover)
public void Unregister(Transform transform)
{
if (mover == null) return;
if (!_agents.TryGetValue(mover, out var agent)) return;
if (transform == null) return;
if (!_agents.TryGetValue(transform, out var agent)) return;
RemoveFromBucket(mover, agent.CellX, agent.CellZ);
_agents.Remove(mover);
RemoveFromBucket(transform, agent.CellX, agent.CellZ);
_agents.Remove(transform);
RecalculateMaxRadius();
_snapshotFrame = -1;
}
public Vector3 Resolve(MovementComponent mover, Vector3 desiredPosition, Vector3 fallbackDirection,
public Vector3 Resolve(Transform transform, Vector3 desiredPosition, Vector3 fallbackDirection,
int iterations)
{
if (mover == null) return desiredPosition;
if (!_agents.TryGetValue(mover, out var self)) return desiredPosition;
if (transform == null) return desiredPosition;
if (!_agents.TryGetValue(transform, out var self)) return desiredPosition;
EnsureSnapshot();
@ -90,9 +89,9 @@ namespace CustomUtility
for (int i = 0; i < bucket.Count; i++)
{
MovementComponent otherMover = bucket[i];
if (otherMover == mover) continue;
if (!_agents.TryGetValue(otherMover, out var other)) continue;
Transform otherTransform = bucket[i];
if (otherTransform == transform) continue;
if (!_agents.TryGetValue(otherTransform, out var other)) continue;
Vector3 toSelf = candidate - other.Position;
float minDistance = self.Radius + other.Radius;
@ -115,7 +114,7 @@ namespace CustomUtility
}
}
SyncAgentPosition(mover, self, candidate);
SyncAgentPosition(transform, self, candidate);
candidate.y = desiredPosition.y;
return candidate;
@ -132,11 +131,11 @@ namespace CustomUtility
foreach (var pair in _agents)
{
MovementComponent mover = pair.Key;
Transform transform = pair.Key;
Agent agent = pair.Value;
if (mover == null || agent.Transform == null)
if (transform == null || agent.Transform == null)
{
_recycle.Add(mover);
_recycle.Add(transform);
continue;
}
@ -146,7 +145,7 @@ namespace CustomUtility
agent.CellX = ToCell(position.x);
agent.CellZ = ToCell(position.z);
AddToBucket(mover, agent.CellX, agent.CellZ);
AddToBucket(transform, agent.CellX, agent.CellZ);
}
for (int i = 0; i < _recycle.Count; i++)
@ -155,15 +154,15 @@ namespace CustomUtility
}
}
private void SyncAgentPosition(MovementComponent mover, Agent agent, Vector3 position)
private void SyncAgentPosition(Transform transform, Agent agent, Vector3 position)
{
int newCellX = ToCell(position.x);
int newCellZ = ToCell(position.z);
if (agent.CellX != newCellX || agent.CellZ != newCellZ)
{
RemoveFromBucket(mover, agent.CellX, agent.CellZ);
AddToBucket(mover, newCellX, newCellZ);
RemoveFromBucket(transform, agent.CellX, agent.CellZ);
AddToBucket(transform, newCellX, newCellZ);
agent.CellX = newCellX;
agent.CellZ = newCellZ;
}
@ -171,24 +170,24 @@ namespace CustomUtility
agent.Position = position;
}
private void AddToBucket(MovementComponent mover, int cellX, int cellZ)
private void AddToBucket(Transform transform, int cellX, int cellZ)
{
long key = CellKey(cellX, cellZ);
if (!_buckets.TryGetValue(key, out var list))
{
list = new System.Collections.Generic.List<MovementComponent>(8);
list = new System.Collections.Generic.List<Transform>(8);
_buckets.Add(key, list);
}
list.Add(mover);
list.Add(transform);
}
private void RemoveFromBucket(MovementComponent mover, int cellX, int cellZ)
private void RemoveFromBucket(Transform transform, int cellX, int cellZ)
{
long key = CellKey(cellX, cellZ);
if (!_buckets.TryGetValue(key, out var list)) return;
list.Remove(mover);
list.Remove(transform);
if (list.Count == 0)
{
_buckets.Remove(key);

View File

@ -1,12 +1,11 @@
using Components;
using UnityEngine;
namespace CustomUtility
{
public interface IEnemySeparationSolver
{
void Register(MovementComponent mover, Transform transform, float bodyRadius);
void Unregister(MovementComponent mover);
Vector3 Resolve(MovementComponent mover, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations);
void Register(Transform transform, float bodyRadius);
void Unregister(Transform transform);
Vector3 Resolve(Transform transform, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations);
}
}
}

View File

@ -1,4 +1,3 @@
using Components;
using UnityEngine;
namespace CustomUtility
@ -11,33 +10,33 @@ namespace CustomUtility
public float Radius;
}
private readonly System.Collections.Generic.Dictionary<MovementComponent, Agent> _agents = new();
private readonly System.Collections.Generic.List<MovementComponent> _agentKeys = new();
private readonly System.Collections.Generic.Dictionary<Transform, Agent> _agents = new();
private readonly System.Collections.Generic.List<Transform> _agentKeys = new();
public void Register(MovementComponent mover, Transform transform, float bodyRadius)
public void Register(Transform transform, float bodyRadius)
{
if (mover == null || transform == null) return;
if (transform == null) return;
if (!_agents.TryGetValue(mover, out var agent))
if (!_agents.TryGetValue(transform, out var agent))
{
agent = new Agent();
_agents.Add(mover, agent);
_agents.Add(transform, agent);
}
agent.Transform = transform;
agent.Radius = Mathf.Max(0.01f, bodyRadius);
}
public void Unregister(MovementComponent mover)
public void Unregister(Transform transform)
{
if (mover == null) return;
_agents.Remove(mover);
if (transform == null) return;
_agents.Remove(transform);
}
public Vector3 Resolve(MovementComponent mover, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations)
public Vector3 Resolve(Transform transform, Vector3 desiredPosition, Vector3 fallbackDirection, int iterations)
{
if (mover == null) return desiredPosition;
if (!_agents.TryGetValue(mover, out var self)) return desiredPosition;
if (transform == null) return desiredPosition;
if (!_agents.TryGetValue(transform, out var self)) return desiredPosition;
Vector3 candidate = desiredPosition;
candidate.y = 0f;
@ -56,9 +55,9 @@ namespace CustomUtility
{
for (int i = 0; i < _agentKeys.Count; i++)
{
MovementComponent otherMover = _agentKeys[i];
if (otherMover == mover) continue;
if (!_agents.TryGetValue(otherMover, out var other)) continue;
Transform otherTransform = _agentKeys[i];
if (otherTransform == transform) continue;
if (!_agents.TryGetValue(otherTransform, out var other)) continue;
if (other.Transform == null) continue;
Vector3 otherPosition = other.Transform.position;

View File

@ -241,6 +241,8 @@ Transform:
- {fileID: 472081678}
- {fileID: 2050832067}
- {fileID: 534968532}
- {fileID: 477326942}
- {fileID: 1652245191}
m_Father: {fileID: 1852670053}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &120093239
@ -802,6 +804,50 @@ MonoBehaviour:
m_EditorClassIdentifier:
_pixelsPerUnit: 100
_defaultPivot: {x: 0.5, y: 0.5}
--- !u!1 &477326941
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 477326942}
- component: {fileID: 477326943}
m_Layer: 0
m_Name: RuntimeDebugPanel
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &477326942
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 477326941}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 119167776}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &477326943
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 477326941}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1d8ada5157a04921a6e543a040e57960, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!1 &513208572
GameObject:
m_ObjectHideFlags: 0
@ -1336,6 +1382,51 @@ MonoBehaviour:
_hpBarItemTemplate: {fileID: 11414536, guid: 96d2e77dd9853514da336717bd5627c0, type: 3}
_hpBarInstanceRoot: {fileID: 1454214587}
_instancePoolCapacity: 16
--- !u!1 &1652245190
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1652245191}
- component: {fileID: 1652245192}
m_Layer: 0
m_Name: SimulationWorld
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &1652245191
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1652245190}
serializedVersion: 2
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 119167776}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &1652245192
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1652245190}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8a558ebbc9cb4d94946ac9f4f27914d8, type: 3}
m_Name:
m_EditorClassIdentifier:
_useSimulationMovement: 1
--- !u!1 &1852670052
GameObject:
m_ObjectHideFlags: 0

View File

@ -0,0 +1,43 @@
## 测试机性能
iQOO Neo8
CPU: 第一代骁龙 8+ 八核
内存: 12 GB
系统: OriginOS 6 (Android 16)
## CPU
| 怪物数量 | 帧率 | TickEnemies 占比 | TickEnemies GC | Movement_Update 占比 |
|--------|-----------------------|--------------------|----------------|--------------------|
| `500` | `60.8 fps (16.43 ms)` | `37.6% (6.18 ms)` | `27.4 KB` | `0.0% (0 ms)` |
| `1000` | `40.2 fps (24.85 ms)` | `51.5% (12.8 ms)` | `54.5 KB` | `0.0% (0 ms)` |
| `1500` | `28.0 fps (35.62 ms)` | `56.4% (20.11 ms)` | `82.1 KB` | `0.0% (0 ms)` |
| `2000` | `18.8 fps (53.04 ms)` | `55.8% (29.62 ms)` | `107.6 KB` | `0.0% (0 ms)` |
tip:
1. 60 fps 为 Android 端帧率上限,具体可参考 CPU ms 耗时
2. 注意 Profiler 里会产生性能损耗的配置Call Stacks
### Memory
| 怪物数量 | GC Used Memory | GC Allocated In Frame |
|------|----------------|-----------------------|
| 500 | 7.8 MB | 29.5 KB |
| 1000 | 8.8 MB | 56.6 KB |
| 1500 | 10.0 MB | 84.2 KB |
| 2000 | 11.8 MB | 109.7 KB |
### Render
| 维度 | 500 enemies | 1000 enemies | 1500 enemies | 2000 enemies |
|----------------------|-------------|--------------|--------------|--------------|
| SetPass Calls | 41 | 42 | 43 | 44 |
| Draw Calls | 46 | 46 | 48 | 49 |
| Batches | 46 | 46 | 48 | 49 |
| **Static Batching:** | | | | |
| Batched Draw Call | 0 | 0 | 0 | 0 |
| Batched | 0 | 0 | 0 | 0 |
| **Instancing:** | | | | |
| Batched Draw Call | 420 | 505 | 584 | 962 |
| Batched | 5 | 5 | 7 | 8 |

View File

@ -25,18 +25,91 @@
- 以上问题修正后,核心流程可稳定连续跑 10 分钟无异常日志。
## 2. P1 Simulation 分层(为 Job/Burst 做结构准备)
- [ ] 新建 `Simulation` 层(建议目录:`Assets/GameMain/Scripts/Simulation`
- `SimulationWorld`:统一持有敌人/投射物/掉落物的纯数据容器。
- `EnemySimData / ProjectileSimData / PickupSimData`:结构化、连续内存友好的数据定义。
- `EntityBinding`:维护 `EntityId <-> SimulationIndex` 映射。
- [ ] 将“逻辑计算”和“表现层Transform/Animator/特效/UI”拆离
- 逻辑层输出 position/rotation/state。
- 表现层只消费结果做显示。
- [ ] 先保持现有 GameFramework 实体生命周期不变,仅替换更新路径。
- [x] Checkpoint 1搭建 Simulation 基础骨架(仅新增,不改行为)
- 新建目录:`Assets/GameMain/Scripts/Simulation`。
- 新建 `SimulationWorld`,统一持有 `EnemySimData / ProjectileSimData / PickupSimData` 容器。
- 新建 `EntityBinding`,维护 `EntityId <-> SimulationIndex` 双向映射。
- 新建 `SimulationTickContext`(至少包含 `deltaTime`、`playerPosition`)。
- 完成标准:工程可编译,场景运行行为与当前一致(只加结构,不切链路)。
- [x] Checkpoint 2敌人生命周期接入 Simulation保持 GameFramework 生命周期不变)
- 在敌人 `Show/Hide` 时同步注册/反注册到 `SimulationWorld``EntityBinding`
- `EnemyManagerComponent` 继续负责刷怪与实体显隐,不改外部调用方式。
- 完成标准:敌人数量统计与当前一致,无重复注册、无悬空索引。
- [x] Checkpoint 3建立 Simulation 主更新入口并接入 Battle 状态
- 在 `GameStateBattle.OnUpdate` 中增加 `SimulationWorld.Tick(...)` 调用。
- 先只接“敌人移动/追踪”系统,其他逻辑保持原路径。
- 增加开关(建议 `UseSimulationMovement`)用于 A/B 对比与回滚。
- 完成标准:关闭开关与当前行为一致;开启开关后敌人仍能正常追踪玩家。
- [x] Checkpoint 4迁移敌人核心移动逻辑到 Simulation去 MonoBehaviour 核心逻辑)
- 将 `MeleeEnemy/RemoteEnemy` 的目标追踪、移动方向、攻击距离判定迁至 Simulation。
- `EnemySimData` 至少包含:`position`、`forward`、`speed`、`attackRange`、`targetType`、`state`。
- `MeleeEnemy/RemoteEnemy.OnUpdate` 仅保留表现层或空实现(不再做核心移动计算)。
- 完成标准:同等刷怪量下,敌人移动结果与旧逻辑视觉一致,无明显穿模/停滞回归。
- [x] Checkpoint 5拆分“逻辑输出”与“表现层消费”
- 逻辑层输出:`position/rotation/state`(必要时含 `isMoving`)。
- 表现层仅消费并回写 `Transform`,动画/特效/UI 不参与逻辑计算。
- 明确边界HPBar、DamageText、Animator 继续由表现层驱动。
- 完成标准:关闭/开启 Simulation 不影响 UI 事件链(血量、经验、金币、关卡流程)。
- [x] Checkpoint 6补齐 Projectile/Pickup 的 Simulation 占位数据通道
- 在 `SimulationWorld` 中接入 `ProjectileSimData / PickupSimData` 容器与绑定关系。
- 先不迁移完整行为,只保证创建、回收、索引同步路径可用。
- 完成标准:投射物/掉落物实体生命周期正常,无索引越界与回收遗漏。
- [x] Checkpoint 7P1 阶段回归与性能记录
- 回归用例:战斗 10 分钟、`Battle -> LevelUp -> Shop -> Battle` 循环、掉落吸附与拾取。
- Profiling 对比:记录 1k/2k/3k 敌人下 Main Thread、GC Alloc、敌人更新耗时。
- 输出文档:`P1 Simulation 分层设计 + 回滚开关说明 + 对比数据`。
- 完成标准:核心流程稳定,无新增 Error/Exception可一键回滚到旧更新路径。
**验收标准**
- 敌人移动/追踪由 Simulation 统一调度,不再逐个 Enemy MonoBehaviour 执行核心逻辑。
## 2.5 P1.5 Simulation 收尾P2 前置)
- [ ] 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` 运行时依赖
- 目标:`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` 职责边界
- 目标:`EntitySync` 仅处理生命周期映射,不承担运行时移动逻辑。
- 重点文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs`。
- 处理方式:保留注册/反注册与初值同步,移除 Tick 过程依赖。
- 完成标准:`OnShow/OnHide` 逻辑稳定,且不引入运行时分配热点。
- [ ] Checkpoint 4拆分 Simulation Tick 阶段,为 Job 化铺路
- 目标:将敌人 Tick 拆分为稳定阶段,便于后续迁移 `IJobParallelFor`
- 建议阶段:`BuildInput -> Move/Separation -> StateUpdate -> WriteBack`。
- 重点文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.cs`。
- 完成标准:每阶段有独立 `ProfilerMarker`,可明确观测耗时占比。
- [ ] Checkpoint 5补最小回归测试P1.5 重构保护)
- 目标:确保重构不改变战斗行为。
- 建议目录:`Assets/Tests/Simulation/`。
- 用例范围:追踪玩家、攻击距离停下、实体移除后的索引重映射。
- 完成标准EditMode/PlayMode 相关用例通过,主流程手测无回归。
- [ ] Checkpoint 6补充 P1.5 结项文档
- 输出:`P1.5 收尾说明 + 对比数据 + 回滚开关`。
- 明确记录Android 60fps 上限、Profiler 采样配置Call Stacks 开关状态)、评估以 CPU ms 为主。
- 完成标准:文档可复现实验结论,并可作为 P2 输入基线。
**验收标准**
- `Movement_Update` 持续维持 `0 ms`(或可忽略占比)。
- `TickEnemies` 在目标敌人数下 GC 与 CPU 耗时均有明显下降,并可复现。
- Simulation 层与表现层边界清晰,可无缝衔接 P2 Job/Burst 改造。
## 3. P2 Job System + Burst 落地(核心性能阶段)
- [ ] 引入并锁定依赖版本Unity 2022.3 对应):
- `com.unity.collections`