Cleanup 1

This commit is contained in:
SepComet 2026-04-02 11:21:07 +08:00
parent 5ed792b09f
commit 1052cc0136
26 changed files with 600 additions and 2057 deletions

View File

@ -2,6 +2,8 @@ using System;
using CustomUtility; using CustomUtility;
using Definition.DataStruct; using Definition.DataStruct;
using Definition.Enum; using Definition.Enum;
using Entity;
using Simulation;
using UnityEngine; using UnityEngine;
using CustomDebugger; using CustomDebugger;
@ -20,6 +22,9 @@ namespace Components
public bool AvoidEnemyOverlap => _avoidEnemyOverlap; public bool AvoidEnemyOverlap => _avoidEnemyOverlap;
public float EnemyBodyRadius => _enemyBodyRadius; public float EnemyBodyRadius => _enemyBodyRadius;
public int SeparationIterations => _separationIterations; public int SeparationIterations => _separationIterations;
public bool IsMoving => _isMoving;
public Vector3 Direction => _direction;
public Transform CachedTransform => _cachedTransform;
[SerializeField] private float _speedBase; [SerializeField] private float _speedBase;
private StatComponent _statComponent; private StatComponent _statComponent;
@ -42,7 +47,10 @@ namespace Components
{ {
_movementStat = _statComponent.GetStat(StatType.MovementSpeed); _movementStat = _statComponent.GetStat(StatType.MovementSpeed);
_movementStatCallback = (modifier, isApply) => _movementStatCallback = (modifier, isApply) =>
{
_statComponent.UpdateStat(_movementStat, modifier, isApply); _statComponent.UpdateStat(_movementStat, modifier, isApply);
SyncToSimulationWorld();
};
_statComponent.Subscribe(StatType.MovementSpeed, _movementStatCallback); _statComponent.Subscribe(StatType.MovementSpeed, _movementStatCallback);
} }
else else
@ -50,15 +58,12 @@ namespace Components
_movementStat = new StatProperty(); _movementStat = new StatProperty();
} }
RefreshEnemyRegistration(); SyncToSimulationWorld();
} }
public void OnUpdate(float elapseSeconds, float realElapseSeconds) public void OnUpdate(float elapseSeconds, float realElapseSeconds)
{ {
if (_isMoving && _cachedTransform != null) SyncToSimulationWorld();
{
Move(elapseSeconds);
}
} }
public void OnReset() public void OnReset()
@ -81,43 +86,59 @@ namespace Components
_statComponent = null; _statComponent = null;
UnregisterEnemyMover(transformToUnregister); if (transformToUnregister != null)
}
private void Move(float deltaTime = 0)
{
using (CustomProfilerMarker.Movement_Update.Auto())
{ {
if (_cachedTransform == null) return; var simulationWorld = GameEntry.SimulationWorld;
simulationWorld?.UnregisterPlayerMovement(transformToUnregister);
Vector3 displacement = Speed * deltaTime * _direction;
Vector3 nextPosition = _cachedTransform.position + displacement;
if (_avoidEnemyOverlap)
{
nextPosition = EnemySeparationSolverProvider.Resolve(
_cachedTransform,
nextPosition,
_direction,
_separationIterations);
}
_cachedTransform.position = nextPosition;
} }
} }
public void SetMove(bool isMoving) => _isMoving = isMoving; public void SetMove(bool isMoving)
public void SetDirection(Vector3 direction) => _direction = direction;
private void RefreshEnemyRegistration()
{ {
UnregisterEnemyMover(); _isMoving = isMoving;
if (!_avoidEnemyOverlap) return; SyncToSimulationWorld();
EnemySeparationSolverProvider.Register(_cachedTransform, _enemyBodyRadius);
} }
private void UnregisterEnemyMover(Transform transform = null) public void SetDirection(Vector3 direction)
{ {
EnemySeparationSolverProvider.Unregister(transform ?? _cachedTransform); _direction = direction;
SyncToSimulationWorld();
}
private void SyncToSimulationWorld()
{
using (CustomProfilerMarker.Movement_Update.Auto())
{
if (_cachedTransform == null)
{
return;
}
SimulationWorld simulationWorld = GameEntry.SimulationWorld;
if (simulationWorld == null)
{
return;
}
Vector3 direction = _direction;
direction.y = 0f;
if (direction.sqrMagnitude > Mathf.Epsilon)
{
direction.Normalize();
}
if (TryGetComponent(out Player _))
{
simulationWorld.SyncPlayerMovementInput(_cachedTransform, _isMoving, direction, Speed);
return;
}
if (TryGetComponent(out EnemyBase enemy))
{
simulationWorld.SyncEnemyMovementInput(enemy.Id, _isMoving, direction, Speed,
_avoidEnemyOverlap, _enemyBodyRadius, _separationIterations);
}
}
} }
} }
} }

View File

@ -29,7 +29,6 @@ namespace CustomComponent
[SerializeField] private bool _showCollisionStats = true; [SerializeField] private bool _showCollisionStats = true;
[SerializeField] private bool _showSpawnControls = true; [SerializeField] private bool _showSpawnControls = true;
[SerializeField] private bool _showBattleDurationControls = true; [SerializeField] private bool _showBattleDurationControls = true;
[SerializeField] private bool _showSeparationSolverControls = true;
[SerializeField] private bool _showPlayerWeaponControls = true; [SerializeField] private bool _showPlayerWeaponControls = true;
[SerializeField] private bool _showPlayerHealthControls = true; [SerializeField] private bool _showPlayerHealthControls = true;
[SerializeField] private bool _showTips = true; [SerializeField] private bool _showTips = true;
@ -283,24 +282,6 @@ namespace CustomComponent
GUILayout.EndHorizontal(); GUILayout.EndHorizontal();
} }
if (_showSeparationSolverControls)
{
GUILayout.Space(4f);
GUILayout.Label($"Enemy Separation Solver: {EnemySeparationSolverProvider.CurrentSolverName}");
GUILayout.BeginHorizontal();
if (GUILayout.Button("Use Naive O(N^2)", GUILayout.Height(24f)))
{
EnemySeparationSolverProvider.UseNaiveSolver();
}
if (GUILayout.Button("Use Grid Bucket", GUILayout.Height(24f)))
{
EnemySeparationSolverProvider.UseGridBucketSolver();
}
GUILayout.EndHorizontal();
}
if (_showPlayerWeaponControls) if (_showPlayerWeaponControls)
{ {
GUILayout.Label( GUILayout.Label(
@ -353,7 +334,6 @@ namespace CustomComponent
_showCollisionStats || _showCollisionStats ||
_showSpawnControls || _showSpawnControls ||
_showBattleDurationControls || _showBattleDurationControls ||
_showSeparationSolverControls ||
_showPlayerWeaponControls || _showPlayerWeaponControls ||
_showPlayerHealthControls; _showPlayerHealthControls;
} }

View File

@ -13,7 +13,6 @@ public abstract class EnemyBase : TargetableObject
protected bool IsSimulationMovementEnabled() protected bool IsSimulationMovementEnabled()
{ {
var simulationWorld = GameEntry.SimulationWorld; return true;
return simulationWorld != null && simulationWorld.UseSimulationMovement;
} }
} }

View File

@ -10,9 +10,7 @@ namespace Entity
{ {
private EnemyProjectileData _projectileData; private EnemyProjectileData _projectileData;
private Vector3 _direction = Vector3.forward; private Vector3 _direction = Vector3.forward;
private float _elapsedTime;
private bool _isActive; private bool _isActive;
private bool _isSimulationDriven;
private ImpactData _impactData; private ImpactData _impactData;
private Collider[] _cachedColliders; private Collider[] _cachedColliders;
@ -33,7 +31,6 @@ namespace Entity
} }
_isActive = true; _isActive = true;
_elapsedTime = 0f;
_impactData = new ImpactData(_projectileData.OwnerCamp, _projectileData.AttackDamage); _impactData = new ImpactData(_projectileData.OwnerCamp, _projectileData.AttackDamage);
_direction = _projectileData.Direction; _direction = _projectileData.Direction;
@ -64,8 +61,7 @@ namespace Entity
gameObject.layer = LayerMask.NameToLayer("EnemyWeapon"); gameObject.layer = LayerMask.NameToLayer("EnemyWeapon");
} }
_isSimulationDriven = IsDrivenBySimulationWorld(); SetColliderEnabled(false);
SetColliderEnabled(!_isSimulationDriven);
} }
protected override void OnUpdate(float elapseSeconds, float realElapseSeconds) protected override void OnUpdate(float elapseSeconds, float realElapseSeconds)
@ -73,34 +69,12 @@ namespace Entity
base.OnUpdate(elapseSeconds, realElapseSeconds); base.OnUpdate(elapseSeconds, realElapseSeconds);
if (!_isActive || _projectileData == null) return; if (!_isActive || _projectileData == null) return;
bool isSimulationDriven = IsDrivenBySimulationWorld();
if (isSimulationDriven != _isSimulationDriven)
{
_isSimulationDriven = isSimulationDriven;
SetColliderEnabled(!_isSimulationDriven);
}
if (_isSimulationDriven) return;
if (_projectileData.Speed > 0f)
{
CachedTransform.position += _direction * (_projectileData.Speed * elapseSeconds);
}
_elapsedTime += elapseSeconds;
if (_projectileData.LifeTime > 0f && _elapsedTime >= _projectileData.LifeTime)
{
Expire();
}
} }
protected override void OnHide(bool isShutdown, object userData) protected override void OnHide(bool isShutdown, object userData)
{ {
_isActive = false; _isActive = false;
_projectileData = null; _projectileData = null;
_elapsedTime = 0f;
_isSimulationDriven = false;
_impactData = default; _impactData = default;
_direction = Vector3.forward; _direction = Vector3.forward;
@ -114,12 +88,6 @@ namespace Entity
GameEntry.Entity.HideEntity(this); GameEntry.Entity.HideEntity(this);
} }
private static bool IsDrivenBySimulationWorld()
{
var simulationWorld = GameEntry.SimulationWorld;
return simulationWorld != null && simulationWorld.UseSimulationMovement;
}
private void SetColliderEnabled(bool enabled) private void SetColliderEnabled(bool enabled)
{ {
_cachedColliders ??= GetComponentsInChildren<Collider>(true); _cachedColliders ??= GetComponentsInChildren<Collider>(true);

View File

@ -82,13 +82,6 @@ namespace Entity
base.OnUpdate(elapseSeconds, realElapseSeconds); base.OnUpdate(elapseSeconds, realElapseSeconds);
UpdateAttackState(elapseSeconds); UpdateAttackState(elapseSeconds);
if (IsSimulationMovementEnabled())
{
return;
}
_movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
} }
protected override void OnDead(EntityBase attacker) protected override void OnDead(EntityBase attacker)

View File

@ -95,11 +95,6 @@ namespace Entity
if (_target == null) if (_target == null)
{ {
_movementComponent.SetMove(false); _movementComponent.SetMove(false);
if (!IsSimulationMovementEnabled())
{
_movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
}
return; return;
} }
@ -116,11 +111,6 @@ namespace Entity
_movementComponent.SetMove(true); _movementComponent.SetMove(true);
_movementComponent.SetDirection(GetTargetDirection()); _movementComponent.SetDirection(GetTargetDirection());
} }
if (!IsSimulationMovementEnabled())
{
_movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
}
} }
protected override void OnHide(bool isShutdown, object userData) protected override void OnHide(bool isShutdown, object userData)

View File

@ -212,7 +212,6 @@ namespace Entity
_inputComponent.OnUpdate(elapseSeconds, realElapseSeconds); _inputComponent.OnUpdate(elapseSeconds, realElapseSeconds);
_movementComponent.SetDirection(_inputComponent.Direction); _movementComponent.SetDirection(_inputComponent.Direction);
_movementComponent.OnUpdate(elapseSeconds, realElapseSeconds);
_absorbComponent.OnUpdate(elapseSeconds, realElapseSeconds); _absorbComponent.OnUpdate(elapseSeconds, realElapseSeconds);
} }

View File

@ -13,26 +13,7 @@ namespace Entity.Weapon
return null; return null;
} }
if (TrySelectFromSpatialIndex(weapon, maxSqrRange, out EntityBase indexedTarget)) return TrySelectFromSpatialIndex(weapon, maxSqrRange, out EntityBase indexedTarget) ? indexedTarget : null;
{
return indexedTarget;
}
EntityBase target = null;
float minSqrMagnitude = maxSqrRange > 0f ? maxSqrRange : float.MaxValue;
foreach (var candidate in candidates)
{
if (candidate == null || !candidate.Available) continue;
float sqrMagnitude = AIUtility.GetSqrMagnitudeXZ(weapon, candidate);
if (sqrMagnitude >= minSqrMagnitude) continue;
minSqrMagnitude = sqrMagnitude;
target = candidate;
}
return target;
} }
private static bool TrySelectFromSpatialIndex(WeaponBase weapon, float maxSqrRange, out EntityBase target) private static bool TrySelectFromSpatialIndex(WeaponBase weapon, float maxSqrRange, out EntityBase target)
@ -44,7 +25,7 @@ namespace Entity.Weapon
} }
var simulationWorld = GameEntry.SimulationWorld; var simulationWorld = GameEntry.SimulationWorld;
if (simulationWorld == null || !simulationWorld.UseSimulationMovement) if (simulationWorld == null)
{ {
return false; return false;
} }

View File

@ -36,11 +36,6 @@ namespace Simulation
in Vector3 center, float radius, int maxTargets, int shapeType, in Vector3 direction, float halfAngleDeg, in Vector3 center, float radius, int maxTargets, int shapeType, in Vector3 direction, float halfAngleDeg,
float halfWidth, float halfLength) float halfWidth, float halfLength)
{ {
if (!_useSimulationMovement)
{
return false;
}
if (sourceEntityId == 0 || radius <= 0f || maxTargets <= 0) if (sourceEntityId == 0 || radius <= 0f || maxTargets <= 0)
{ {
return false; return false;

View File

@ -16,7 +16,7 @@ namespace Simulation
public void OnLateUpdate() public void OnLateUpdate()
{ {
if (_world == null || !_world.UseSimulationMovement) if (_world == null)
{ {
return; return;
} }

View File

@ -0,0 +1,82 @@
using UnityEngine;
namespace Simulation
{
public sealed partial class SimulationWorld
{
private Transform _playerMovementTransform;
private Vector3 _playerMovementPosition;
private Vector3 _playerMovementDirection = Vector3.zero;
private float _playerMovementSpeed;
private bool _playerMovementActive;
public void SyncPlayerMovementInput(Transform playerTransform, bool isMoving, in Vector3 direction, float speed)
{
if (playerTransform == null)
{
ClearPlayerMovementState();
return;
}
if (_playerMovementTransform != playerTransform)
{
_playerMovementTransform = playerTransform;
_playerMovementPosition = playerTransform.position;
}
Vector3 planarDirection = direction;
planarDirection.y = 0f;
bool hasDirection = planarDirection.sqrMagnitude > Mathf.Epsilon;
if (hasDirection)
{
_playerMovementDirection = planarDirection.normalized;
}
else
{
_playerMovementDirection = Vector3.zero;
}
_playerMovementActive = isMoving && hasDirection;
_playerMovementSpeed = _playerMovementActive ? Mathf.Max(0f, speed) : 0f;
}
public void UnregisterPlayerMovement(Transform playerTransform)
{
if (playerTransform == null || _playerMovementTransform != playerTransform)
{
return;
}
ClearPlayerMovementState();
}
private Vector3 ResolvePlayerPositionForTick(in SimulationTickContext context)
{
if (_playerMovementTransform == null)
{
return context.PlayerPosition;
}
if (!_playerMovementActive || _playerMovementSpeed <= 0f ||
_playerMovementDirection.sqrMagnitude <= Mathf.Epsilon || context.DeltaTime <= 0f)
{
_playerMovementPosition = _playerMovementTransform.position;
return _playerMovementPosition;
}
_playerMovementPosition += _playerMovementDirection * (_playerMovementSpeed * context.DeltaTime);
_playerMovementPosition.y = _playerMovementTransform.position.y;
_playerMovementTransform.position = _playerMovementPosition;
return _playerMovementPosition;
}
private void ClearPlayerMovementState()
{
_playerMovementTransform = null;
_playerMovementPosition = Vector3.zero;
_playerMovementDirection = Vector3.zero;
_playerMovementSpeed = 0f;
_playerMovementActive = false;
}
}
}

View File

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

View File

@ -22,6 +22,7 @@ namespace Simulation
EnemyBinding.Clear(); EnemyBinding.Clear();
ProjectileBinding.Clear(); ProjectileBinding.Clear();
PickupBinding.Clear(); PickupBinding.Clear();
ClearPlayerMovementState();
} }
#endregion #endregion
@ -101,6 +102,31 @@ namespace Simulation
return true; return true;
} }
public void SyncEnemyMovementInput(int entityId, bool isMoving, in UnityEngine.Vector3 direction, float speed,
bool avoidEnemyOverlap, float enemyBodyRadius, int separationIterations)
{
if (!EnemyBinding.TryGetSimulationIndex(entityId, out int simulationIndex) || simulationIndex < 0 ||
simulationIndex >= _enemies.Count)
{
return;
}
EnemySimData enemyData = _enemies[simulationIndex];
enemyData.Speed = isMoving ? UnityEngine.Mathf.Max(0f, speed) : 0f;
enemyData.AvoidEnemyOverlap = avoidEnemyOverlap;
enemyData.EnemyBodyRadius = UnityEngine.Mathf.Max(0.01f, enemyBodyRadius);
enemyData.SeparationIterations = UnityEngine.Mathf.Max(1, separationIterations);
UnityEngine.Vector3 planarDirection = direction;
planarDirection.y = 0f;
if (planarDirection.sqrMagnitude > UnityEngine.Mathf.Epsilon)
{
enemyData.Forward = planarDirection.normalized;
}
_enemies[simulationIndex] = enemyData;
}
#endregion #endregion
#region Projectile Simulation State #region Projectile Simulation State

View File

@ -14,11 +14,6 @@ namespace Simulation
return false; return false;
} }
if (!_useSimulationMovement)
{
return false;
}
BuildEnemyTargetSpatialIndexIfNeeded(); BuildEnemyTargetSpatialIndexIfNeeded();
float cellSize = GetTargetSelectionCellSize(); float cellSize = GetTargetSelectionCellSize();

View File

@ -37,9 +37,6 @@ namespace Simulation
private const int ProjectileStateActive = 0; private const int ProjectileStateActive = 0;
private const int ProjectileStateExpired = 1; private const int ProjectileStateExpired = 1;
[Header("模拟世界全局设置")] [Tooltip("是否启用世界模拟")] [SerializeField]
private bool _useSimulationMovement = true;
private EntitySync _entitySync; private EntitySync _entitySync;
private TransformSync _transformSync; private TransformSync _transformSync;
private HitPresentation _hitPresentation; private HitPresentation _hitPresentation;
@ -47,7 +44,7 @@ namespace Simulation
public IReadOnlyList<EnemySimData> Enemies => _enemies; public IReadOnlyList<EnemySimData> Enemies => _enemies;
public IReadOnlyList<ProjectileSimData> Projectiles => _projectiles; public IReadOnlyList<ProjectileSimData> Projectiles => _projectiles;
public IReadOnlyList<PickupSimData> Pickups => _pickups; public IReadOnlyList<PickupSimData> Pickups => _pickups;
public bool UseSimulationMovement => _useSimulationMovement; public bool UseSimulationMovement => true;
#region Lifecycle #region Lifecycle
@ -68,14 +65,12 @@ namespace Simulation
public void Tick(in SimulationTickContext context) public void Tick(in SimulationTickContext context)
{ {
if (!_useSimulationMovement) Vector3 playerPosition = ResolvePlayerPositionForTick(in context);
{ SimulationTickContext resolvedContext =
return; new SimulationTickContext(context.DeltaTime, context.RealDeltaTime, playerPosition);
}
using (CustomProfilerMarker.TickEnemies.Auto()) using (CustomProfilerMarker.TickEnemies.Auto())
{ {
TickSimulationPipeline(in context); TickSimulationPipeline(in resolvedContext);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -40,11 +40,11 @@
- 当前主瓶颈明确:`MoveSeperation` 是绝对热点(约 `43%~47%` 帧占比P2 优先并行化该阶段。 - 当前主瓶颈明确:`MoveSeperation` 是绝对热点(约 `43%~47%` 帧占比P2 优先并行化该阶段。
- 评估口径可复现Android 端受 `60 fps` 上限影响,性能判断以 CPU `ms` 为准。 - 评估口径可复现Android 端受 `60 fps` 上限影响,性能判断以 CPU `ms` 为准。
## 回滚开关说明 ## 路线收敛说明
- 开关字段:`SimulationWorld._useSimulationMovement`(序列化私有字段) - `SimulationWorld.Tick(...)` 已收敛为战斗内唯一仿真执行入口。
- 对外接口:`UseSimulationMovement` / `SetUseSimulationMovement(bool)` - `UseSimulationMovement` 不再承担运行时双路径路由职责,不应再作为回滚到旧 `MovementComponent` 路径的开关理解。
- 回滚方式:将开关置 `false`,敌人立即回退到旧 `MovementComponent` 更新路径 - 敌人、投射物与目标查询的运行时行为统一以 `SimulationWorld` 主容器和 Burst Job 管线为准
- 验证建议:同场景同刷怪参数下执行 A/B 对比,确认行为一致与性能差异 - 验证建议:聚焦单一路径下的敌人移动、投射物生命周期、最近敌查询和 area hit 结果,而不是做旧路径 A/B 对照
## P2 交接建议 ## P2 交接建议
- Job/Burst 第一优先级:`MoveSeperation` 阶段并行化。 - Job/Burst 第一优先级:`MoveSeperation` 阶段并行化。

View File

@ -6,7 +6,7 @@
目标: 目标:
- 固化压测口径0.5k/1k/1.5k/2k - 固化压测口径0.5k/1k/1.5k/2k
- 给出回归验证结论 - 给出回归验证结论
- 给出开关/回滚策略 - 给出单一路径架构下的验证策略
- 给出最终验收判定(通过/不通过) - 给出最终验收判定(通过/不通过)
## 2. 验收标准(对齐 TodoList ## 2. 验收标准(对齐 TodoList
@ -23,21 +23,17 @@
- Profiler 口径:以 CPU `ms` 为主,`fps` 仅作辅助Android 端存在 60fps 上限) - Profiler 口径:以 CPU `ms` 为主,`fps` 仅作辅助Android 端存在 60fps 上限)
- Profiler 配置:`Call Stacks = Off` - Profiler 配置:`Call Stacks = Off`
## 4. P2 开关与回滚策略 ## 4. P2 路线收敛说明
### 4.1 运行开关 ### 4.1 当前运行时语义
- `UseSimulationMovement` - `SimulationWorld.Tick(...)` 是战斗内唯一仿真执行入口。
- `UseJobSimulation` - 敌人移动、敌人分离、投射物推进、碰撞 broad-phase、最近敌查询统一走 Burst/Job 管线。
- `UseBurstJobs` - 文档中的 `UseJobSimulation`、`UseBurstJobs` 当前没有代码实现,不应再作为实际回滚方案描述。
### 4.2 生效时机约束 ### 4.2 验证重点
- `UseSimulationMovement` / `UseJobSimulation`:战斗内不支持热切换,需在 Battle 外修改后生效。 1. 验证单一路径下的敌人移动、投射物生命周期、碰撞候选与 area hit 结果。
- `UseBurstJobs`:可切换,但建议仅用于战斗外 A/B。 2. 验证 `Battle -> LevelUp -> Shop -> Battle` 与清场流程不会留下脏的仿真状态。
3. 验证 Debug/测试表面不再暴露旧 solver 或双路径开关语义。
### 4.3 回滚策略(建议)
1. 切回非 Job 路径:`UseJobSimulation = false`
2. 若仍异常,切回旧移动:`UseSimulationMovement = false`
3. 保留 `UseBurstJobs` 仅在 Job 路径 A/B 对照
## 5. 回归验证Checkpoint 9 ## 5. 回归验证Checkpoint 9
@ -57,7 +53,7 @@
#### 用例 110 分钟连续战斗 #### 用例 110 分钟连续战斗
- 执行时间:待填 - 执行时间:待填
- 场景/波次参数:待填 - 场景/波次参数:待填
- 运行开关:`UseSimulationMovement = true``UseJobSimulation = true``UseBurstJobs = true` - 运行路径:`SimulationWorld` Burst/Job 单一路径
- 结果:待填 - 结果:待填
- 日志/录屏:待填 - 日志/录屏:待填
- 备注:待填 - 备注:待填
@ -66,7 +62,7 @@
- 执行时间:已执行,见 `Logs/editmode-test-results.xml` - 执行时间:已执行,见 `Logs/editmode-test-results.xml`
- 操作步骤:由 EditMode 测试 `ProcedureGame_TransitionsBattleToLevelUpShopAndBackToBattle` 覆盖 - 操作步骤:由 EditMode 测试 `ProcedureGame_TransitionsBattleToLevelUpShopAndBackToBattle` 覆盖
- 执行方式:自动化测试 - 执行方式:自动化测试
- 运行开关:`UseSimulationMovement = true``UseJobSimulation = true``UseBurstJobs = true` - 运行路径:`SimulationWorld` Burst/Job 单一路径
- 结果:通过 - 结果:通过
- 日志/录屏:`Logs/editmode-test-results.xml` - 日志/录屏:`Logs/editmode-test-results.xml`
- 备注:验证 `ProcedureGame` 可从 `Battle` 正确切换到 `LevelUp`、再到 `Shop`,并最终返回 `Battle` - 备注:验证 `ProcedureGame` 可从 `Battle` 正确切换到 `LevelUp`、再到 `Shop`,并最终返回 `Battle`
@ -75,7 +71,7 @@
- 执行时间:已执行,见 `Logs/editmode-test-results.xml` - 执行时间:已执行,见 `Logs/editmode-test-results.xml`
- 验证范围:掉落注册 / 更新 / 回收 - 验证范围:掉落注册 / 更新 / 回收
- 执行方式:自动化测试 - 执行方式:自动化测试
- 运行开关:`UseSimulationMovement = true``UseJobSimulation = true``UseBurstJobs = true` - 运行路径:`SimulationWorld` Burst/Job 单一路径
- 结果:通过 - 结果:通过
- 日志/录屏:`Logs/editmode-test-results.xml` - 日志/录屏:`Logs/editmode-test-results.xml`
- 备注:由 EditMode 测试 `PickupLifecycle_UpsertAndRemove_KeepsBindingsConsistent` 覆盖,验证掉落在 `SimulationWorld` 中的生命周期与 binding remap 正常 - 备注:由 EditMode 测试 `PickupLifecycle_UpsertAndRemove_KeepsBindingsConsistent` 覆盖,验证掉落在 `SimulationWorld` 中的生命周期与 binding remap 正常

View File

@ -40,8 +40,8 @@
- [x] Checkpoint 3建立 Simulation 主更新入口并接入 Battle 状态 - [x] Checkpoint 3建立 Simulation 主更新入口并接入 Battle 状态
- 在 `GameStateBattle.OnUpdate` 中增加 `SimulationWorld.Tick(...)` 调用。 - 在 `GameStateBattle.OnUpdate` 中增加 `SimulationWorld.Tick(...)` 调用。
- 先只接“敌人移动/追踪”系统,其他逻辑保持原路径。 - 先只接“敌人移动/追踪”系统,其他逻辑保持原路径。
- 增加开关(建议 `UseSimulationMovement`)用于 A/B 对比与回滚 - 路线已收敛:不再维护 `UseSimulationMovement` 作为运行时 A/B 与回滚开关
- 完成标准:关闭开关与当前行为一致;开启开关后敌人仍能正常追踪玩家。 - 完成标准:`SimulationWorld.Tick(...)` 成为唯一执行入口,敌人仍能正常追踪玩家。
- [x] Checkpoint 4迁移敌人核心移动逻辑到 Simulation去 MonoBehaviour 核心逻辑) - [x] Checkpoint 4迁移敌人核心移动逻辑到 Simulation去 MonoBehaviour 核心逻辑)
- 将 `MeleeEnemy/RemoteEnemy` 的目标追踪、移动方向、攻击距离判定迁至 Simulation。 - 将 `MeleeEnemy/RemoteEnemy` 的目标追踪、移动方向、攻击距离判定迁至 Simulation。
@ -117,11 +117,9 @@
- `com.unity.jobs`(已废弃并并入 `com.unity.collections`Unity 2022.3 不再单独锁定包) - `com.unity.jobs`(已废弃并并入 `com.unity.collections`Unity 2022.3 不再单独锁定包)
- `com.unity.burst` - `com.unity.burst`
- `com.unity.mathematics` - `com.unity.mathematics`
- 增加 P2 运行开关(建议): - 文档中的 `UseJobSimulation` / `UseBurstJobs` 当前未落代码实现,不再作为运行时方案前提。
- `UseJobSimulation` - 约束:以当前 `SimulationWorld` Burst/Job 单一路径为唯一验收对象。
- `UseBurstJobs` - 完成标准Editor/Development Build 均可编译运行;单一路径行为稳定。
- 约束:默认可一键回退到 P1.5 路径,避免全量切换导致定位困难。
- 完成标准Editor/Development Build 均可编译运行;关闭开关时行为与 P1.5 一致。
- [x] Checkpoint 2Simulation 与 Job 数据通道打通(仅建通道,不改行为) - [x] Checkpoint 2Simulation 与 Job 数据通道打通(仅建通道,不改行为)
- 为敌人/投射物建立 Job 输入输出结构(纯数据,不含 `Transform`/托管引用)。 - 为敌人/投射物建立 Job 输入输出结构(纯数据,不含 `Transform`/托管引用)。

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-02

View File

@ -0,0 +1,61 @@
## Context
当前战斗运行时已经把 `SimulationWorld.Tick(...)` 接入主循环,并由 Burst Job 管线承担敌人移动、敌人分离、投射物推进与碰撞 broad-phase 的主体计算。但组件与实体层仍保留旧路径,包括 `MovementComponent.OnUpdate()` 直接改位移、`EnemyProjectile.OnUpdate()` 自驱动、`NearestTargetSelector` fallback 查询,以及 `EnemySeparationSolverProvider` 的旧互斥职责。这使运行时行为、调试入口和测试方式都围绕双路径假设展开。
本次变更横跨 `SimulationWorld`、实体/组件、战斗入口、调试面板、测试和文档,属于一次架构收敛,而不是单点修补。约束是:必须保留现有 Burst/Job 管线与 `_enemies/_projectiles/_pickups` 作为正式状态源,避免重新引入另一套仿真框架。
## Goals / Non-Goals
**Goals:**
- 让 `SimulationWorld` 成为战斗中的唯一仿真执行入口。
- 把实体与组件职责收敛为输入提交、注册同步和表现消费。
- 删除旧双路径分支、旧互斥 solver 依赖和与之绑定的调试入口。
- 将测试改为验证可观察行为,而不是依赖私有字段反射或 Native 通道细节。
- 同步文档,使其准确反映单一路径架构。
**Non-Goals:**
- 不重写现有 Burst/Job 算法本身。
- 不在本次变更中扩展新武器或新仿真能力。
- 不恢复或保留可切换的 P1.5 / 非 `SimulationWorld` 执行路径。
- 不以兼容旧测试为目标保留多余字段或调试钩子。
## Decisions
### 1. 保留现有 Burst Job 管线,删除双路径路由语义
- 决策:以 `SimulationWorld.TickSimulationPipeline(in SimulationTickContext context)` 作为唯一执行面,移除 `UseSimulationMovement` 的运行时路由职责;如果保留该字段,也只能作为全局停机开关而不是分支入口。
- 原因:现有可工作的完整路径只有 Burst Job 管线,继续维护组件驱动 fallback 没有等价能力,只会增加漂移。
- 备选方案:恢复旧路径并保留切换开关。否决原因是旧路径已不完整,恢复成本高且会持续制造测试矩阵和调试噪音。
### 2. 以 sim state 作为唯一真相源,实体侧只提交输入与消费输出
- 决策:`_enemies`、`_projectiles`、`_pickups` 继续作为正式运行时状态源;`MovementComponent`、敌人、玩家、投射物只维护输入态、注册态和表现同步所需数据,不再直接推进世界位置或互斥。
- 原因状态集中后Job 输入/输出和 presentation write-back 才能形成闭环,避免实体私自改写坐标导致状态撕裂。
- 备选方案:保留实体局部自驱动,再由 `SimulationWorld` 尽量同步。否决原因是会持续产生写冲突和追责困难。
### 3. 保留 `SimulationWorld` 查询/结算能力,移除实体 fallback 查询
- 决策:碰撞 broad-phase、area/sector/projectile 命中查询统一由 `SimulationWorld` 提供;目标选择器与武器逻辑直接依赖该能力,不再回退到遍历或组件侧查询。
- 原因查询能力只有与仿真状态源共用同一空间索引时才一致fallback 查询会导致命中范围与真实运行时不同步。
- 备选方案:保留 fallback 作为调试或兜底。否决原因是这会掩盖真实问题,并让生产行为与测试行为不一致。
### 4. 测试与调试面板跟随单一路径重建
- 决策:移除旧 solver 切换 UI 和依赖私有字段的测试模式,改为验证敌人移动、投射物生命周期、范围命中、实体 hide/remove 等外部行为,并仅展示当前 `SimulationWorld` 的指标。
- 原因:单一路径架构下,私有 Native 通道结构属于实现细节,不应成为长期契约。
- 备选方案:继续保留反射测试以降低短期改动量。否决原因是会固化错误抽象边界。
## Risks / Trade-offs
- [风险] 删除旧分支后,部分依赖 `MovementComponent.OnUpdate()` 或 projectile 自驱动的实体可能短期失效。 → 缓解:优先收敛战斗入口和输入同步接口,再逐类替换敌人、玩家、投射物的调用点。
- [风险] 测试从白盒切到黑盒后,定位 Native 通道回归会更慢。 → 缓解:保留必要运行时指标与日志,但不把私有字段暴露为长期契约。
- [风险] 文档和代码阶段性不同步会误导后续开发。 → 缓解:将文档同步列为同一 change 的完成条件,而不是后续补做。
- [权衡] 放弃双路径意味着失去旧逻辑的快速兜底。 → 缓解:通过更稳定的单路径测试覆盖和可观测指标替代兜底分支。
## Migration Plan
1. 先收敛战斗主入口与 `SimulationWorld` tick 语义,明确单一路径。
2. 再将 `MovementComponent`、敌人、玩家、投射物改为输入/注册/表现职责,移除旧 solver 与 fallback 查询。
3. 最后清理调试面板、重建测试、更新文档,确保仓库不再暴露旧路径语义。
4. 回滚策略仅限于回退整个 change不设计运行时开关回滚。
## Open Questions
- `UseSimulationMovement` 是否完全删除,还是保留为仅用于全局停机/诊断的只读配置,需要在实现前最终确认。
- 玩家位移是否已经有完整的 `SimulationWorld` 输入同步接口;若没有,需要先补足最小接口再移除 `MovementComponent.OnUpdate()` 调用。

View File

@ -0,0 +1,24 @@
## Why
`SimulationWorld` 当前已经以 Burst Job 管线承担主要仿真职责,但运行时仍残留组件自驱动、实体 fallback 和旧调试/测试路径,导致移动、碰撞和生命周期逻辑在两套语义之间分叉。继续维护这些旧路径只会放大行为漂移和测试脆弱性,因此需要把运行时彻底收敛到单一路径。
## What Changes
- 将 `SimulationWorld` Burst/Job 管线固化为战斗中的唯一仿真执行入口。
- 删除 `UseSimulationMovement` 一类双路径路由语义,以及实体、组件中的自驱动移动和 fallback 查询逻辑。
- 将 `MovementComponent`、敌人/投射物实体、目标选择器收敛为输入同步、注册管理和表现消费层。
- 清理 `EnemySeparationSolverProvider` 及其调试面板入口,移除旧互斥 solver 的运行时依赖。
- 重建测试与文档,使其面向外部可观察行为,而不是旧私有字段和 Native 通道反射。
## Capabilities
### New Capabilities
- `simulationworld-runtime-convergence`: Define `SimulationWorld` as the sole runtime simulation path for movement, projectile stepping, collision queries, presentation sync, and related battle integration.
### Modified Capabilities
## Impact
- Affected code: `Assets/GameMain/Scripts/Simulation/**`, `Assets/GameMain/Scripts/Components/MovementComponent.cs`, `Assets/GameMain/Scripts/Entity/EntityLogic/**`, `Assets/GameMain/Scripts/Procedure/Game/**`, `Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs`, `Assets/Tests/Simulation/**`, `docs/P1.5 Simulation-Supplement.md`, `docs/P2 Job System + Burst 落地.md`, `docs/TodoList.md`.
- Runtime impact: battle update order, enemy/player/projectile movement, collision candidate queries, target selection, presentation write-back, and simulation lifecycle reset behavior.
- Breaking impact: removes legacy runtime branches and debug affordances that assume a non-`SimulationWorld` execution path exists.

View File

@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: SimulationWorld SHALL be the sole battle simulation executor
The battle runtime MUST execute movement, projectile stepping, collision broad-phase, and related simulation state updates through `SimulationWorld`, and it MUST NOT route these responsibilities through an alternative non-`SimulationWorld` runtime path.
#### Scenario: Battle tick advances through SimulationWorld
- **WHEN** the battle update loop advances a gameplay frame
- **THEN** `SimulationWorld` executes the simulation pipeline for that frame as the authoritative runtime update path
#### Scenario: Legacy routing switch does not select an alternate executor
- **WHEN** runtime configuration related to simulation movement is evaluated
- **THEN** it does not select or re-enable a separate legacy movement execution path
### Requirement: Runtime entities SHALL submit input and consume simulation output only
Enemy entities, the player entity, projectile entities, and `MovementComponent` MUST submit movement or behavior input into `SimulationWorld` state and MUST consume position, facing, hit, or lifecycle results from simulation output, rather than computing world-space advancement independently.
#### Scenario: MovementComponent no longer advances transforms directly
- **WHEN** movement input, speed, or separation parameters change on an entity
- **THEN** the component synchronizes those values to `SimulationWorld` state without directly moving the entity transform
#### Scenario: Entity presentation follows simulation output
- **WHEN** simulation state is committed for enemies or projectiles
- **THEN** entity presentation updates consume the committed output to refresh transforms, facing, visibility, or removal state
### Requirement: Spatial queries SHALL use SimulationWorld-owned data and services
Target selection, projectile hits, area hits, and sector hits MUST use `SimulationWorld` spatial indexing and query services, and MUST NOT fall back to legacy entity-side traversal or solver-specific query paths.
#### Scenario: Target selection uses simulation spatial data
- **WHEN** weapon or AI logic requests nearby or nearest targets
- **THEN** the query resolves against `SimulationWorld` maintained spatial data for the current frame
#### Scenario: Projectile and area hit evaluation stay on simulation path
- **WHEN** projectile, area, or sector hit logic runs during combat
- **THEN** hit candidates are produced from `SimulationWorld` collision and query capabilities instead of a fallback entity-side path
### Requirement: Runtime surfaces SHALL reflect the single-path architecture
Runtime debug surfaces, automated tests, and architecture documents MUST reflect that the project supports one authoritative `SimulationWorld` execution path rather than dual-path behavior.
#### Scenario: Debug panel omits legacy solver controls
- **WHEN** runtime simulation debugging is displayed
- **THEN** it shows current `SimulationWorld` metrics without exposing legacy solver switching or dual-path controls
#### Scenario: Regression tests validate observable single-path behavior
- **WHEN** simulation regression coverage is maintained
- **THEN** tests validate observable outcomes such as movement, projectile lifetime, hit results, and hide/remove lifecycle instead of asserting private compatibility fields for legacy paths

View File

@ -0,0 +1,23 @@
## 1. Battle Runtime Convergence
- [x] 1.1 Update `SimulationWorld` and battle entry points so the Burst/Job simulation pipeline is the only runtime execution path.
- [x] 1.2 Remove or redefine `UseSimulationMovement` and related semantics so it can no longer route combat through a legacy non-simulation path.
- [x] 1.3 Confirm `ClearSimulationState()` and battle bootstrap/reset flows only clear simulation-owned state without reintroducing dual-path behavior.
## 2. Entity and Component Path Cleanup
- [x] 2.1 Refactor `MovementComponent` into an input/register/sync layer and remove direct transform advancement plus `EnemySeparationSolverProvider` runtime dependence.
- [x] 2.2 Remove enemy and player calls that self-advance movement or branch on `UseSimulationMovement`, replacing them with simulation input submission and presentation consumption.
- [x] 2.3 Remove projectile self-driven movement/lifetime logic and make projectile presentation follow `SimulationWorld` output only.
## 3. Query, Debug, and Regression Alignment
- [x] 3.1 Route target selection and hit queries exclusively through `SimulationWorld` spatial data and remove legacy fallback traversal paths.
- [x] 3.2 Clean the runtime debug panel so it exposes current `SimulationWorld` metrics without legacy solver or dual-path controls.
- [x] 3.3 Rewrite simulation regression tests to validate observable behavior such as movement, projectile lifetime, hit results, and hide/remove lifecycle.
## 4. Documentation and Verification
- [x] 4.1 Update simulation architecture documents and todo notes to describe the single authoritative `SimulationWorld` path and remove stale switch descriptions.
- [x] 4.2 Run targeted verification for battle movement, projectile flow, query behavior, and updated tests to confirm no legacy execution path remains.

View File

@ -0,0 +1,51 @@
# simulationworld-runtime-convergence
## Purpose
Define the battle runtime contract that `SimulationWorld` is the single authoritative simulation path for movement, projectile stepping, collision/query execution, and presentation-driven output consumption.
## Requirements
### Requirement: SimulationWorld SHALL be the sole battle simulation executor
The battle runtime MUST execute movement, projectile stepping, collision broad-phase, and related simulation state updates through `SimulationWorld`, and it MUST NOT route these responsibilities through an alternative non-`SimulationWorld` runtime path.
#### Scenario: Battle tick advances through SimulationWorld
- **WHEN** the battle update loop advances a gameplay frame
- **THEN** `SimulationWorld` executes the simulation pipeline for that frame as the authoritative runtime update path
#### Scenario: Legacy routing switch does not select an alternate executor
- **WHEN** runtime configuration related to simulation movement is evaluated
- **THEN** it does not select or re-enable a separate legacy movement execution path
### Requirement: Runtime entities SHALL submit input and consume simulation output only
Enemy entities, the player entity, projectile entities, and `MovementComponent` MUST submit movement or behavior input into `SimulationWorld` state and MUST consume position, facing, hit, or lifecycle results from simulation output, rather than computing world-space advancement independently.
#### Scenario: MovementComponent no longer advances transforms directly
- **WHEN** movement input, speed, or separation parameters change on an entity
- **THEN** the component synchronizes those values to `SimulationWorld` state without directly moving the entity transform
#### Scenario: Entity presentation follows simulation output
- **WHEN** simulation state is committed for enemies or projectiles
- **THEN** entity presentation updates consume the committed output to refresh transforms, facing, visibility, or removal state
### Requirement: Spatial queries SHALL use SimulationWorld-owned data and services
Target selection, projectile hits, area hits, and sector hits MUST use `SimulationWorld` spatial indexing and query services, and MUST NOT fall back to legacy entity-side traversal or solver-specific query paths.
#### Scenario: Target selection uses simulation spatial data
- **WHEN** weapon or AI logic requests nearby or nearest targets
- **THEN** the query resolves against `SimulationWorld` maintained spatial data for the current frame
#### Scenario: Projectile and area hit evaluation stay on simulation path
- **WHEN** projectile, area, or sector hit logic runs during combat
- **THEN** hit candidates are produced from `SimulationWorld` collision and query capabilities instead of a fallback entity-side path
### Requirement: Runtime surfaces SHALL reflect the single-path architecture
Runtime debug surfaces, automated tests, and architecture documents MUST reflect that the project supports one authoritative `SimulationWorld` execution path rather than dual-path behavior.
#### Scenario: Debug panel omits legacy solver controls
- **WHEN** runtime simulation debugging is displayed
- **THEN** it shows current `SimulationWorld` metrics without exposing legacy solver switching or dual-path controls
#### Scenario: Regression tests validate observable single-path behavior
- **WHEN** simulation regression coverage is maintained
- **THEN** tests validate observable outcomes such as movement, projectile lifetime, hit results, and hide/remove lifecycle instead of asserting private compatibility fields for legacy paths