diff --git a/.gitignore b/.gitignore index c0740b5..7d1d397 100644 --- a/.gitignore +++ b/.gitignore @@ -75,9 +75,8 @@ crashlytics-build.properties # Packed Addressables /[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin* -# Temporary auto-generated Android Assets -/[Aa]ssets/[Ss]treamingAssets/aa.meta -/[Aa]ssets/[Ss]treamingAssets/aa/* +/Assets/StreamingAssets +/Assets/StreamingAssets.meta /UI参考 /AGENTS.md diff --git a/Assets/GameMain/Configs/ResourceBuilder.xml b/Assets/GameMain/Configs/ResourceBuilder.xml index a787b58..5eb1750 100644 --- a/Assets/GameMain/Configs/ResourceBuilder.xml +++ b/Assets/GameMain/Configs/ResourceBuilder.xml @@ -2,7 +2,7 @@ - 3 + 5 33 1 UnityGameFramework.Runtime.DefaultCompressionHelper diff --git a/Assets/GameMain/Configs/ResourceCollection.xml b/Assets/GameMain/Configs/ResourceCollection.xml index 5abf749..5324e65 100644 --- a/Assets/GameMain/Configs/ResourceCollection.xml +++ b/Assets/GameMain/Configs/ResourceCollection.xml @@ -2,19 +2,28 @@ - - + + - - - - + + + + - - - + + + @@ -41,9 +50,11 @@ - + - + @@ -92,7 +103,8 @@ - + @@ -132,7 +144,9 @@ - + + @@ -157,6 +171,7 @@ + diff --git a/Assets/GameMain/Entities/MeleeEnemy.prefab b/Assets/GameMain/Entities/MeleeEnemy.prefab index c34424c..1d6c23a 100644 --- a/Assets/GameMain/Entities/MeleeEnemy.prefab +++ b/Assets/GameMain/Entities/MeleeEnemy.prefab @@ -11,7 +11,6 @@ GameObject: - component: {fileID: 7683855655592166216} - component: {fileID: 6418687210998749921} - component: {fileID: 4710806460657047075} - - component: {fileID: 8116679074104541426} - component: {fileID: 1932268889601128120} - component: {fileID: 557030043145096197} - component: {fileID: 6353753365317756414} @@ -87,33 +86,6 @@ MeshRenderer: m_SortingLayer: 0 m_SortingOrder: 0 m_AdditionalVertexStreams: {fileID: 0} ---- !u!54 &8116679074104541426 -Rigidbody: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 9166462022471897675} - serializedVersion: 4 - m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 - m_CenterOfMass: {x: 0, y: 0, z: 0} - m_InertiaTensor: {x: 1, y: 1, z: 1} - m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} - m_IncludeLayers: - serializedVersion: 2 - m_Bits: 0 - m_ExcludeLayers: - serializedVersion: 2 - m_Bits: 5376 - m_ImplicitCom: 1 - m_ImplicitTensor: 1 - m_UseGravity: 0 - m_IsKinematic: 1 - m_Interpolate: 0 - m_Constraints: 0 - m_CollisionDetection: 0 --- !u!136 &1932268889601128120 CapsuleCollider: m_ObjectHideFlags: 0 @@ -131,7 +103,7 @@ CapsuleCollider: m_LayerOverridePriority: 0 m_IsTrigger: 1 m_ProvidesContacts: 0 - m_Enabled: 1 + m_Enabled: 0 serializedVersion: 2 m_Radius: 0.5 m_Height: 2 diff --git a/Assets/GameMain/Entities/RemoteEnemy.prefab b/Assets/GameMain/Entities/RemoteEnemy.prefab index e12de4c..4facb8d 100644 --- a/Assets/GameMain/Entities/RemoteEnemy.prefab +++ b/Assets/GameMain/Entities/RemoteEnemy.prefab @@ -103,7 +103,7 @@ CapsuleCollider: m_LayerOverridePriority: 0 m_IsTrigger: 0 m_ProvidesContacts: 0 - m_Enabled: 1 + m_Enabled: 0 serializedVersion: 2 m_Radius: 0.5 m_Height: 2 diff --git a/Assets/GameMain/Scripts/CustomComponent/DamageText/DamageTextComponent.cs b/Assets/GameMain/Scripts/CustomComponent/DamageText/DamageTextComponent.cs index 86efb80..19782fc 100644 --- a/Assets/GameMain/Scripts/CustomComponent/DamageText/DamageTextComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/DamageText/DamageTextComponent.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using GameFramework.ObjectPool; using TMPro; @@ -9,7 +10,7 @@ namespace CustomComponent { public class DamageTextComponent : GameFrameworkComponent { - [SerializeField] private int _instancePoolCapacity = 32; + [SerializeField] private int _instancePoolCapacity = 256; [SerializeField] private string _poolName = "DamageTextItem"; @@ -43,14 +44,20 @@ namespace CustomComponent private DamageTextItem CreateDamageTextItem() { + if (_activeDamageTextItems.Count == _instancePoolCapacity) + { + _instancePoolCapacity = Mathf.Min(_instancePoolCapacity * 2, 1024); + _damageTextItemPool.Capacity = _instancePoolCapacity; + } + DamageTextItemObject itemObject = _damageTextItemPool.Spawn(); if (itemObject != null) { return (DamageTextItem)itemObject.Target; } - + GameObject itemGo = Instantiate(_damageTextItemPrefab, _instanceRoot, false); - + DamageTextItem item = itemGo.GetComponent(); _damageTextItemPool.Register(DamageTextItemObject.Create(item), true); return item; @@ -63,5 +70,12 @@ namespace CustomComponent _activeDamageTextItems.Remove(item); _damageTextItemPool.Unspawn(item); } + + private void OnDestroy() + { + _activeDamageTextItems.Clear(); + _damageTextItemPool.Release(); + _damageTextItemPool = null; + } } } \ No newline at end of file diff --git a/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs b/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs index 84594fd..3f84ba5 100644 --- a/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/DebugPanel/RuntimeDebugPanelComponent.cs @@ -1,6 +1,8 @@ #if UNITY_EDITOR || DEVELOPMENT_BUILD using System; using System.Linq; +using Components; +using CustomEvent; using DataTable; using Definition.DataStruct; using Entity; @@ -19,6 +21,7 @@ namespace CustomComponent private const float MinSpawnRate = 0.1f; private const float CornerTapWindow = 0.6f; private const int RequiredCornerTapCount = 3; + private const int DebugHealAmount = 200; private Rect _windowRect = new Rect(20f, 60f, 460f, 800f); private bool _isPanelVisible; @@ -38,6 +41,7 @@ namespace CustomComponent private int _cornerTapCount; private float _lastCornerTapTime = -10f; + private bool _lockPlayerHealthToMax; protected override void Awake() { @@ -54,6 +58,10 @@ namespace CustomComponent } HandleCornerTapGesture(); + if (_lockPlayerHealthToMax) + { + KeepPlayerHealthAtMax(); + } } private void OnGUI() @@ -147,6 +155,7 @@ namespace CustomComponent ProcedureGame procedure = GameEntry.Procedure.CurrentProcedure as ProcedureGame; EnemyManagerComponent enemyManager = GameEntry.EnemyManager; Player player = FindPlayer(); + HealthComponent playerHealth = player != null ? player.GetComponent() : null; if (enemyManager == null) { @@ -168,8 +177,6 @@ namespace CustomComponent if (simulationWorld != null) { GUILayout.Space(4f); - GUILayout.Label( - $"Sim Switch: Move={(simulationWorld.UseSimulationMovement ? "On" : "Off")} Job={(simulationWorld.UseJobSimulation ? "On" : "Off")} Burst={(simulationWorld.UseBurstJobs ? "On" : "Off")}"); GUILayout.Label( $"Collision Queries: total {simulationWorld.LastCollisionQueryCount} (Projectile {simulationWorld.LastProjectileCollisionQueryCount} / Area {simulationWorld.LastAreaCollisionQueryCount})"); GUILayout.Label( @@ -265,6 +272,28 @@ namespace CustomComponent GUI.enabled = true; GUILayout.EndHorizontal(); + + GUILayout.Space(4f); + GUILayout.Label( + $"Player HP: {(playerHealth == null ? "Unavailable" : $"{playerHealth.CurrentHealth}/{playerHealth.MaxHealth}")}"); + GUILayout.BeginHorizontal(); + GUI.enabled = playerHealth != null; + if (GUILayout.Button($"+{DebugHealAmount} HP", GUILayout.Height(24f))) + { + AddPlayerHealth(playerHealth, DebugHealAmount); + } + + if (GUILayout.Button(_lockPlayerHealthToMax ? "GodMode: ON" : "GodMode: OFF", GUILayout.Height(24f))) + { + _lockPlayerHealthToMax = !_lockPlayerHealthToMax; + if (_lockPlayerHealthToMax) + { + RestorePlayerHealthToMax(playerHealth); + } + } + + GUI.enabled = true; + GUILayout.EndHorizontal(); } private void EnsurePropList(bool force = false) @@ -324,6 +353,52 @@ namespace CustomComponent return UnityEngine.Object.FindObjectOfType(); } + private void KeepPlayerHealthAtMax() + { + Player player = FindPlayer(); + if (player == null) return; + + HealthComponent playerHealth = player.GetComponent(); + if (playerHealth == null) return; + + RestorePlayerHealthToMax(playerHealth); + } + + private static void AddPlayerHealth(HealthComponent playerHealth, int amount) + { + if (playerHealth == null || amount <= 0) return; + if (playerHealth.CurrentHealth <= 0) return; + + int maxHealth = playerHealth.MaxHealth; + if (maxHealth <= 0) return; + + int nextHealth = Mathf.Clamp(playerHealth.CurrentHealth + amount, 0, maxHealth); + if (nextHealth == playerHealth.CurrentHealth) return; + + playerHealth.CurrentHealth = nextHealth; + PublishPlayerHealthChanged(playerHealth); + } + + private static void RestorePlayerHealthToMax(HealthComponent playerHealth) + { + if (playerHealth == null) return; + if (playerHealth.CurrentHealth <= 0) return; + + int maxHealth = playerHealth.MaxHealth; + if (maxHealth <= 0 || playerHealth.CurrentHealth >= maxHealth) return; + + playerHealth.CurrentHealth = maxHealth; + PublishPlayerHealthChanged(playerHealth); + } + + private static void PublishPlayerHealthChanged(HealthComponent playerHealth) + { + if (playerHealth == null || GameEntry.Event == null) return; + + GameEntry.Event.Fire(null, + PlayerHealthChangeEventArgs.Create(0, playerHealth.CurrentHealth, playerHealth.MaxHealth)); + } + private static void AddSelectedBuffToPlayer(DRProp prop, int count) { Player player = FindPlayer(); @@ -407,4 +482,4 @@ namespace CustomComponent } } } -#endif \ No newline at end of file +#endif diff --git a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs index 9c24e7b..fe2c13e 100644 --- a/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs +++ b/Assets/GameMain/Scripts/Debugger/ProfilerMarker.cs @@ -4,23 +4,26 @@ 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_Schedule = new ProfilerMarker("TickEnemies.Schedule"); - public static readonly ProfilerMarker TickEnemies_Complete = new ProfilerMarker("TickEnemies.Complete"); - public static readonly ProfilerMarker TickEnemies_MainThreadCommit = new ProfilerMarker("TickEnemies.MainThreadCommit"); - public static readonly ProfilerMarker TickEnemies_WriteBack = new ProfilerMarker("TickEnemies.WriteBack"); + public static readonly ProfilerMarker TickEnemies = new("TickEnemies"); + public static readonly ProfilerMarker TickEnemies_BuildInput = new("TickEnemies.BuildInput"); + public static readonly ProfilerMarker TickEnemies_StateUpdate = new("TickEnemies.StateUpdate"); + public static readonly ProfilerMarker TickEnemies_Schedule = new("TickEnemies.Schedule"); + public static readonly ProfilerMarker TickEnemies_Complete = new("TickEnemies.Complete"); + public static readonly ProfilerMarker TickEnemies_MainThreadCommit = new("TickEnemies.MainThreadCommit"); + public static readonly ProfilerMarker TickEnemies_WriteBack = new("TickEnemies.WriteBack"); + + public static readonly ProfilerMarker Collision = new("Collision"); public static readonly ProfilerMarker Collision_BuildQueries = new("Collision.BuildQueries"); public static readonly ProfilerMarker Collision_BuildBuckets = new("Collision.BuildBuckets"); public static readonly ProfilerMarker Collision_QueryCandidates = new("Collision.QueryCandidates"); public static readonly ProfilerMarker Collision_ResolveProjectile = new("Collision.ResolveProjectile"); public static readonly ProfilerMarker Collision_ResolveArea = new("Collision.ResolveArea"); + public static readonly ProfilerMarker TargetSelection_BuildBuckets = new("TargetSelection.BuildBuckets"); public static readonly ProfilerMarker TargetSelection_QueryNeighbors = new("TargetSelection.QueryNeighbors"); - public static readonly ProfilerMarker Movement_Update = new ProfilerMarker("Movement_Update"); + + public static readonly ProfilerMarker Movement_Update = new("Movement_Update"); public static readonly ProfilerMarker ShopUI_Update = new("UGF.ShopUI.Update"); public static readonly ProfilerMarker Inventory_Refresh = new("UGF.Inventory.Refresh"); - } -} + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs index 410edd8..5d19702 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/EnemyProjectile.cs @@ -117,7 +117,7 @@ namespace Entity private static bool IsDrivenBySimulationWorld() { var simulationWorld = GameEntry.SimulationWorld; - return simulationWorld != null && simulationWorld.UseSimulationMovement && simulationWorld.UseJobSimulation; + return simulationWorld != null && simulationWorld.UseSimulationMovement; } private void SetColliderEnabled(bool enabled) @@ -137,4 +137,4 @@ namespace Entity } } } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerAttackEffect.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerAttackEffect.cs index dd8e814..22787f8 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerAttackEffect.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerAttackEffect.cs @@ -1,13 +1,23 @@ +using GameFramework.ObjectPool; using UnityEngine; namespace Entity.Weapon { public sealed class HandgunHitMarkerAttackEffect : IWeaponAttackEffect { + private const string PoolName = "Weapon.HandgunHitMarker"; + private const float PoolAutoReleaseInterval = 60f; + private const int PoolCapacity = 128; + private const float PoolExpireTime = 120f; + private const int PoolPriority = 0; + + private static IObjectPool s_Pool; + private readonly float _size; private readonly float _yOffset; private readonly float _duration; private readonly Color _color; + private Material _sharedMaterial; public HandgunHitMarkerAttackEffect(float size, float yOffset, float duration, Color color) { @@ -20,35 +30,95 @@ namespace Entity.Weapon public void Play(WeaponBase weapon, Vector3 position, EntityBase target, float radius) { if (target == null) return; + if (!TrySpawnMarker(out HandgunHitMarkerPooledInstance markerInstance)) + { + return; + } - GameObject marker = GameObject.CreatePrimitive(PrimitiveType.Sphere); - marker.name = "HandgunHitMarker"; + Transform targetTransform = target.CachedTransform; + Vector3 worldPosition = targetTransform != null ? targetTransform.position : position; + markerInstance.transform.SetParent(null, false); + markerInstance.transform.position = worldPosition + Vector3.up * _yOffset; + markerInstance.transform.localScale = Vector3.one * Mathf.Max(0.01f, _size); + markerInstance.ApplyMaterial(GetSharedMaterial()); + markerInstance.Activate(Mathf.Max(0.01f, _duration), s_Pool); + } - Collider collider = marker.GetComponent(); + private bool TrySpawnMarker(out HandgunHitMarkerPooledInstance markerInstance) + { + markerInstance = null; + IObjectPool pool = EnsurePool(); + if (pool == null) + { + return false; + } + + HandgunHitMarkerPoolObject pooledObject = pool.Spawn(); + if (pooledObject != null) + { + markerInstance = pooledObject.Target as HandgunHitMarkerPooledInstance; + if (markerInstance != null) + { + return true; + } + } + + GameObject markerGameObject = GameObject.CreatePrimitive(PrimitiveType.Sphere); + markerGameObject.name = "HandgunHitMarker"; + Collider collider = markerGameObject.GetComponent(); if (collider != null) { Object.Destroy(collider); } - marker.transform.SetParent(target.CachedTransform, false); - marker.transform.localPosition = new Vector3(0f, _yOffset, 0f); - marker.transform.localScale = Vector3.one * Mathf.Max(0.01f, _size); + markerInstance = markerGameObject.AddComponent(); + markerGameObject.SetActive(false); + pool.Register(HandgunHitMarkerPoolObject.Create(markerInstance), true); + return true; + } - Renderer renderer = marker.GetComponent(); - if (renderer != null) + private static IObjectPool EnsurePool() + { + var poolComponent = GameEntry.ObjectPool; + if (poolComponent == null) { - Shader shader = Shader.Find("Sprites/Default"); - if (shader == null) - { - shader = Shader.Find("Unlit/Color"); - } - - Material material = new Material(shader); - material.color = _color; - renderer.material = material; + return null; } - Object.Destroy(marker, Mathf.Max(0.01f, _duration)); + if (s_Pool != null && poolComponent.HasObjectPool(PoolName)) + { + return s_Pool; + } + + s_Pool = poolComponent.HasObjectPool(PoolName) + ? poolComponent.GetObjectPool(PoolName) + : poolComponent.CreateSingleSpawnObjectPool( + PoolName, + PoolAutoReleaseInterval, + PoolCapacity, + PoolExpireTime, + PoolPriority); + return s_Pool; + } + + private Material GetSharedMaterial() + { + if (_sharedMaterial != null) + { + return _sharedMaterial; + } + + Shader shader = Shader.Find("Sprites/Default"); + if (shader == null) + { + shader = Shader.Find("Unlit/Color"); + } + + _sharedMaterial = new Material(shader) + { + color = _color + }; + return _sharedMaterial; } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs new file mode 100644 index 0000000..57efc88 --- /dev/null +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs @@ -0,0 +1,27 @@ +using GameFramework; +using GameFramework.ObjectPool; +using UnityEngine; + +namespace Entity.Weapon +{ + public sealed class HandgunHitMarkerPoolObject : ObjectBase + { + public static HandgunHitMarkerPoolObject Create(object target) + { + HandgunHitMarkerPoolObject pooledObject = ReferencePool.Acquire(); + pooledObject.Initialize(target); + return pooledObject; + } + + protected override void Release(bool isShutdown) + { + HandgunHitMarkerPooledInstance markerInstance = Target as HandgunHitMarkerPooledInstance; + if (markerInstance == null) + { + return; + } + + Object.Destroy(markerInstance.gameObject); + } + } +} diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs.meta new file mode 100644 index 0000000..3dd2f9b --- /dev/null +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPoolObject.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 966d34a18f5d00c49bddea3c7c5ec13d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs new file mode 100644 index 0000000..accc3a3 --- /dev/null +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs @@ -0,0 +1,78 @@ +using GameFramework.ObjectPool; +using UnityEngine; + +namespace Entity.Weapon +{ + public sealed class HandgunHitMarkerPooledInstance : MonoBehaviour + { + private Renderer _renderer; + private IObjectPool _ownerPool; + private float _expireTime; + private bool _isActive; + + private void Awake() + { + _renderer = GetComponent(); + } + + private void Update() + { + if (!_isActive) + { + return; + } + + if (Time.time < _expireTime) + { + return; + } + + ReturnToPool(); + } + + public void ApplyMaterial(Material material) + { + if (_renderer == null) + { + _renderer = GetComponent(); + } + + if (_renderer == null) + { + return; + } + + _renderer.sharedMaterial = material; + } + + public void Activate(float duration, IObjectPool pool) + { + _ownerPool = pool; + _expireTime = Time.time + Mathf.Max(0.01f, duration); + _isActive = true; + gameObject.SetActive(true); + } + + private void OnDisable() + { + _isActive = false; + _ownerPool = null; + } + + private void ReturnToPool() + { + _isActive = false; + IObjectPool pool = _ownerPool; + _ownerPool = null; + transform.SetParent(null, false); + + if (pool == null) + { + gameObject.SetActive(false); + return; + } + + pool.Unspawn(this); + } + } +} diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs.meta b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs.meta new file mode 100644 index 0000000..328f200 --- /dev/null +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/AttackEffects/HandgunHitMarkerPooledInstance.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7ff8a67466d8f0643845c41fd5952f50 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs index 5ff9dcb..1d72447 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/TargetSelector/NearestTargetSelector.cs @@ -44,7 +44,7 @@ namespace Entity.Weapon } var simulationWorld = GameEntry.SimulationWorld; - if (simulationWorld == null || !simulationWorld.UseSimulationMovement || !simulationWorld.UseJobSimulation) + if (simulationWorld == null || !simulationWorld.UseSimulationMovement) { return false; } @@ -65,4 +65,4 @@ namespace Entity.Weapon return true; } } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs index 46d2f81..754a296 100644 --- a/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs +++ b/Assets/GameMain/Scripts/Entity/EntityLogic/Weapon/WeaponBase.cs @@ -229,7 +229,7 @@ namespace Entity.Weapon } int ownerEntityId = WeaponData != null ? WeaponData.OwnerId : Id; - return simulationWorld.TryEnqueueAreaCollisionQuery(Id, ownerEntityId, in center, radius, maxTargets); + return simulationWorld.TryRequestAreaCollision(Id, ownerEntityId, in center, radius, maxTargets); } protected bool TryQueueSectorCollisionQuery(in Vector3 center, float radius, in Vector3 direction, @@ -242,7 +242,7 @@ namespace Entity.Weapon } int ownerEntityId = WeaponData != null ? WeaponData.OwnerId : Id; - return simulationWorld.TryEnqueueSectorCollisionQuery(Id, ownerEntityId, in center, radius, in direction, + return simulationWorld.TryRequestSectorCollision(Id, ownerEntityId, in center, radius, in direction, halfAngleDeg, maxTargets); } diff --git a/Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs b/Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs index f7280d5..12efc78 100644 --- a/Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs +++ b/Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs @@ -88,7 +88,7 @@ namespace Procedure base.OnEnter(procedureOwner); _procedureOwner = procedureOwner; - GameEntry.SimulationWorld?.Clear(); + GameEntry.SimulationWorld?.ClearSimulationState(); GameEntry.Event.Subscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess); GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, ShowEntitySuccess); @@ -136,7 +136,7 @@ namespace Procedure Player = null; _procedureOwner = null; - GameEntry.SimulationWorld?.Clear(); + GameEntry.SimulationWorld?.ClearSimulationState(); GameEntry.Event.Unsubscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess); GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, ShowEntitySuccess); diff --git a/Assets/GameMain/Scripts/Simulation/DataChannel.meta b/Assets/GameMain/Scripts/Simulation/DataChannel.meta new file mode 100644 index 0000000..db64f25 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/DataChannel.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5f28ff313e954720a04246191b7f9ddd +timeCreated: 1771901064 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs similarity index 78% rename from Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs rename to Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs index 04d36ba..c9ccb29 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs +++ b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs @@ -8,115 +8,7 @@ namespace Simulation { public sealed partial class SimulationWorld { - private struct EnemyJobInputData - { - public int EntityId; - public float3 Position; - public float3 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; - } - - private struct EnemyJobOutputData - { - public int EntityId; - public float3 Position; - public float3 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; - } - - private struct ProjectileJobInputData - { - public int EntityId; - public int OwnerEntityId; - public float3 Position; - public float3 Forward; - public float3 Velocity; - public float Speed; - public float LifeTime; - public float Age; - public bool Active; - public float RemainingLifetime; - public int State; - } - - private struct ProjectileJobOutputData - { - public int EntityId; - public int OwnerEntityId; - public float3 Position; - public float3 Forward; - public float3 Velocity; - public float Speed; - public float LifeTime; - public float Age; - public bool Active; - public float RemainingLifetime; - public int State; - } - - // Shared broad-phase query payload for CP5 projectile collision and CP6 AOE collision candidates. - private struct CollisionQueryData - { - public int QueryId; - public int SourceType; - public int SourceEntityId; - public int SourceOwnerEntityId; - public bool SourceWasActiveAtQueryTime; - public float3 Position; - public float Radius; - public int MaxTargets; - public int ShapeType; - public float3 Direction; - public float HalfAngleDeg; - } - - // Shared candidate buffer consumed by the main thread settlement stage. - private struct CollisionCandidateData - { - public int QueryId; - public int SourceType; - public int SourceEntityId; - public int SourceOwnerEntityId; - public int TargetEntityId; - public float SqrDistance; - } - - private struct AreaCollisionRequestData - { - public int SourceEntityId; - public int SourceOwnerEntityId; - public bool SourceWasActiveAtQueryTime; - public Vector3 Center; - public float Radius; - public int MaxTargets; - public int ShapeType; - public Vector3 Direction; - public float HalfAngleDeg; - } - - private struct AreaCollisionHitEventData - { - public int QueryId; - public int SourceEntityId; - public int SourceOwnerEntityId; - public int TargetEntityId; - public float SqrDistance; - } - + // Native buffers used by burst jobs and main-thread collision post-processing. private const int CollisionSourceTypeProjectile = 1; private const int CollisionSourceTypeArea = 2; private const int CollisionShapeCircle = 0; @@ -352,7 +244,7 @@ namespace Simulation _lastCollisionHasEnemyTargets = false; } - private void SyncSimulationToJobInput() + private void SyncSimulationStateToJobInputs() { InitializeJobDataChannels(); EnsureCapacity(ref _enemyJobInputs, _enemies.Count); @@ -361,34 +253,14 @@ namespace Simulation _enemyJobInputs.Clear(); _projectileJobInputs.Clear(); - for (int i = 0; i < _enemies.Count; i++) + foreach (var data in _enemies) { - _enemyJobInputs.Add(ConvertToEnemyJobInput(_enemies[i])); + _enemyJobInputs.Add(ConvertToEnemyJobInput(data)); } - for (int i = 0; i < _projectiles.Count; i++) + foreach (var data in _projectiles) { - _projectileJobInputs.Add(ConvertToProjectileJobInput(_projectiles[i])); - } - } - - private void SyncSimulationToJobOutput() - { - InitializeJobDataChannels(); - EnsureCapacity(ref _enemyJobOutputs, _enemies.Count); - EnsureCapacity(ref _projectileJobOutputs, _projectiles.Count); - - _enemyJobOutputs.Clear(); - _projectileJobOutputs.Clear(); - - for (int i = 0; i < _enemies.Count; i++) - { - _enemyJobOutputs.Add(ConvertToEnemyJobOutput(_enemies[i])); - } - - for (int i = 0; i < _projectiles.Count; i++) - { - _projectileJobOutputs.Add(ConvertToProjectileJobOutput(_projectiles[i])); + _projectileJobInputs.Add(ConvertToProjectileJobInput(data)); } } @@ -416,19 +288,7 @@ namespace Simulation } } - private void SyncProjectilesToJobOutput() - { - InitializeJobDataChannels(); - EnsureCapacity(ref _projectileJobOutputs, _projectiles.Count); - _projectileJobOutputs.Clear(); - - for (int i = 0; i < _projectiles.Count; i++) - { - _projectileJobOutputs.Add(ConvertToProjectileJobOutput(_projectiles[i])); - } - } - - private void CopyProjectileInputToOutput() + private void CopyProjectileInputsToOutputs() { for (int i = 0; i < _projectileJobInputs.Length; i++) { @@ -450,7 +310,8 @@ namespace Simulation } } - private void PrepareCollisionCandidateChannels(int queryCount, int expectedCandidateCount, int bucketCapacity) + private void PrepareCollisionQueryAndCandidateChannels(int queryCount, int expectedCandidateCount, + int bucketCapacity) { InitializeJobDataChannels(); EnsureCapacity(ref _collisionQueryInputs, queryCount); @@ -621,7 +482,7 @@ namespace Simulation } } - private void ApplyJobOutputToSimulation() + private void ApplyJobOutputsToSimulationState() { int enemyCount = Mathf.Min(_enemies.Count, _enemyJobOutputs.Length); bool hasEnemyPositionChanged = false; @@ -732,24 +593,6 @@ namespace Simulation }; } - private static EnemyJobOutputData ConvertToEnemyJobOutput(in EnemySimData enemy) - { - return new EnemyJobOutputData - { - EntityId = enemy.EntityId, - Position = new float3(enemy.Position.x, enemy.Position.y, enemy.Position.z), - Forward = new float3(enemy.Forward.x, enemy.Forward.y, enemy.Forward.z), - Rotation = new quaternion(enemy.Rotation.x, enemy.Rotation.y, enemy.Rotation.z, enemy.Rotation.w), - Speed = enemy.Speed, - AttackRange = enemy.AttackRange, - AvoidEnemyOverlap = enemy.AvoidEnemyOverlap, - EnemyBodyRadius = enemy.EnemyBodyRadius, - SeparationIterations = enemy.SeparationIterations, - TargetType = enemy.TargetType, - State = enemy.State - }; - } - private static EnemySimData ConvertToEnemySimData(in EnemyJobOutputData enemy) { return new EnemySimData @@ -787,24 +630,6 @@ namespace Simulation }; } - private static ProjectileJobOutputData ConvertToProjectileJobOutput(in ProjectileSimData projectile) - { - return new ProjectileJobOutputData - { - EntityId = projectile.EntityId, - OwnerEntityId = projectile.OwnerEntityId, - Position = new float3(projectile.Position.x, projectile.Position.y, projectile.Position.z), - Forward = new float3(projectile.Forward.x, projectile.Forward.y, projectile.Forward.z), - Velocity = new float3(projectile.Velocity.x, projectile.Velocity.y, projectile.Velocity.z), - Speed = projectile.Speed, - LifeTime = projectile.LifeTime, - Age = projectile.Age, - Active = projectile.Active, - RemainingLifetime = projectile.RemainingLifetime, - State = projectile.State - }; - } - private static ProjectileSimData ConvertToProjectileSimData(in ProjectileJobOutputData projectile) { return new ProjectileSimData diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs.meta b/Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs.meta similarity index 100% rename from Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs.meta rename to Assets/GameMain/Scripts/Simulation/DataChannel/SimulationWorld.JobDataChannel.cs.meta diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct.meta b/Assets/GameMain/Scripts/Simulation/JobStruct.meta new file mode 100644 index 0000000..5880af5 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3f549ca9decb4f38933329abacbf78ac +timeCreated: 1771901120 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs new file mode 100644 index 0000000..9176817 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs @@ -0,0 +1,14 @@ +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private struct AreaCollisionHitEventData + { + public int QueryId; + public int SourceEntityId; + public int SourceOwnerEntityId; + public int TargetEntityId; + public float SqrDistance; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs.meta new file mode 100644 index 0000000..87603fc --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionHitEventData.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5140d989cba042e6bcb217893491273d +timeCreated: 1771901785 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs new file mode 100644 index 0000000..27df2b4 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs @@ -0,0 +1,20 @@ +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private struct AreaCollisionRequestData + { + public int SourceEntityId; + public int SourceOwnerEntityId; + public bool SourceWasActiveAtQueryTime; + public Vector3 Center; + public float Radius; + public int MaxTargets; + public int ShapeType; + public Vector3 Direction; + public float HalfAngleDeg; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs.meta new file mode 100644 index 0000000..e36da08 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/AreaCollisionRequestData.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e32f4e0724314c70882f5ed043a4dca4 +timeCreated: 1771901749 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs new file mode 100644 index 0000000..74dad2c --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs @@ -0,0 +1,48 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + [BurstCompile] + private struct BuildEnemySeparationBucketsBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + public NativeParallelMultiHashMap.ParallelWriter Buckets; + public float CellSize; + + public void Execute(int index) + { + BuildEnemySeparationBucket( + index, + Inputs, + Buckets, + CellSize + ); + } + } + + private static void BuildEnemySeparationBucket( + int index, + NativeArray inputs, + NativeParallelMultiHashMap.ParallelWriter buckets, + float cellSize + ) + { + EnemyJobOutputData output = inputs[index]; + if (!output.AvoidEnemyOverlap) + { + return; + } + + float3 position = output.Position; + position.y = 0f; + int cellX = (int)math.floor(position.x / cellSize); + int cellZ = (int)math.floor(position.z / cellSize); + buckets.Add(SeparationCellKey(cellX, cellZ), index); + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs.meta new file mode 100644 index 0000000..896729f --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/BuildEnemySeparationBucketsBurstJob.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e833e91677bb49a4a458c9c8b90099e9 +timeCreated: 1771901500 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs new file mode 100644 index 0000000..6747975 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs @@ -0,0 +1,15 @@ +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private struct CollisionCandidateData + { + public int QueryId; + public int SourceType; + public int SourceEntityId; + public int SourceOwnerEntityId; + public int TargetEntityId; + public float SqrDistance; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs.meta new file mode 100644 index 0000000..4bed197 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionCandidateData.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 453c14964a4b4aed89c0abd7b25cd26c +timeCreated: 1771901722 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs new file mode 100644 index 0000000..cdf7c23 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs @@ -0,0 +1,22 @@ +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private struct CollisionQueryData + { + public int QueryId; + public int SourceType; + public int SourceEntityId; + public int SourceOwnerEntityId; + public bool SourceWasActiveAtQueryTime; + public float3 Position; + public float Radius; + public int MaxTargets; + public int ShapeType; + public float3 Direction; + public float HalfAngleDeg; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs.meta new file mode 100644 index 0000000..2ace39d --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/CollisionQueryData.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 58abe0d4fd7a49acb716fbd26e0fb19f +timeCreated: 1771901691 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs new file mode 100644 index 0000000..5d72ecc --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs @@ -0,0 +1,22 @@ +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private struct EnemyJobInputData + { + public int EntityId; + public float3 Position; + public float3 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; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs.meta new file mode 100644 index 0000000..ce66843 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobInputData.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 171936b8a6b84a8fbacc7651ce473b98 +timeCreated: 1771901146 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs new file mode 100644 index 0000000..e7b7a22 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs @@ -0,0 +1,22 @@ +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private struct EnemyJobOutputData + { + public int EntityId; + public float3 Position; + public float3 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; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs.meta new file mode 100644 index 0000000..d12a3e8 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyJobOutputData.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 34ef6fbf123f45ca88d8e7ab8311d543 +timeCreated: 1771901204 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs new file mode 100644 index 0000000..51f9eb8 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs @@ -0,0 +1,93 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + [BurstCompile] + private struct EnemyMovementBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + public NativeArray Outputs; + public float DeltaTime; + public float3 PlayerPosition; + + public void Execute(int index) + { + ExecuteEnemyMovement( + index, + Inputs, + Outputs, + DeltaTime, + PlayerPosition + ); + } + } + + private static void ExecuteEnemyMovement( + int index, + NativeArray inputs, + NativeArray outputs, + float deltaTime, + float3 playerPosition + ) + { + EnemyJobInputData input = inputs[index]; + float attackRange = input.AttackRange > 0f ? input.AttackRange : DefaultAttackRange; + float attackRangeSqr = attackRange * attackRange; + + float3 currentPosition = input.Position; + float3 horizontalPosition = new float3(currentPosition.x, 0f, currentPosition.z); + float3 toPlayer = playerPosition - horizontalPosition; + float sqrDistance = math.lengthsq(toPlayer); + bool isInAttackRange = sqrDistance <= attackRangeSqr; + bool canChase = !isInAttackRange && input.Speed > 0f && sqrDistance > float.Epsilon; + + float3 forward = input.Forward; + float3 desiredPosition = currentPosition; + quaternion rotation = input.Rotation; + + if (canChase) + { + forward = math.normalizesafe(toPlayer, forward); + desiredPosition = currentPosition + forward * input.Speed * deltaTime; + if (math.lengthsq(forward) > float.Epsilon) + { + rotation = quaternion.LookRotationSafe(forward, math.up()); + } + } + + int nextState; + if (isInAttackRange) + { + nextState = EnemyStateInAttackRange; + } + else if (canChase) + { + nextState = EnemyStateChasing; + } + else + { + nextState = EnemyStateIdle; + } + + outputs[index] = new EnemyJobOutputData + { + EntityId = input.EntityId, + Position = desiredPosition, + Forward = forward, + Rotation = rotation, + Speed = input.Speed, + AttackRange = attackRange, + AvoidEnemyOverlap = input.AvoidEnemyOverlap, + EnemyBodyRadius = input.EnemyBodyRadius, + SeparationIterations = input.SeparationIterations, + TargetType = input.TargetType, + State = nextState + }; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs.meta new file mode 100644 index 0000000..dbd2ee4 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemyMovementBurstJob.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: abd5519779ed41d4947280eec326ffb1 +timeCreated: 1771901472 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs new file mode 100644 index 0000000..f3457b1 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs @@ -0,0 +1,245 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + [BurstCompile] + private struct EnemySeparationBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + [ReadOnly] public NativeParallelMultiHashMap Buckets; + [ReadOnly] public NativeArray PreviousPushes; + public NativeArray Outputs; + public NativeArray CurrentPushes; + public float CellSize; + public float MaxRadius; + public float3 PlayerPosition; + public float PushDamping; + public float MaxStepScale; + public bool UseTangentialInAttackRange; + public float PushSmoothing; + + public void Execute(int index) + { + ExecuteEnemySeparation( + index, + Inputs, + Buckets, + Outputs, + CellSize, + MaxRadius, + PlayerPosition, + PushDamping, + MaxStepScale, + UseTangentialInAttackRange, + PreviousPushes, + CurrentPushes, + PushSmoothing + ); + } + } + + private static void ExecuteEnemySeparation( + int index, + NativeArray inputs, + NativeParallelMultiHashMap buckets, + NativeArray outputs, + float cellSize, + float maxRadius, + float3 playerPosition, + float pushDamping, + float maxStepScale, + bool useTangentialInAttackRange, + NativeArray previousPushes, + NativeArray currentPushes, + float pushSmoothing + ) + { + currentPushes[index] = float2.zero; + EnemyJobOutputData self = inputs[index]; + if (!self.AvoidEnemyOverlap) + { + outputs[index] = self; + return; + } + + float3 candidate = self.Position; + candidate.y = 0f; + float3 original = candidate; + float3 fallback = + math.normalizesafe(new float3(self.Forward.x, 0f, self.Forward.z), new float3(1f, 0f, 0f)); + float selfRadius = self.EnemyBodyRadius > 0f ? self.EnemyBodyRadius : 0.45f; + int iterations = self.SeparationIterations > 0 ? self.SeparationIterations : 1; + int queryRange = math.max(1, (int)math.ceil((selfRadius + maxRadius) / cellSize)); + + for (int iter = 0; iter < iterations; iter++) + { + int cellX = (int)math.floor(candidate.x / cellSize); + int cellZ = (int)math.floor(candidate.z / cellSize); + float3 pushAccumulation = float3.zero; + + for (int dx = -queryRange; dx <= queryRange; dx++) + { + for (int dz = -queryRange; dz <= queryRange; dz++) + { + long key = SeparationCellKey(cellX + dx, cellZ + dz); + if (!buckets.TryGetFirstValue(key, out int otherIndex, + out NativeParallelMultiHashMapIterator iterator)) + { + continue; + } + + do + { + if (otherIndex == index) + { + continue; + } + + EnemyJobOutputData other = inputs[otherIndex]; + if (!other.AvoidEnemyOverlap) + { + continue; + } + + float otherRadius = other.EnemyBodyRadius > 0f ? other.EnemyBodyRadius : 0.45f; + float minDistance = selfRadius + otherRadius; + float minDistanceSqr = minDistance * minDistance; + + float3 otherPosition = other.Position; + otherPosition.y = 0f; + float3 toSelf = candidate - otherPosition; + float sqrDistance = math.lengthsq(toSelf); + + if (sqrDistance <= float.Epsilon) + { + float3 zeroDistanceAxis = GetZeroDistanceSeparationAxis(index, otherIndex); + float directionSign = index < otherIndex ? 1f : -1f; + pushAccumulation += zeroDistanceAxis * (selfRadius * 0.25f * directionSign); + continue; + } + + if (sqrDistance >= minDistanceSqr) + { + continue; + } + + float distance = math.sqrt(sqrDistance); + float penetration = minDistance - distance; + pushAccumulation += (toSelf / distance) * penetration; + } while (buckets.TryGetNextValue(out otherIndex, ref iterator)); + } + } + + if (math.lengthsq(pushAccumulation) <= float.Epsilon) + { + continue; + } + + float3 resolvedPush = pushAccumulation * pushDamping; + + float maxStep = selfRadius * maxStepScale; + float pushLength = math.length(resolvedPush); + if (pushLength > maxStep && pushLength > float.Epsilon) + { + resolvedPush = resolvedPush / pushLength * maxStep; + } + + candidate += resolvedPush; + } + + float3 framePush = candidate - original; + float2 previousPush2 = previousPushes[index]; + float3 previousPush = new float3(previousPush2.x, 0f, previousPush2.y); + float3 smoothedPush = SmoothSeparationPush(framePush, previousPush, pushSmoothing); + + if (useTangentialInAttackRange && self.State == EnemyStateInAttackRange) + { + smoothedPush = ProjectToTangential(smoothedPush, playerPosition, original); + } + + float maxTotalStep = selfRadius * maxStepScale * iterations; + float smoothedLength = math.length(smoothedPush); + if (smoothedLength > maxTotalStep && smoothedLength > float.Epsilon) + { + smoothedPush = smoothedPush / smoothedLength * maxTotalStep; + } + + float3 finalPosition = original + smoothedPush; + currentPushes[index] = new float2(smoothedPush.x, smoothedPush.z); + self.Position = new float3(finalPosition.x, self.Position.y, finalPosition.z); + if (math.lengthsq(smoothedPush) > float.Epsilon) + { + self.Forward = new float3(fallback.x, self.Forward.y, fallback.z); + } + + outputs[index] = self; + } + + private static float3 SmoothSeparationPush(float3 framePush, float3 previousPush, float pushSmoothing) + { + float frameLengthSqr = math.lengthsq(framePush); + float previousLengthSqr = math.lengthsq(previousPush); + + if (frameLengthSqr <= float.Epsilon) + { + return float3.zero; + } + + if (previousLengthSqr <= float.Epsilon || pushSmoothing <= 0f) + { + return framePush; + } + + float frameLength = math.sqrt(frameLengthSqr); + float previousLength = math.sqrt(previousLengthSqr); + float3 frameDirection = framePush / frameLength; + float3 previousDirection = previousPush / previousLength; + float directionAlignment = math.dot(frameDirection, previousDirection); + + if (directionAlignment >= 0.35f) + { + return framePush; + } + + float directionalFactor = math.saturate((0.35f - directionAlignment) / 1.35f); + float smoothingStrength = pushSmoothing * directionalFactor; + return math.lerp(framePush, previousPush, smoothingStrength); + } + + private static float3 ProjectToTangential(float3 push, float3 playerPosition, float3 currentPosition) + { + if (math.lengthsq(push) <= float.Epsilon) + { + return push; + } + + float3 toPlayer = playerPosition - currentPosition; + float toPlayerSqr = math.lengthsq(toPlayer); + if (toPlayerSqr <= float.Epsilon) + { + return push; + } + + float3 radialDirection = toPlayer / math.sqrt(toPlayerSqr); + float radialOffset = math.dot(push, radialDirection); + return push - radialDirection * radialOffset; + } + + private static float3 GetZeroDistanceSeparationAxis(int index, int otherIndex) + { + int lowIndex = math.min(index, otherIndex); + int highIndex = math.max(index, otherIndex); + uint pairHash = (uint)(lowIndex * 73856093) ^ (uint)(highIndex * 19349663); + + float axisX = (pairHash & 1023u) / 511.5f - 1f; + float axisZ = ((pairHash >> 10) & 1023u) / 511.5f - 1f; + float3 axis = new float3(axisX, 0f, axisZ); + return math.normalizesafe(axis, new float3(1f, 0f, 0f)); + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs.meta new file mode 100644 index 0000000..5142e9a --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/EnemySeparationBurstJob.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 94a77477c3a8410cb58b5028dd5d2c63 +timeCreated: 1771901543 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs new file mode 100644 index 0000000..bd9fe86 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs @@ -0,0 +1,22 @@ +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private struct ProjectileJobInputData + { + public int EntityId; + public int OwnerEntityId; + public float3 Position; + public float3 Forward; + public float3 Velocity; + public float Speed; + public float LifeTime; + public float Age; + public bool Active; + public float RemainingLifetime; + public int State; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs.meta new file mode 100644 index 0000000..5613784 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobInputData.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 334ca3869d7940058256ba5419573637 +timeCreated: 1771901235 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs new file mode 100644 index 0000000..a1cd081 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs @@ -0,0 +1,22 @@ +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private struct ProjectileJobOutputData + { + public int EntityId; + public int OwnerEntityId; + public float3 Position; + public float3 Forward; + public float3 Velocity; + public float Speed; + public float LifeTime; + public float Age; + public bool Active; + public float RemainingLifetime; + public int State; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs.meta new file mode 100644 index 0000000..62908ee --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileJobOutputData.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b5c4a144914548379d9991d8ef061da7 +timeCreated: 1771901266 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs new file mode 100644 index 0000000..d27c6e5 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs @@ -0,0 +1,118 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + [BurstCompile] + private struct ProjectileMovementBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray Inputs; + public NativeArray Outputs; + public float DeltaTime; + public float3 PlayerPosition; + public float MaxSqrDistanceFromPlayer; + public float MaxVerticalOffsetFromPlayer; + + public void Execute(int index) + { + ExecuteProjectileMovement( + index, + Inputs, + Outputs, + DeltaTime, + PlayerPosition, + MaxSqrDistanceFromPlayer, + MaxVerticalOffsetFromPlayer); + } + } + + private static void ExecuteProjectileMovement( + int index, + NativeArray inputs, + NativeArray outputs, + float deltaTime, + float3 playerPosition, + float maxSqrDistanceFromPlayer, + float maxVerticalOffsetFromPlayer) + { + ProjectileJobInputData input = inputs[index]; + ProjectileJobOutputData output = new ProjectileJobOutputData + { + EntityId = input.EntityId, + OwnerEntityId = input.OwnerEntityId, + Position = input.Position, + Forward = input.Forward, + Velocity = input.Velocity, + Speed = input.Speed, + LifeTime = input.LifeTime, + Age = input.Age, + Active = input.Active, + RemainingLifetime = input.RemainingLifetime, + State = input.State + }; + + if (!input.Active) + { + output.State = ProjectileStateExpired; + outputs[index] = output; + return; + } + + float3 position = input.Position; + float3 forward = input.Forward; + float3 velocity = input.Velocity; + if (math.lengthsq(velocity) <= float.Epsilon && input.Speed > 0f) + { + float3 moveDirection = math.normalizesafe(forward, new float3(0f, 0f, 1f)); + velocity = moveDirection * input.Speed; + } + + float3 nextPosition = position + velocity * deltaTime; + float nextAge = math.max(0f, input.Age + deltaTime); + float nextRemainingLifetime = input.RemainingLifetime; + bool shouldExpire = false; + + if (input.LifeTime > 0f) + { + nextRemainingLifetime = math.max(0f, input.LifeTime - nextAge); + shouldExpire = nextAge >= input.LifeTime; + } + else if (input.RemainingLifetime > 0f) + { + nextRemainingLifetime = math.max(0f, input.RemainingLifetime - deltaTime); + shouldExpire = nextRemainingLifetime <= float.Epsilon; + } + + if (!shouldExpire && maxSqrDistanceFromPlayer > 0f) + { + float3 horizontalDelta = + new float3(nextPosition.x - playerPosition.x, 0f, nextPosition.z - playerPosition.z); + shouldExpire = math.lengthsq(horizontalDelta) > maxSqrDistanceFromPlayer; + } + + if (!shouldExpire && maxVerticalOffsetFromPlayer > 0f) + { + shouldExpire = math.abs(nextPosition.y - playerPosition.y) > maxVerticalOffsetFromPlayer; + } + + output.Position = nextPosition; + output.Velocity = velocity; + output.Age = nextAge; + output.RemainingLifetime = nextRemainingLifetime; + output.Active = !shouldExpire; + output.State = shouldExpire ? ProjectileStateExpired : ProjectileStateActive; + + if (math.lengthsq(velocity) > float.Epsilon) + { + float3 moveForward = math.normalizesafe(velocity, forward); + output.Forward = moveForward; + } + + outputs[index] = output; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs.meta new file mode 100644 index 0000000..88c2bb9 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/ProjectileMovementBurstJob.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 49001e849c1b4afdaa37d851b9b76866 +timeCreated: 1771901884 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs b/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs new file mode 100644 index 0000000..3765232 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs @@ -0,0 +1,122 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + [BurstCompile] + private struct QueryCollisionCandidatesBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray Queries; + [ReadOnly] public NativeParallelMultiHashMap EnemyBuckets; + [ReadOnly] public NativeArray EnemyOutputs; + public NativeList.ParallelWriter Candidates; + public bool HasEnemyTargets; + public bool HasPlayerTarget; + public int PlayerTargetEntityId; + public float3 PlayerPosition; + public float CellSize; + + public void Execute(int index) + { + CollisionQueryData query = Queries[index]; + float radiusSqr = query.Radius * query.Radius; + int centerCellX = (int)math.floor(query.Position.x / CellSize); + int centerCellZ = (int)math.floor(query.Position.z / CellSize); + int queryRange = math.max(1, (int)math.ceil(query.Radius / CellSize)); + int selectedCount = 0; + bool reachedLimit = false; + + if (HasPlayerTarget && query.SourceEntityId != PlayerTargetEntityId && + query.SourceOwnerEntityId != PlayerTargetEntityId) + { + float3 playerPosition = PlayerPosition; + playerPosition.y = query.Position.y; + float3 playerDelta = playerPosition - query.Position; + float playerSqrDistance = math.lengthsq(playerDelta); + if (playerSqrDistance <= radiusSqr) + { + Candidates.AddNoResize(new CollisionCandidateData + { + QueryId = query.QueryId, + SourceType = query.SourceType, + SourceEntityId = query.SourceEntityId, + SourceOwnerEntityId = query.SourceOwnerEntityId, + TargetEntityId = PlayerTargetEntityId, + SqrDistance = playerSqrDistance + }); + + selectedCount++; + if (selectedCount >= query.MaxTargets) + { + reachedLimit = true; + } + } + } + + if (!HasEnemyTargets || reachedLimit) + { + return; + } + + for (int dx = -queryRange; dx <= queryRange && !reachedLimit; dx++) + { + for (int dz = -queryRange; dz <= queryRange && !reachedLimit; dz++) + { + long key = SeparationCellKey(centerCellX + dx, centerCellZ + dz); + if (!EnemyBuckets.TryGetFirstValue(key, out int enemyIndex, + out NativeParallelMultiHashMapIterator iterator)) + { + continue; + } + + do + { + if (enemyIndex < 0 || enemyIndex >= EnemyOutputs.Length) + { + continue; + } + + EnemyJobOutputData enemy = EnemyOutputs[enemyIndex]; + if (enemy.EntityId == query.SourceOwnerEntityId) + { + continue; + } + + float3 delta = new float3( + enemy.Position.x - query.Position.x, + enemy.Position.y - query.Position.y, + enemy.Position.z - query.Position.z); + float sqrDistance = math.lengthsq(delta); + if (sqrDistance > radiusSqr) + { + continue; + } + + Candidates.AddNoResize(new CollisionCandidateData + { + QueryId = query.QueryId, + SourceType = query.SourceType, + SourceEntityId = query.SourceEntityId, + SourceOwnerEntityId = query.SourceOwnerEntityId, + TargetEntityId = enemy.EntityId, + SqrDistance = sqrDistance + }); + + selectedCount++; + + if (selectedCount >= query.MaxTargets) + { + reachedLimit = true; + break; + } + } while (EnemyBuckets.TryGetNextValue(out enemyIndex, ref iterator)); + } + } + } + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs.meta b/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs.meta new file mode 100644 index 0000000..aea157e --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/JobStruct/QueryCollisionCandidatesBurstJob.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 81aa43bb35f547c48b867eafa17a3857 +timeCreated: 1771914779 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/Jobs.meta b/Assets/GameMain/Scripts/Simulation/Jobs.meta new file mode 100644 index 0000000..f0ecbfb --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Jobs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c66a8c10fffb404c9f4819adffea45fd +timeCreated: 1771900893 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs similarity index 55% rename from Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs rename to Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs index 06cb4b4..6fed3ca 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs +++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs @@ -1,101 +1,75 @@ +using CustomDebugger; +using CustomEvent; +using CustomUtility; +using Definition.DataStruct; +using Definition.Enum; +using Entity; +using Entity.Weapon; using Unity.Burst; using Unity.Collections; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; -using CustomDebugger; -using CustomEvent; -using Definition.DataStruct; -using Definition.Enum; -using Entity; -using Entity.Weapon; -using CustomUtility; -using UnityGameFramework.Runtime; namespace Simulation { public sealed partial class SimulationWorld { private const int PlayerEntityId = -1; + private JobHandle _collisionCandidateQueryHandle; + private bool _collisionCandidateQueryScheduled; - [Header("投射物模拟参数")] [Tooltip("投射物距离玩家超过该水平半径时回收。小于等于 0 表示不启用该回收条件。")] [SerializeField] - private float _projectileMaxDistanceFromPlayer = 120f; - - [Tooltip("投射物与玩家的垂直高度差超过该值时回收。小于等于 0 表示不启用该回收条件。")] [SerializeField] - private float _projectileMaxVerticalOffsetFromPlayer = 30f; - - [Tooltip("投射物 Broad Phase 命中查询半径。")] [SerializeField] + [Header("Projectile Collision Query")] + [Tooltip("Projectile broad-phase collision query radius.")] + [SerializeField] private float _projectileCollisionQueryRadius = 0.35f; - [Tooltip("每个投射物最多保留的候选目标数量。")] [SerializeField] + [Tooltip("Maximum retained candidates per projectile query.")] + [SerializeField] private int _projectileMaxCandidatesPerQuery = 1; - [Tooltip("投射物 Broad Phase 分桶网格尺寸。小于等于 0 时将按查询半径自动推导。")] [SerializeField] + [Tooltip("Broad-phase bucket cell size. <=0 derives from query radius.")] + [SerializeField] private float _projectileCollisionCellSize = 0f; - [Header("投射物命中事件派发")] [Tooltip("是否派发投射物命中表现事件。")] [SerializeField] + [Header("Projectile Hit Event Dispatch")] + [Tooltip("Dispatch projectile hit presentation event.")] + [SerializeField] private bool _dispatchProjectileHitPresentationEvent = true; - [Tooltip("命中时是否请求命中标记表现。")] [SerializeField] + [Tooltip("Request hit marker when projectile hits.")] + [SerializeField] private bool _dispatchProjectileHitMarkerEvent = true; - [Tooltip("命中时是否请求特效表现。")] [SerializeField] + [Tooltip("Request hit effect when projectile hits.")] + [SerializeField] private bool _dispatchProjectileHitEffectEvent = true; - [Tooltip("命中事件建议使用的特效实体类型 Id(<=0 表示不指定,由表现层决定)。")] [SerializeField] + [Tooltip("Default hit effect entity type id in presentation event. 0 means not specified.")] + [SerializeField] private int _projectileHitPresentationEffectTypeId = 0; - [BurstCompile] - private struct ProjectileMovementBurstJob : IJobParallelFor - { - [ReadOnly] public NativeArray Inputs; - public NativeArray Outputs; - public float DeltaTime; - public float3 PlayerPosition; - public float MaxSqrDistanceFromPlayer; - public float MaxVerticalOffsetFromPlayer; + #region Area Query Request - public void Execute(int index) - { - ExecuteProjectileMovement(index, Inputs, Outputs, DeltaTime, PlayerPosition, - MaxSqrDistanceFromPlayer, MaxVerticalOffsetFromPlayer); - } - } - - private struct ProjectileMovementJob : IJobParallelFor - { - [ReadOnly] public NativeArray Inputs; - public NativeArray Outputs; - public float DeltaTime; - public float3 PlayerPosition; - public float MaxSqrDistanceFromPlayer; - public float MaxVerticalOffsetFromPlayer; - - public void Execute(int index) - { - ExecuteProjectileMovement(index, Inputs, Outputs, DeltaTime, PlayerPosition, - MaxSqrDistanceFromPlayer, MaxVerticalOffsetFromPlayer); - } - } - - public bool TryEnqueueAreaCollisionQuery(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center, + public bool TryRequestAreaCollision(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center, float radius, int maxTargets = 16) { - return TryEnqueueAreaCollisionQueryInternal(sourceEntityId, sourceOwnerEntityId, in center, radius, + return TryRequestAreaCollisionInternal(sourceEntityId, sourceOwnerEntityId, in center, radius, maxTargets, CollisionShapeCircle, Vector3.forward, 180f); } - public bool TryEnqueueSectorCollisionQuery(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center, + public bool TryRequestSectorCollision(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center, float radius, in Vector3 direction, float halfAngleDeg, int maxTargets = 16) { - return TryEnqueueAreaCollisionQueryInternal(sourceEntityId, sourceOwnerEntityId, in center, radius, + return TryRequestAreaCollisionInternal(sourceEntityId, sourceOwnerEntityId, in center, radius, maxTargets, CollisionShapeSector, direction, halfAngleDeg); } - private bool TryEnqueueAreaCollisionQueryInternal(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center, + private bool TryRequestAreaCollisionInternal(int sourceEntityId, int sourceOwnerEntityId, + in Vector3 center, float radius, int maxTargets, int shapeType, in Vector3 direction, float halfAngleDeg) { - if (!_useSimulationMovement || !_useJobSimulation) + if (!_useSimulationMovement) { return false; } @@ -106,7 +80,7 @@ namespace Simulation } int resolvedOwnerEntityId = sourceOwnerEntityId != 0 ? sourceOwnerEntityId : sourceEntityId; - bool sourceWasActiveAtQueryTime = IsCollisionSourceActiveAtQueryTime(sourceEntityId); + bool sourceWasActiveAtQueryTime = WasCollisionSourceActiveAtQueryTime(sourceEntityId); Vector3 normalizedDirection = direction; normalizedDirection.y = 0f; if (normalizedDirection.sqrMagnitude <= Mathf.Epsilon) @@ -134,12 +108,12 @@ namespace Simulation return true; } - private int GetPendingAreaCollisionQueryCount() + private int GetPendingAreaCollisionRequestCount() { return _areaCollisionRequests.Count; } - private int EstimatePendingAreaCollisionCandidateCount() + private int EstimatePendingAreaCollisionCandidateCountFromRequests() { int expectedCount = 0; for (int i = 0; i < _areaCollisionRequests.Count; i++) @@ -150,61 +124,19 @@ namespace Simulation return expectedCount; } - private JobHandle ExecuteProjectileMovementJob(in SimulationTickContext context) + #endregion + + #region Collision Pipeline + + private void PrepareCollisionCandidatesForFrame() { - int projectileCount = _projectileJobInputs.Length; - if (projectileCount == 0) - { - return default; - } + _collisionCandidateQueryScheduled = false; - if (context.DeltaTime <= 0f) - { - CopyProjectileInputToOutput(); - return default; - } - - float maxDistance = Mathf.Max(0f, _projectileMaxDistanceFromPlayer); - float maxSqrDistanceFromPlayer = maxDistance > 0f ? maxDistance * maxDistance : -1f; - float maxVerticalOffsetFromPlayer = Mathf.Max(0f, _projectileMaxVerticalOffsetFromPlayer); - float3 playerPosition = - new float3(context.PlayerPosition.x, context.PlayerPosition.y, context.PlayerPosition.z); - NativeArray inputArray = _projectileJobInputs.AsArray(); - NativeArray outputArray = _projectileJobOutputs.AsArray(); - - if (_useBurstJobs) - { - ProjectileMovementBurstJob burstJob = new ProjectileMovementBurstJob - { - Inputs = inputArray, - Outputs = outputArray, - DeltaTime = context.DeltaTime, - PlayerPosition = playerPosition, - MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer, - MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer - }; - return burstJob.Schedule(projectileCount, 64); - } - - ProjectileMovementJob job = new ProjectileMovementJob - { - Inputs = inputArray, - Outputs = outputArray, - DeltaTime = context.DeltaTime, - PlayerPosition = playerPosition, - MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer, - MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer - }; - return job.Schedule(projectileCount, 64); - } - - private void BuildProjectileCollisionCandidates() - { if (!_collisionQueryInputs.IsCreated || !_collisionCandidates.IsCreated || !_enemyCollisionBuckets.IsCreated) { ResetCollisionRuntimeStats(); - ClearAreaCollisionFrameBuffers(); + ClearAreaCollisionTransientBuffers(); return; } @@ -278,29 +210,37 @@ namespace Simulation { if (hasEnemyTargets) { - BuildEnemyCollisionBucketsForProjectiles(cellSize); + BuildEnemyCollisionBuckets(cellSize); } } - int projectileCandidateCount; - int areaCandidateCount; using (CustomProfilerMarker.Collision_QueryCandidates.Auto()) { - QueryProjectileCollisionCandidates(cellSize, hasEnemyTargets, out projectileCandidateCount, - out areaCandidateCount); + _collisionCandidateQueryScheduled = ScheduleCollisionCandidateQueryJob(cellSize, hasEnemyTargets, + out _collisionCandidateQueryHandle); + } + } + + private void CompleteCollisionCandidatesForFrame() + { + if (_collisionCandidateQueryScheduled) + { + _collisionCandidateQueryHandle.Complete(); + _collisionCandidateQueryScheduled = false; } + CountCollisionCandidatesBySourceType(out int projectileCandidateCount, out int areaCandidateCount); _lastProjectileCollisionCandidateCount = projectileCandidateCount; _lastAreaCollisionCandidateCount = areaCandidateCount; _lastCollisionCandidateCount = projectileCandidateCount + areaCandidateCount; } - private void ResolveProjectileCollisionCandidatesMainThread() + private void ResolveCollisionCandidatesOnMainThread() { if (!_collisionCandidates.IsCreated) { _lastResolvedAreaHitCount = 0; - ClearAreaCollisionFrameBuffers(); + ClearAreaCollisionTransientBuffers(); return; } @@ -311,7 +251,7 @@ namespace Simulation if (_collisionCandidates.Length == 0) { _lastResolvedAreaHitCount = 0; - ClearAreaCollisionFrameBuffers(); + ClearAreaCollisionTransientBuffers(); return; } @@ -328,7 +268,7 @@ namespace Simulation continue; } - if (!TryGetActiveProjectileData(projectileEntityId, out _, out ProjectileSimData projectile)) + if (!TryGetActiveProjectileSimData(projectileEntityId, out _, out ProjectileSimData projectile)) { _projectileResolvedEntityIds.Add(projectileEntityId); continue; @@ -338,11 +278,13 @@ namespace Simulation bool shouldDispatchPresentation = false; int damage = 0; Vector3 hitPosition = projectile.Position; - if (TryGetTargetableEntity(candidate.TargetEntityId, out TargetableObject target)) + if (TryGetAliveTargetableEntity(candidate.TargetEntityId, out TargetableObject target)) { EntityBase sourceEntity = TryGetEntityById(candidate.SourceEntityId); EntityBase ownerEntity = TryGetEntityById(candidate.SourceOwnerEntityId); - shouldExpireProjectile = ResolveProjectileHit(target, sourceEntity, ownerEntity, in projectile, + shouldExpireProjectile = ResolveProjectileHitAgainstTarget(target, sourceEntity, + ownerEntity, + in projectile, out damage, out hitPosition, out shouldDispatchPresentation); } @@ -354,9 +296,10 @@ namespace Simulation if (shouldExpireProjectile) { - MarkProjectileExpired(projectileEntityId); + MarkProjectileAsExpired(projectileEntityId); _projectileResolvedEntityIds.Add(projectileEntityId); } + continue; } @@ -383,49 +326,20 @@ namespace Simulation int resolvedAreaHitCount; using (CustomProfilerMarker.Collision_ResolveArea.Auto()) { - resolvedAreaHitCount = ResolveAreaCollisionHitsMainThread(); + resolvedAreaHitCount = ResolveAreaCollisionHitsOnMainThread(); } _lastResolvedAreaHitCount = resolvedAreaHitCount; _projectileResolvedEntityIds.Clear(); - ClearAreaCollisionFrameBuffers(); + ClearAreaCollisionTransientBuffers(); } - private void RecycleInactiveProjectiles() - { - _projectileRecycleEntityIds.Clear(); - for (int i = 0; i < _projectiles.Count; i++) - { - ProjectileSimData projectile = _projectiles[i]; - if (!ShouldRecycleProjectile(projectile)) - { - continue; - } + #endregion - _projectileRecycleEntityIds.Add(projectile.EntityId); - } + #region Collision Resolve Helpers - if (_projectileRecycleEntityIds.Count == 0) - { - return; - } - - var entityComponent = GameEntry.Entity; - for (int i = 0; i < _projectileRecycleEntityIds.Count; i++) - { - int entityId = _projectileRecycleEntityIds[i]; - if (entityComponent != null) - { - entityComponent.HideEntity(entityId); - } - - RemoveProjectileByEntityId(entityId); - } - - _projectileRecycleEntityIds.Clear(); - } - - private bool ResolveProjectileHit(TargetableObject target, EntityBase sourceEntity, EntityBase ownerEntity, + private bool ResolveProjectileHitAgainstTarget(TargetableObject target, EntityBase sourceEntity, + EntityBase ownerEntity, in ProjectileSimData projectile, out int damage, out Vector3 hitPosition, out bool shouldDispatchPresentation) { @@ -540,7 +454,7 @@ namespace Simulation return false; } - private static bool TryGetTargetableEntity(int entityId, out TargetableObject target) + private static bool TryGetAliveTargetableEntity(int entityId, out TargetableObject target) { target = null; @@ -570,7 +484,7 @@ namespace Simulation return entityComponent != null ? entityComponent.GetGameEntity(entityId) : null; } - private bool TryGetActiveProjectileData(int projectileEntityId, out int simulationIndex, + private bool TryGetActiveProjectileSimData(int projectileEntityId, out int simulationIndex, out ProjectileSimData projectile) { simulationIndex = -1; @@ -593,7 +507,7 @@ namespace Simulation return true; } - private bool MarkProjectileExpired(int projectileEntityId) + private bool MarkProjectileAsExpired(int projectileEntityId) { if (!ProjectileBinding.TryGetSimulationIndex(projectileEntityId, out int simulationIndex) || simulationIndex < 0 || simulationIndex >= _projectiles.Count) @@ -614,7 +528,7 @@ namespace Simulation return true; } - private int ResolveAreaCollisionHitsMainThread() + private int ResolveAreaCollisionHitsOnMainThread() { if (_areaCollisionHitEvents.Count == 0) { @@ -625,7 +539,7 @@ namespace Simulation for (int i = 0; i < _areaCollisionHitEvents.Count; i++) { AreaCollisionHitEventData hitEvent = _areaCollisionHitEvents[i]; - if (!TryGetCollisionQueryById(hitEvent.QueryId, out CollisionQueryData query) || + if (!TryGetCollisionQueryByQueryId(hitEvent.QueryId, out CollisionQueryData query) || query.SourceType != CollisionSourceTypeArea) { continue; @@ -642,7 +556,7 @@ namespace Simulation continue; } - if (!TryGetTargetableEntity(hitEvent.TargetEntityId, out TargetableObject target)) + if (!TryGetAliveTargetableEntity(hitEvent.TargetEntityId, out TargetableObject target)) { continue; } @@ -659,7 +573,7 @@ namespace Simulation return resolvedHitCount; } - private bool TryGetCollisionQueryById(int queryId, out CollisionQueryData query) + private bool TryGetCollisionQueryByQueryId(int queryId, out CollisionQueryData query) { query = default; if (!_collisionQueryInputs.IsCreated || queryId < 0 || queryId >= _collisionQueryInputs.Length) @@ -734,139 +648,107 @@ namespace Simulation return angle <= halfAngle; } - private void ClearAreaCollisionFrameBuffers() + private void ClearAreaCollisionTransientBuffers() { _areaCollisionRequests.Clear(); _areaCollisionHitEvents.Clear(); _areaCollisionHitDedupKeys.Clear(); } - private void BuildEnemyCollisionBucketsForProjectiles(float cellSize) + private void BuildEnemyCollisionBuckets(float cellSize) { _enemyCollisionBuckets.Clear(); - for (int i = 0; i < _enemyJobOutputs.Length; i++) + int enemyCount = _enemyJobOutputs.Length; + if (enemyCount <= 0) { - EnemyJobOutputData enemy = _enemyJobOutputs[i]; - int cellX = (int)math.floor(enemy.Position.x / cellSize); - int cellZ = (int)math.floor(enemy.Position.z / cellSize); - _enemyCollisionBuckets.Add(SeparationCellKey(cellX, cellZ), i); + return; + } + + BuildCollisionBucketsBurstJob job = new BuildCollisionBucketsBurstJob + { + EnemyOutputs = _enemyJobOutputs.AsArray(), + Buckets = _enemyCollisionBuckets.AsParallelWriter(), + CellSize = cellSize + }; + + using (CustomProfilerMarker.TickEnemies_Complete.Auto()) + { + JobHandle handle = job.Schedule(enemyCount, 64); + handle.Complete(); } } - private void QueryProjectileCollisionCandidates(float cellSize, bool hasEnemyTargets, - out int projectileCandidateCount, out int areaCandidateCount) + [BurstCompile] + private struct BuildCollisionBucketsBurstJob : IJobParallelFor + { + [ReadOnly] public NativeArray EnemyOutputs; + public NativeParallelMultiHashMap.ParallelWriter Buckets; + public float CellSize; + + public void Execute(int index) + { + EnemyJobOutputData enemy = EnemyOutputs[index]; + int cellX = (int)math.floor(enemy.Position.x / CellSize); + int cellZ = (int)math.floor(enemy.Position.z / CellSize); + Buckets.Add(SeparationCellKey(cellX, cellZ), index); + } + } + + + + private bool ScheduleCollisionCandidateQueryJob(float cellSize, bool hasEnemyTargets, out JobHandle handle) + { + handle = default; + if (!_collisionQueryInputs.IsCreated || !_collisionCandidates.IsCreated) + { + return false; + } + + _collisionCandidates.Clear(); + int queryCount = _collisionQueryInputs.Length; + if (queryCount == 0) + { + return false; + } + + bool hasPlayerTarget = TryGetPlayerCollisionTarget(out int playerTargetEntityId, out float3 playerPosition); + QueryCollisionCandidatesBurstJob job = new QueryCollisionCandidatesBurstJob + { + Queries = _collisionQueryInputs.AsArray(), + EnemyBuckets = _enemyCollisionBuckets, + EnemyOutputs = _enemyJobOutputs.AsArray(), + Candidates = _collisionCandidates.AsParallelWriter(), + HasEnemyTargets = hasEnemyTargets, + HasPlayerTarget = hasPlayerTarget, + PlayerTargetEntityId = playerTargetEntityId, + PlayerPosition = playerPosition, + CellSize = cellSize + }; + + handle = job.Schedule(queryCount, 64); + return true; + } + + private void CountCollisionCandidatesBySourceType(out int projectileCandidateCount, out int areaCandidateCount) { projectileCandidateCount = 0; areaCandidateCount = 0; - bool hasPlayerTarget = TryGetPlayerCollisionTarget(out int playerTargetEntityId, out float3 playerPosition); - for (int i = 0; i < _collisionQueryInputs.Length; i++) + if (!_collisionCandidates.IsCreated) { - CollisionQueryData query = _collisionQueryInputs[i]; - float radiusSqr = query.Radius * query.Radius; - int centerCellX = (int)math.floor(query.Position.x / cellSize); - int centerCellZ = (int)math.floor(query.Position.z / cellSize); - int queryRange = math.max(1, (int)math.ceil(query.Radius / cellSize)); - int selectedCount = 0; - bool reachedLimit = false; + return; + } - if (hasPlayerTarget && query.SourceEntityId != playerTargetEntityId && - query.SourceOwnerEntityId != playerTargetEntityId) + for (int i = 0; i < _collisionCandidates.Length; i++) + { + CollisionCandidateData candidate = _collisionCandidates[i]; + if (candidate.SourceType == CollisionSourceTypeProjectile) { - playerPosition.y = query.Position.y; - float3 playerDelta = playerPosition - query.Position; - float playerSqrDistance = math.lengthsq(playerDelta); - // Log.Info( - // $"playerPos:{playerPosition} - queryPos:{query.Position} = playerSqrDistance:{playerSqrDistance}"); - if (playerSqrDistance <= radiusSqr) - { - AddCollisionCandidate( - query.QueryId, - query.SourceType, - query.SourceEntityId, - query.SourceOwnerEntityId, - playerTargetEntityId, - playerSqrDistance); - if (query.SourceType == CollisionSourceTypeProjectile) - { - projectileCandidateCount++; - } - else if (query.SourceType == CollisionSourceTypeArea) - { - areaCandidateCount++; - } - - selectedCount++; - if (selectedCount >= query.MaxTargets) - { - reachedLimit = true; - } - } + projectileCandidateCount++; } - - if (!hasEnemyTargets || reachedLimit) + else if (candidate.SourceType == CollisionSourceTypeArea) { - continue; - } - - for (int dx = -queryRange; dx <= queryRange && !reachedLimit; dx++) - { - for (int dz = -queryRange; dz <= queryRange && !reachedLimit; dz++) - { - long key = SeparationCellKey(centerCellX + dx, centerCellZ + dz); - if (!_enemyCollisionBuckets.TryGetFirstValue(key, out int enemyIndex, - out NativeParallelMultiHashMapIterator iterator)) - { - continue; - } - - do - { - if (enemyIndex < 0 || enemyIndex >= _enemyJobOutputs.Length) - { - continue; - } - - EnemyJobOutputData enemy = _enemyJobOutputs[enemyIndex]; - if (enemy.EntityId == query.SourceOwnerEntityId) - { - continue; - } - - float3 delta = new float3( - enemy.Position.x - query.Position.x, - enemy.Position.y - query.Position.y, - enemy.Position.z - query.Position.z); - float sqrDistance = math.lengthsq(delta); - if (sqrDistance > radiusSqr) - { - continue; - } - - AddCollisionCandidate( - query.QueryId, - query.SourceType, - query.SourceEntityId, - query.SourceOwnerEntityId, - enemy.EntityId, - sqrDistance); - if (query.SourceType == CollisionSourceTypeProjectile) - { - projectileCandidateCount++; - } - else if (query.SourceType == CollisionSourceTypeArea) - { - areaCandidateCount++; - } - selectedCount++; - - if (selectedCount >= query.MaxTargets) - { - reachedLimit = true; - break; - } - } while (_enemyCollisionBuckets.TryGetNextValue(out enemyIndex, ref iterator)); - } + areaCandidateCount++; } } } @@ -876,7 +758,7 @@ namespace Simulation playerEntityId = PlayerEntityId; playerPosition = default; - if (!TryGetTargetableEntity(playerEntityId, out TargetableObject playerTarget)) + if (!TryGetAliveTargetableEntity(playerEntityId, out TargetableObject playerTarget)) { return false; } @@ -892,22 +774,7 @@ namespace Simulation return true; } - private static bool ShouldRecycleProjectile(in ProjectileSimData projectile) - { - if (!projectile.Active) - { - return true; - } - - if (projectile.State == ProjectileStateExpired) - { - return true; - } - - return projectile.LifeTime > 0f && projectile.Age >= projectile.LifeTime; - } - - private static bool IsCollisionSourceActiveAtQueryTime(int sourceEntityId) + private static bool WasCollisionSourceActiveAtQueryTime(int sourceEntityId) { EntityBase sourceEntity = TryGetEntityById(sourceEntityId); if (sourceEntity == null || !sourceEntity.Available) @@ -928,84 +795,6 @@ namespace Simulation return true; } - private static void ExecuteProjectileMovement(int index, NativeArray inputs, - NativeArray outputs, float deltaTime, float3 playerPosition, - float maxSqrDistanceFromPlayer, float maxVerticalOffsetFromPlayer) - { - ProjectileJobInputData input = inputs[index]; - ProjectileJobOutputData output = new ProjectileJobOutputData - { - EntityId = input.EntityId, - OwnerEntityId = input.OwnerEntityId, - Position = input.Position, - Forward = input.Forward, - Velocity = input.Velocity, - Speed = input.Speed, - LifeTime = input.LifeTime, - Age = input.Age, - Active = input.Active, - RemainingLifetime = input.RemainingLifetime, - State = input.State - }; - - if (!input.Active) - { - output.State = ProjectileStateExpired; - outputs[index] = output; - return; - } - - float3 position = input.Position; - float3 forward = input.Forward; - float3 velocity = input.Velocity; - if (math.lengthsq(velocity) <= float.Epsilon && input.Speed > 0f) - { - float3 moveDirection = math.normalizesafe(forward, new float3(0f, 0f, 1f)); - velocity = moveDirection * input.Speed; - } - - float3 nextPosition = position + velocity * deltaTime; - float nextAge = math.max(0f, input.Age + deltaTime); - float nextRemainingLifetime = input.RemainingLifetime; - bool shouldExpire = false; - - if (input.LifeTime > 0f) - { - nextRemainingLifetime = math.max(0f, input.LifeTime - nextAge); - shouldExpire = nextAge >= input.LifeTime; - } - else if (input.RemainingLifetime > 0f) - { - nextRemainingLifetime = math.max(0f, input.RemainingLifetime - deltaTime); - shouldExpire = nextRemainingLifetime <= float.Epsilon; - } - - if (!shouldExpire && maxSqrDistanceFromPlayer > 0f) - { - float3 horizontalDelta = - new float3(nextPosition.x - playerPosition.x, 0f, nextPosition.z - playerPosition.z); - shouldExpire = math.lengthsq(horizontalDelta) > maxSqrDistanceFromPlayer; - } - - if (!shouldExpire && maxVerticalOffsetFromPlayer > 0f) - { - shouldExpire = math.abs(nextPosition.y - playerPosition.y) > maxVerticalOffsetFromPlayer; - } - - output.Position = nextPosition; - output.Velocity = velocity; - output.Age = nextAge; - output.RemainingLifetime = nextRemainingLifetime; - output.Active = !shouldExpire; - output.State = shouldExpire ? ProjectileStateExpired : ProjectileStateActive; - - if (math.lengthsq(velocity) > float.Epsilon) - { - float3 moveForward = math.normalizesafe(velocity, forward); - output.Forward = moveForward; - } - - outputs[index] = output; - } + #endregion } } diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs.meta new file mode 100644 index 0000000..be1c4d6 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.CollisionPipeline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 87bc5c5ec75920d46a65881e949bd2a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs new file mode 100644 index 0000000..3d447f5 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs @@ -0,0 +1,280 @@ +using CustomDebugger; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + // Orchestrates per-tick simulation pipeline and enemy movement/separation jobs. + [Header("敌人互斥参数")] + [Tooltip("敌人互斥分桶使用的网格尺寸。小于等于 0 时,将根据敌人体积半径自动计算。")] + [SerializeField] + private float _enemySeparationCellSize = 0f; + + [Tooltip("每次迭代对互斥推力累积值的阻尼系数。数值越大,分离速度越快。")] + [SerializeField] + private float _enemySeparationPushDamping = 0.75f; + + [Tooltip("每次迭代允许的最大互斥位移步长(按敌人体积半径倍率计算)。")] + [SerializeField] + private float _enemySeparationMaxStepScale = 1f; + + [Tooltip("敌人进入攻击范围后,互斥位移是否保持为相对玩家方向的切向分量(避免被径向推离玩家)。")] + [SerializeField] + private bool _enemySeparationUseTangentialInAttackRange = true; + + [Tooltip("互斥推力方向突变时的时间平滑系数。越大越稳定,但响应越慢。")] + [SerializeField] + private float _enemySeparationPushSmoothing = 0.55f; + + private void TickSimulationPipeline(in SimulationTickContext context) + { + // 1. 早退分支:deltaTime <= 0 时只清理碰撞通道和统计,然后返回。 + if (context.DeltaTime <= 0f) + { + PrepareCollisionQueryAndCandidateChannels(0, 0, 0); + ResetCollisionRuntimeStats(); + ClearAreaCollisionTransientBuffers(); + return; + } + + JobHandle enemyMovementHandle = default; + JobHandle projectileMovementHandle = default; + JobHandle enemySeparationHandle = default; + bool hasEnemySeparationJob = false; + bool hasEnemySeparationCandidates = false; + int enemySeparationCount = 0; + float enemySeparationMaxRadius = 0.45f; + + // 2. BuildInput 阶段: + // 把 _enemies/_projectiles 同步到 Native 输入,准备输出缓冲; + // 统计是否需要敌人分离 Job; + // 预估并准备碰撞查询/候选缓冲。 + using (CustomProfilerMarker.TickEnemies_BuildInput.Auto()) + { + SyncSimulationStateToJobInputs(); + int enemyCount = _enemyJobInputs.Length; + int projectileCount = _projectileJobInputs.Length; + PrepareEnemyJobOutputBuffer(enemyCount); + PrepareProjectileJobOutputBuffer(projectileCount); + + enemySeparationCount = enemyCount; + for (int i = 0; i < enemyCount; i++) + { + EnemyJobInputData input = _enemyJobInputs[i]; + if (!input.AvoidEnemyOverlap) + { + continue; + } + + hasEnemySeparationCandidates = true; + float radius = input.EnemyBodyRadius > 0f ? input.EnemyBodyRadius : 0.45f; + if (radius > enemySeparationMaxRadius) + { + enemySeparationMaxRadius = radius; + } + } + + if (hasEnemySeparationCandidates) + { + int separationBucketCapacity = Mathf.Max(128, enemyCount * 2); + PrepareEnemySeparationJobBuffers(enemyCount, separationBucketCapacity); + } + + int projectileQueryCount = _projectiles.Count; + int areaQueryCount = GetPendingAreaCollisionRequestCount(); + int queryCount = projectileQueryCount + areaQueryCount; + int projectileExpectedCount = projectileQueryCount * Mathf.Max(1, _projectileMaxCandidatesPerQuery); + int areaExpectedCount = EstimatePendingAreaCollisionCandidateCountFromRequests(); + int expectedCandidateCount = Mathf.Max(16, projectileExpectedCount + areaExpectedCount); + int bucketCapacity = Mathf.Max(256, _enemies.Count * 2 + queryCount); + PrepareCollisionQueryAndCandidateChannels(queryCount, expectedCandidateCount, bucketCapacity); + } + + // 3. StateUpdate 阶段(调度两个移动 Job) + using (CustomProfilerMarker.TickEnemies_StateUpdate.Auto()) + { + enemyMovementHandle = ExecuteEnemyMovementJob(in context); + projectileMovementHandle = ExecuteProjectileMovementJob(in context); + } + + // 4. Schedule 阶段(可选分离 Job) + // 如果有需要分离的敌人,调度: + // BuildEnemySeparationBucketsBurstJob(依赖 EnemyMovement) + // EnemySeparationBurstJob(依赖分桶 Job) + // 然后把“敌人链路 handle”和“投射物移动 handle”合并。 + JobHandle simulationHandle; + using (CustomProfilerMarker.TickEnemies_Schedule.Auto()) + { + hasEnemySeparationJob = TryScheduleEnemySeparationFromJobOutput( + in context, + enemyMovementHandle, + hasEnemySeparationCandidates, + enemySeparationCount, + enemySeparationMaxRadius, + out enemySeparationHandle); + JobHandle enemyHandle = hasEnemySeparationJob ? enemySeparationHandle : enemyMovementHandle; + simulationHandle = JobHandle.CombineDependencies(enemyHandle, projectileMovementHandle); + } + + // 5. Complete 阶段:等待上述 Job 全部完成。 + using (CustomProfilerMarker.TickEnemies_Complete.Auto()) + { + simulationHandle.Complete(); + } + + // 6. 主线程后处理阶段: + // - 把分离结果覆盖回敌人输出(如果有分离 Job) + // - 构建碰撞候选(投射物查询 + area 请求查询 + 网格筛选) + using (CustomProfilerMarker.TickEnemies_MainThreadCommit.Auto()) + { + if (hasEnemySeparationJob) + { + CommitEnemySeparationFromJobOutput(enemySeparationCount); + } + } + + using (CustomProfilerMarker.Collision.Auto()) + { + PrepareCollisionCandidatesForFrame(); + CompleteCollisionCandidatesForFrame(); + } + + // 7. MainThreadCommit 阶段 + // - ApplyJobOutputsToSimulationState(写回 _enemies/_projectiles) + // - ResolveCollisionCandidatesOnMainThread(命中结算、事件、范围碰撞) + // - RecycleInactiveAndExpiredProjectiles(隐藏并移除失效投射物) + using (CustomProfilerMarker.TickEnemies_WriteBack.Auto()) + { + using (CustomProfilerMarker.TickEnemies_MainThreadCommit.Auto()) + { + ApplyJobOutputsToSimulationState(); + ResolveCollisionCandidatesOnMainThread(); + RecycleInactiveAndExpiredProjectiles(); + } + } + } + + private JobHandle ExecuteEnemyMovementJob(in SimulationTickContext context) + { + int enemyCount = _enemyJobInputs.Length; + if (enemyCount == 0) + { + return default; + } + + if (context.DeltaTime <= 0f) + { + CopyEnemyInputsToOutputs(); + return default; + } + + float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z); + NativeArray inputArray = _enemyJobInputs.AsArray(); + NativeArray outputArray = _enemyJobOutputs.AsArray(); + + EnemyMovementBurstJob burstJob = new EnemyMovementBurstJob + { + Inputs = inputArray, + Outputs = outputArray, + DeltaTime = context.DeltaTime, + PlayerPosition = playerPosition + }; + return burstJob.Schedule(enemyCount, 64); + } + + private void CopyEnemyInputsToOutputs() + { + for (int i = 0; i < _enemyJobInputs.Length; i++) + { + EnemyJobInputData input = _enemyJobInputs[i]; + _enemyJobOutputs[i] = new EnemyJobOutputData + { + EntityId = input.EntityId, + Position = input.Position, + Forward = input.Forward, + Rotation = input.Rotation, + Speed = input.Speed, + AttackRange = input.AttackRange, + AvoidEnemyOverlap = input.AvoidEnemyOverlap, + EnemyBodyRadius = input.EnemyBodyRadius, + SeparationIterations = input.SeparationIterations, + TargetType = input.TargetType, + State = input.State + }; + } + } + + private bool TryScheduleEnemySeparationFromJobOutput(in SimulationTickContext context, JobHandle dependency, + bool hasSeparationCandidates, int enemyCount, float maxRadius, out JobHandle separationHandle) + { + separationHandle = dependency; + if (enemyCount <= 0 || !hasSeparationCandidates) + { + return false; + } + + float autoCellSize = maxRadius * 2f; + float configuredCellSize = _enemySeparationCellSize > 0f ? _enemySeparationCellSize : autoCellSize; + float cellSize = Mathf.Max(0.1f, configuredCellSize); + float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z); + float pushDamping = Mathf.Clamp(_enemySeparationPushDamping, 0f, 2f); + float maxStepScale = Mathf.Max(0.1f, _enemySeparationMaxStepScale); + bool useTangentialInAttackRange = _enemySeparationUseTangentialInAttackRange; + float pushSmoothing = Mathf.Clamp01(_enemySeparationPushSmoothing); + + NativeArray inputArray = _enemyJobOutputs.AsArray(); + NativeArray separatedOutputArray = _enemyJobSeparationOutputs.AsArray(); + NativeArray previousPushes = _enemySeparationPreviousPushes.AsArray(); + NativeArray currentPushes = _enemySeparationCurrentPushes.AsArray(); + + + BuildEnemySeparationBucketsBurstJob buildJob = new BuildEnemySeparationBucketsBurstJob + { + Inputs = inputArray, + Buckets = _enemySeparationBuckets.AsParallelWriter(), + CellSize = cellSize + }; + JobHandle buildHandle = buildJob.Schedule(enemyCount, 64, dependency); + EnemySeparationBurstJob separationJob = new EnemySeparationBurstJob + { + Inputs = inputArray, + Buckets = _enemySeparationBuckets, + PreviousPushes = previousPushes, + Outputs = separatedOutputArray, + CurrentPushes = currentPushes, + CellSize = cellSize, + MaxRadius = maxRadius, + PlayerPosition = playerPosition, + PushDamping = pushDamping, + MaxStepScale = maxStepScale, + UseTangentialInAttackRange = useTangentialInAttackRange, + PushSmoothing = pushSmoothing + }; + separationHandle = separationJob.Schedule(enemyCount, 64, buildHandle); + return true; + } + + private void CommitEnemySeparationFromJobOutput(int enemyCount) + { + if (enemyCount <= 0) + { + return; + } + + CommitEnemySeparationTemporalBuffers(enemyCount); + for (int i = 0; i < enemyCount; i++) + { + _enemyJobOutputs[i] = _enemyJobSeparationOutputs[i]; + } + } + + private static long SeparationCellKey(int x, int z) + { + return ((long)x << 32) ^ (uint)z; + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs.meta similarity index 100% rename from Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs.meta rename to Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.EnemyJobs.cs.meta diff --git a/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs new file mode 100644 index 0000000..c56b8f7 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs @@ -0,0 +1,111 @@ +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + [Header("Projectile Simulation")] + [Tooltip("Recycle projectile when horizontal distance to player exceeds this range. <=0 disables this rule.")] + [SerializeField] + private float _projectileMaxDistanceFromPlayer = 120f; + + [Tooltip("Recycle projectile when vertical offset to player exceeds this range. <=0 disables this rule.")] + [SerializeField] + private float _projectileMaxVerticalOffsetFromPlayer = 30f; + + #region Projectile Movement Job + + private JobHandle ExecuteProjectileMovementJob(in SimulationTickContext context) + { + int projectileCount = _projectileJobInputs.Length; + if (projectileCount == 0) + { + return default; + } + + if (context.DeltaTime <= 0f) + { + CopyProjectileInputsToOutputs(); + return default; + } + + float maxDistance = Mathf.Max(0f, _projectileMaxDistanceFromPlayer); + float maxSqrDistanceFromPlayer = maxDistance > 0f ? maxDistance * maxDistance : -1f; + float maxVerticalOffsetFromPlayer = Mathf.Max(0f, _projectileMaxVerticalOffsetFromPlayer); + float3 playerPosition = + new float3(context.PlayerPosition.x, context.PlayerPosition.y, context.PlayerPosition.z); + NativeArray inputArray = _projectileJobInputs.AsArray(); + NativeArray outputArray = _projectileJobOutputs.AsArray(); + + + ProjectileMovementBurstJob burstJob = new ProjectileMovementBurstJob + { + Inputs = inputArray, + Outputs = outputArray, + DeltaTime = context.DeltaTime, + PlayerPosition = playerPosition, + MaxSqrDistanceFromPlayer = maxSqrDistanceFromPlayer, + MaxVerticalOffsetFromPlayer = maxVerticalOffsetFromPlayer + }; + return burstJob.Schedule(projectileCount, 64); + } + + #endregion + + #region Projectile Cleanup + + private void RecycleInactiveAndExpiredProjectiles() + { + _projectileRecycleEntityIds.Clear(); + for (int i = 0; i < _projectiles.Count; i++) + { + ProjectileSimData projectile = _projectiles[i]; + if (!ShouldRecycleProjectileSimData(projectile)) + { + continue; + } + + _projectileRecycleEntityIds.Add(projectile.EntityId); + } + + if (_projectileRecycleEntityIds.Count == 0) + { + return; + } + + var entityComponent = GameEntry.Entity; + for (int i = 0; i < _projectileRecycleEntityIds.Count; i++) + { + int entityId = _projectileRecycleEntityIds[i]; + if (entityComponent != null) + { + entityComponent.HideEntity(entityId); + } + + RemoveProjectileByEntityId(entityId); + } + + _projectileRecycleEntityIds.Clear(); + } + + private static bool ShouldRecycleProjectileSimData(in ProjectileSimData projectile) + { + if (!projectile.Active) + { + return true; + } + + if (projectile.State == ProjectileStateExpired) + { + return true; + } + + return projectile.LifeTime > 0f && projectile.Age >= projectile.LifeTime; + } + + #endregion + } +} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs.meta b/Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs.meta similarity index 100% rename from Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs.meta rename to Assets/GameMain/Scripts/Simulation/Jobs/SimulationWorld.ProjectileJobs.cs.meta diff --git a/Assets/GameMain/Scripts/Simulation/Presentation.meta b/Assets/GameMain/Scripts/Simulation/Presentation.meta new file mode 100644 index 0000000..4ef6cfc --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Presentation.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 4bf448696a8641268abb660b16def6f5 +timeCreated: 1771911417 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs new file mode 100644 index 0000000..d2776e7 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs @@ -0,0 +1,145 @@ +using CustomEvent; +using Entity; +using Entity.EntityData; +using Entity.Weapon; +using GameFramework.Event; +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + [Header("投射物命中表现")] + [Tooltip("是否监听投射物命中表现事件。")] + [SerializeField] + private bool _projectileHitPresentationEnabled = true; + + [Tooltip("是否播放投射物命中标记。")] + [SerializeField] + private bool _projectileHitMarkerEnabled = true; + + [Tooltip("命中标记尺寸。")][SerializeField] private float _projectileHitMarkerSize = 0.2f; + + [Tooltip("命中标记在目标上的高度偏移。")] + [SerializeField] + private float _projectileHitMarkerYOffset = 1.2f; + + [Tooltip("命中标记持续时间。")] + [SerializeField] + private float _projectileHitMarkerDuration = 0.15f; + + [Tooltip("命中标记颜色。")][SerializeField] private Color _projectileHitMarkerColor = new(1f, 0f, 0f, 0.95f); + + [Tooltip("是否播放投射物命中特效实体。")] + [SerializeField] + private bool _projectileHitEffectEnabled; + + [Tooltip("投射物命中特效实体类型 Id(<=0 表示不启用)。")] + [SerializeField] + private int _projectileHitEffectTypeId; + + private sealed class HitPresentation + { + private readonly SimulationWorld _world; + private HandgunHitMarkerAttackEffect _projectileHitMarkerEffect; + private bool _isProjectileHitEventSubscribed; + + public HitPresentation(SimulationWorld world) + { + _world = world; + } + + public void OnStart() + { + if (_world == null || !_world._projectileHitPresentationEnabled) + { + return; + } + + var eventComponent = GameEntry.Event; + if (eventComponent == null) + { + return; + } + + eventComponent.Subscribe(ProjectileHitPresentationEventArgs.EventId, OnProjectileHitPresentationEvent); + _isProjectileHitEventSubscribed = true; + } + + public void OnDestroy() + { + if (!_isProjectileHitEventSubscribed) + { + return; + } + + var eventComponent = GameEntry.Event; + if (eventComponent != null) + { + eventComponent.Unsubscribe(ProjectileHitPresentationEventArgs.EventId, + OnProjectileHitPresentationEvent); + } + + _isProjectileHitEventSubscribed = false; + } + + private void OnProjectileHitPresentationEvent(object sender, GameEventArgs e) + { + if (!_isProjectileHitEventSubscribed || _world == null || + e is not ProjectileHitPresentationEventArgs args) + { + return; + } + + if (args.ShowHitMarker && _world._projectileHitMarkerEnabled) + { + PlayHitMarker(args); + } + + if (args.ShowHitEffect && _world._projectileHitEffectEnabled) + { + PlayHitEffect(args); + } + } + + private void PlayHitMarker(ProjectileHitPresentationEventArgs args) + { + EntityBase targetEntity = TryGetEntityById(args.TargetEntityId); + if (targetEntity == null || !targetEntity.Available) + { + return; + } + + _projectileHitMarkerEffect ??= new HandgunHitMarkerAttackEffect( + Mathf.Max(0.01f, _world._projectileHitMarkerSize), + _world._projectileHitMarkerYOffset, + Mathf.Max(0.01f, _world._projectileHitMarkerDuration), + _world._projectileHitMarkerColor); + _projectileHitMarkerEffect.Play(null, args.HitPosition, targetEntity, 0f); + } + + private void PlayHitEffect(ProjectileHitPresentationEventArgs args) + { + int effectTypeId = args.EffectEntityTypeId > 0 + ? args.EffectEntityTypeId + : _world._projectileHitEffectTypeId; + if (effectTypeId <= 0) + { + return; + } + + var entityComponent = GameEntry.Entity; + if (entityComponent == null) + { + return; + } + + EffectData effectData = new EffectData(entityComponent.GenerateSerialId(), effectTypeId) + { + Position = args.HitPosition + }; + entityComponent.ShowEffect(effectData); + } + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs.meta b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs.meta new file mode 100644 index 0000000..0364ca5 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.HitPresentation.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 99cec505913140a2b4bdf1498b28c044 +timeCreated: 1771911517 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs new file mode 100644 index 0000000..34b4914 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs @@ -0,0 +1,9 @@ +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + + } +} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs.meta b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs.meta similarity index 100% rename from Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs.meta rename to Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.Presentation.cs.meta diff --git a/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs new file mode 100644 index 0000000..5217514 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs @@ -0,0 +1,107 @@ +using Entity; +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + private sealed class TransformSync + { + private readonly SimulationWorld _world; + + public TransformSync(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; + foreach (var enemy in enemies) + { + if (enemy is not EnemyBase enemyEntity || !enemyEntity.Available) + { + continue; + } + + if (!_world.TryGetEnemyData(enemyEntity.Id, out EnemySimData enemyData)) + { + continue; + } + + ApplyEnemyPresentation(enemyEntity, enemyData); + } + + var projectiles = _world._projectiles; + for (int i = 0; i < projectiles.Count; i++) + { + ProjectileSimData projectileData = projectiles[i]; + if (!projectileData.Active || projectileData.State != ProjectileStateActive) + { + continue; + } + + EntityBase projectileEntity = TryGetEntityById(projectileData.EntityId); + if (projectileEntity == null || !projectileEntity.Available) + { + continue; + } + + ApplyProjectilePresentation(projectileEntity, projectileData); + } + } + + 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; + } + } + + private static void ApplyProjectilePresentation(EntityBase projectileEntity, + in ProjectileSimData projectileData) + { + Transform projectileTransform = projectileEntity.CachedTransform; + if (projectileTransform == null) + { + return; + } + + projectileTransform.position = projectileData.Position; + Vector3 forward = projectileData.Forward; + if (forward.sqrMagnitude <= float.Epsilon) + { + return; + } + + projectileTransform.rotation = Quaternion.LookRotation(forward.normalized, Vector3.up); + } + } + } +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs.meta b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs.meta new file mode 100644 index 0000000..e3bb324 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/Presentation/SimulationWorld.TransformSync.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 21da1bcb02d247358738e9b562af5512 +timeCreated: 1771911431 \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs deleted file mode 100644 index 8fbfee7..0000000 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs +++ /dev/null @@ -1,651 +0,0 @@ -using CustomDebugger; -using Unity.Burst; -using Unity.Collections; -using Unity.Jobs; -using Unity.Mathematics; -using UnityEngine; - -namespace Simulation -{ - public sealed partial class SimulationWorld - { - [Header("敌人互斥参数")] [Tooltip("敌人互斥分桶使用的网格尺寸。小于等于 0 时,将根据敌人体积半径自动计算。")] [SerializeField] - private float _enemySeparationCellSize = 0f; - - [Tooltip("每次迭代对互斥推力累积值的阻尼系数。数值越大,分离速度越快。")] [SerializeField] - private float _enemySeparationPushDamping = 0.75f; - - [Tooltip("每次迭代允许的最大互斥位移步长(按敌人体积半径倍率计算)。")] [SerializeField] - private float _enemySeparationMaxStepScale = 1f; - - [Tooltip("敌人进入攻击范围后,互斥位移是否保持为相对玩家方向的切向分量(避免被径向推离玩家)。")] [SerializeField] - private bool _enemySeparationUseTangentialInAttackRange = true; - - [Tooltip("互斥推力方向突变时的时间平滑系数。越大越稳定,但响应越慢。")] [SerializeField] - private float _enemySeparationPushSmoothing = 0.55f; - - [BurstCompile] - private struct EnemyMovementBurstJob : IJobParallelFor - { - [ReadOnly] public NativeArray Inputs; - public NativeArray Outputs; - public float DeltaTime; - public float3 PlayerPosition; - - public void Execute(int index) - { - ExecuteEnemyMovement(index, Inputs, Outputs, DeltaTime, PlayerPosition); - } - } - - private struct EnemyMovementJob : IJobParallelFor - { - [ReadOnly] public NativeArray Inputs; - public NativeArray Outputs; - public float DeltaTime; - public float3 PlayerPosition; - - public void Execute(int index) - { - ExecuteEnemyMovement(index, Inputs, Outputs, DeltaTime, PlayerPosition); - } - } - - [BurstCompile] - private struct BuildEnemySeparationBucketsBurstJob : IJobParallelFor - { - [ReadOnly] public NativeArray Inputs; - public NativeParallelMultiHashMap.ParallelWriter Buckets; - public float CellSize; - - public void Execute(int index) - { - BuildEnemySeparationBucket(index, Inputs, Buckets, CellSize); - } - } - - private struct BuildEnemySeparationBucketsJob : IJobParallelFor - { - [ReadOnly] public NativeArray Inputs; - public NativeParallelMultiHashMap.ParallelWriter Buckets; - public float CellSize; - - public void Execute(int index) - { - BuildEnemySeparationBucket(index, Inputs, Buckets, CellSize); - } - } - - [BurstCompile] - private struct EnemySeparationBurstJob : IJobParallelFor - { - [ReadOnly] public NativeArray Inputs; - [ReadOnly] public NativeParallelMultiHashMap Buckets; - [ReadOnly] public NativeArray PreviousPushes; - public NativeArray Outputs; - public NativeArray CurrentPushes; - public float CellSize; - public float MaxRadius; - public float3 PlayerPosition; - public float PushDamping; - public float MaxStepScale; - public bool UseTangentialInAttackRange; - public float PushSmoothing; - - public void Execute(int index) - { - ExecuteEnemySeparation(index, Inputs, Buckets, Outputs, CellSize, MaxRadius, PlayerPosition, - PushDamping, MaxStepScale, UseTangentialInAttackRange, PreviousPushes, CurrentPushes, - PushSmoothing); - } - } - - private struct EnemySeparationJob : IJobParallelFor - { - [ReadOnly] public NativeArray Inputs; - [ReadOnly] public NativeParallelMultiHashMap Buckets; - [ReadOnly] public NativeArray PreviousPushes; - public NativeArray Outputs; - public NativeArray CurrentPushes; - public float CellSize; - public float MaxRadius; - public float3 PlayerPosition; - public float PushDamping; - public float MaxStepScale; - public bool UseTangentialInAttackRange; - public float PushSmoothing; - - public void Execute(int index) - { - ExecuteEnemySeparation(index, Inputs, Buckets, Outputs, CellSize, MaxRadius, PlayerPosition, - PushDamping, MaxStepScale, UseTangentialInAttackRange, PreviousPushes, CurrentPushes, - PushSmoothing); - } - } - - private void TickEnemiesJobified(in SimulationTickContext context) - { - if (context.DeltaTime <= 0f) - { - PrepareCollisionCandidateChannels(0, 0, 0); - ResetCollisionRuntimeStats(); - ClearAreaCollisionFrameBuffers(); - return; - } - - JobHandle enemyMovementHandle = default; - JobHandle projectileMovementHandle = default; - JobHandle enemySeparationHandle = default; - bool hasEnemySeparationJob = false; - bool hasEnemySeparationCandidates = false; - int enemySeparationCount = 0; - float enemySeparationMaxRadius = 0.45f; - - using (CustomProfilerMarker.TickEnemies_BuildInput.Auto()) - { - SyncSimulationToJobInput(); - int enemyCount = _enemyJobInputs.Length; - int projectileCount = _projectileJobInputs.Length; - PrepareEnemyJobOutputBuffer(enemyCount); - PrepareProjectileJobOutputBuffer(projectileCount); - - enemySeparationCount = enemyCount; - for (int i = 0; i < enemyCount; i++) - { - EnemyJobInputData input = _enemyJobInputs[i]; - if (!input.AvoidEnemyOverlap) - { - continue; - } - - hasEnemySeparationCandidates = true; - float radius = input.EnemyBodyRadius > 0f ? input.EnemyBodyRadius : 0.45f; - if (radius > enemySeparationMaxRadius) - { - enemySeparationMaxRadius = radius; - } - } - - if (hasEnemySeparationCandidates) - { - int separationBucketCapacity = Mathf.Max(128, enemyCount * 2); - PrepareEnemySeparationJobBuffers(enemyCount, separationBucketCapacity); - } - - int projectileQueryCount = _projectiles.Count; - int areaQueryCount = GetPendingAreaCollisionQueryCount(); - int queryCount = projectileQueryCount + areaQueryCount; - int projectileExpectedCount = projectileQueryCount * Mathf.Max(1, _projectileMaxCandidatesPerQuery); - int areaExpectedCount = EstimatePendingAreaCollisionCandidateCount(); - int expectedCandidateCount = Mathf.Max(16, projectileExpectedCount + areaExpectedCount); - int bucketCapacity = Mathf.Max(256, _enemies.Count * 2 + queryCount); - PrepareCollisionCandidateChannels(queryCount, expectedCandidateCount, bucketCapacity); - } - - using (CustomProfilerMarker.TickEnemies_StateUpdate.Auto()) - { - enemyMovementHandle = ExecuteEnemyMovementJob(in context); - projectileMovementHandle = ExecuteProjectileMovementJob(in context); - } - - JobHandle simulationHandle; - using (CustomProfilerMarker.TickEnemies_Schedule.Auto()) - { - hasEnemySeparationJob = TryScheduleEnemySeparationForJobOutput( - in context, - enemyMovementHandle, - hasEnemySeparationCandidates, - enemySeparationCount, - enemySeparationMaxRadius, - out enemySeparationHandle); - JobHandle enemyHandle = hasEnemySeparationJob ? enemySeparationHandle : enemyMovementHandle; - simulationHandle = JobHandle.CombineDependencies(enemyHandle, projectileMovementHandle); - } - - using (CustomProfilerMarker.TickEnemies_Complete.Auto()) - { - simulationHandle.Complete(); - } - - using (CustomProfilerMarker.TickEnemies_MoveSeparation.Auto()) - { - if (hasEnemySeparationJob) - { - CommitEnemySeparationForJobOutput(enemySeparationCount); - } - - BuildProjectileCollisionCandidates(); - } - - using (CustomProfilerMarker.TickEnemies_WriteBack.Auto()) - { - using (CustomProfilerMarker.TickEnemies_MainThreadCommit.Auto()) - { - ApplyJobOutputToSimulation(); - ResolveProjectileCollisionCandidatesMainThread(); - RecycleInactiveProjectiles(); - } - } - } - - private JobHandle ExecuteEnemyMovementJob(in SimulationTickContext context) - { - int enemyCount = _enemyJobInputs.Length; - if (enemyCount == 0) - { - return default; - } - - if (context.DeltaTime <= 0f) - { - CopyEnemyInputToOutput(); - return default; - } - - float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z); - NativeArray inputArray = _enemyJobInputs.AsArray(); - NativeArray outputArray = _enemyJobOutputs.AsArray(); - - if (_useBurstJobs) - { - EnemyMovementBurstJob burstJob = new EnemyMovementBurstJob - { - Inputs = inputArray, - Outputs = outputArray, - DeltaTime = context.DeltaTime, - PlayerPosition = playerPosition - }; - return burstJob.Schedule(enemyCount, 64); - } - - EnemyMovementJob job = new EnemyMovementJob - { - Inputs = inputArray, - Outputs = outputArray, - DeltaTime = context.DeltaTime, - PlayerPosition = playerPosition - }; - return job.Schedule(enemyCount, 64); - } - - private void CopyEnemyInputToOutput() - { - for (int i = 0; i < _enemyJobInputs.Length; i++) - { - EnemyJobInputData input = _enemyJobInputs[i]; - _enemyJobOutputs[i] = new EnemyJobOutputData - { - EntityId = input.EntityId, - Position = input.Position, - Forward = input.Forward, - Rotation = input.Rotation, - Speed = input.Speed, - AttackRange = input.AttackRange, - AvoidEnemyOverlap = input.AvoidEnemyOverlap, - EnemyBodyRadius = input.EnemyBodyRadius, - SeparationIterations = input.SeparationIterations, - TargetType = input.TargetType, - State = input.State - }; - } - } - - private bool TryScheduleEnemySeparationForJobOutput(in SimulationTickContext context, JobHandle dependency, - bool hasSeparationCandidates, int enemyCount, float maxRadius, out JobHandle separationHandle) - { - separationHandle = dependency; - if (enemyCount <= 0 || !hasSeparationCandidates) - { - return false; - } - - float autoCellSize = maxRadius * 2f; - float configuredCellSize = _enemySeparationCellSize > 0f ? _enemySeparationCellSize : autoCellSize; - float cellSize = Mathf.Max(0.1f, configuredCellSize); - float3 playerPosition = new float3(context.PlayerPosition.x, 0f, context.PlayerPosition.z); - float pushDamping = Mathf.Clamp(_enemySeparationPushDamping, 0f, 2f); - float maxStepScale = Mathf.Max(0.1f, _enemySeparationMaxStepScale); - bool useTangentialInAttackRange = _enemySeparationUseTangentialInAttackRange; - float pushSmoothing = Mathf.Clamp01(_enemySeparationPushSmoothing); - - NativeArray inputArray = _enemyJobOutputs.AsArray(); - NativeArray separatedOutputArray = _enemyJobSeparationOutputs.AsArray(); - NativeArray previousPushes = _enemySeparationPreviousPushes.AsArray(); - NativeArray currentPushes = _enemySeparationCurrentPushes.AsArray(); - if (_useBurstJobs) - { - BuildEnemySeparationBucketsBurstJob buildJob = new BuildEnemySeparationBucketsBurstJob - { - Inputs = inputArray, - Buckets = _enemySeparationBuckets.AsParallelWriter(), - CellSize = cellSize - }; - JobHandle buildHandle = buildJob.Schedule(enemyCount, 64, dependency); - EnemySeparationBurstJob separationJob = new EnemySeparationBurstJob - { - Inputs = inputArray, - Buckets = _enemySeparationBuckets, - PreviousPushes = previousPushes, - Outputs = separatedOutputArray, - CurrentPushes = currentPushes, - CellSize = cellSize, - MaxRadius = maxRadius, - PlayerPosition = playerPosition, - PushDamping = pushDamping, - MaxStepScale = maxStepScale, - UseTangentialInAttackRange = useTangentialInAttackRange, - PushSmoothing = pushSmoothing - }; - separationHandle = separationJob.Schedule(enemyCount, 64, buildHandle); - return true; - } - - BuildEnemySeparationBucketsJob nonBurstBuildJob = new BuildEnemySeparationBucketsJob - { - Inputs = inputArray, - Buckets = _enemySeparationBuckets.AsParallelWriter(), - CellSize = cellSize - }; - JobHandle nonBurstBuildHandle = nonBurstBuildJob.Schedule(enemyCount, 64, dependency); - EnemySeparationJob nonBurstSeparationJob = new EnemySeparationJob - { - Inputs = inputArray, - Buckets = _enemySeparationBuckets, - PreviousPushes = previousPushes, - Outputs = separatedOutputArray, - CurrentPushes = currentPushes, - CellSize = cellSize, - MaxRadius = maxRadius, - PlayerPosition = playerPosition, - PushDamping = pushDamping, - MaxStepScale = maxStepScale, - UseTangentialInAttackRange = useTangentialInAttackRange, - PushSmoothing = pushSmoothing - }; - separationHandle = nonBurstSeparationJob.Schedule(enemyCount, 64, nonBurstBuildHandle); - return true; - } - - private void CommitEnemySeparationForJobOutput(int enemyCount) - { - if (enemyCount <= 0) - { - return; - } - - CommitEnemySeparationTemporalBuffers(enemyCount); - for (int i = 0; i < enemyCount; i++) - { - _enemyJobOutputs[i] = _enemyJobSeparationOutputs[i]; - } - } - - private static void BuildEnemySeparationBucket(int index, NativeArray inputs, - NativeParallelMultiHashMap.ParallelWriter buckets, float cellSize) - { - EnemyJobOutputData output = inputs[index]; - if (!output.AvoidEnemyOverlap) - { - return; - } - - float3 position = output.Position; - position.y = 0f; - int cellX = (int)math.floor(position.x / cellSize); - int cellZ = (int)math.floor(position.z / cellSize); - buckets.Add(SeparationCellKey(cellX, cellZ), index); - } - - private static void ExecuteEnemySeparation(int index, NativeArray inputs, - NativeParallelMultiHashMap buckets, NativeArray outputs, - float cellSize, float maxRadius, float3 playerPosition, float pushDamping, float maxStepScale, - bool useTangentialInAttackRange, NativeArray previousPushes, - NativeArray currentPushes, float pushSmoothing) - { - currentPushes[index] = float2.zero; - EnemyJobOutputData self = inputs[index]; - if (!self.AvoidEnemyOverlap) - { - outputs[index] = self; - return; - } - - float3 candidate = self.Position; - candidate.y = 0f; - float3 original = candidate; - float3 fallback = - math.normalizesafe(new float3(self.Forward.x, 0f, self.Forward.z), new float3(1f, 0f, 0f)); - float selfRadius = self.EnemyBodyRadius > 0f ? self.EnemyBodyRadius : 0.45f; - int iterations = self.SeparationIterations > 0 ? self.SeparationIterations : 1; - int queryRange = math.max(1, (int)math.ceil((selfRadius + maxRadius) / cellSize)); - - for (int iter = 0; iter < iterations; iter++) - { - int cellX = (int)math.floor(candidate.x / cellSize); - int cellZ = (int)math.floor(candidate.z / cellSize); - float3 pushAccumulation = float3.zero; - - for (int dx = -queryRange; dx <= queryRange; dx++) - { - for (int dz = -queryRange; dz <= queryRange; dz++) - { - long key = SeparationCellKey(cellX + dx, cellZ + dz); - if (!buckets.TryGetFirstValue(key, out int otherIndex, - out NativeParallelMultiHashMapIterator iterator)) - { - continue; - } - - do - { - if (otherIndex == index) - { - continue; - } - - EnemyJobOutputData other = inputs[otherIndex]; - if (!other.AvoidEnemyOverlap) - { - continue; - } - - float otherRadius = other.EnemyBodyRadius > 0f ? other.EnemyBodyRadius : 0.45f; - float minDistance = selfRadius + otherRadius; - float minDistanceSqr = minDistance * minDistance; - - float3 otherPosition = other.Position; - otherPosition.y = 0f; - float3 toSelf = candidate - otherPosition; - float sqrDistance = math.lengthsq(toSelf); - - if (sqrDistance <= float.Epsilon) - { - float3 zeroDistanceAxis = GetZeroDistanceSeparationAxis(index, otherIndex); - float directionSign = index < otherIndex ? 1f : -1f; - pushAccumulation += zeroDistanceAxis * (selfRadius * 0.25f * directionSign); - continue; - } - - if (sqrDistance >= minDistanceSqr) - { - continue; - } - - float distance = math.sqrt(sqrDistance); - float penetration = minDistance - distance; - pushAccumulation += (toSelf / distance) * penetration; - } while (buckets.TryGetNextValue(out otherIndex, ref iterator)); - } - } - - if (math.lengthsq(pushAccumulation) <= float.Epsilon) - { - continue; - } - - float3 resolvedPush = pushAccumulation * pushDamping; - - float maxStep = selfRadius * maxStepScale; - float pushLength = math.length(resolvedPush); - if (pushLength > maxStep && pushLength > float.Epsilon) - { - resolvedPush = resolvedPush / pushLength * maxStep; - } - - candidate += resolvedPush; - } - - float3 framePush = candidate - original; - float2 previousPush2 = previousPushes[index]; - float3 previousPush = new float3(previousPush2.x, 0f, previousPush2.y); - float3 smoothedPush = SmoothSeparationPush(framePush, previousPush, pushSmoothing); - - if (useTangentialInAttackRange && self.State == EnemyStateInAttackRange) - { - smoothedPush = ProjectToTangential(smoothedPush, playerPosition, original); - } - - float maxTotalStep = selfRadius * maxStepScale * iterations; - float smoothedLength = math.length(smoothedPush); - if (smoothedLength > maxTotalStep && smoothedLength > float.Epsilon) - { - smoothedPush = smoothedPush / smoothedLength * maxTotalStep; - } - - float3 finalPosition = original + smoothedPush; - currentPushes[index] = new float2(smoothedPush.x, smoothedPush.z); - self.Position = new float3(finalPosition.x, self.Position.y, finalPosition.z); - if (math.lengthsq(smoothedPush) > float.Epsilon) - { - self.Forward = new float3(fallback.x, self.Forward.y, fallback.z); - } - - outputs[index] = self; - } - - private static float3 SmoothSeparationPush(float3 framePush, float3 previousPush, float pushSmoothing) - { - float frameLengthSqr = math.lengthsq(framePush); - float previousLengthSqr = math.lengthsq(previousPush); - - if (frameLengthSqr <= float.Epsilon) - { - return float3.zero; - } - - if (previousLengthSqr <= float.Epsilon || pushSmoothing <= 0f) - { - return framePush; - } - - float frameLength = math.sqrt(frameLengthSqr); - float previousLength = math.sqrt(previousLengthSqr); - float3 frameDirection = framePush / frameLength; - float3 previousDirection = previousPush / previousLength; - float directionAlignment = math.dot(frameDirection, previousDirection); - - if (directionAlignment >= 0.35f) - { - return framePush; - } - - float directionalFactor = math.saturate((0.35f - directionAlignment) / 1.35f); - float smoothingStrength = pushSmoothing * directionalFactor; - return math.lerp(framePush, previousPush, smoothingStrength); - } - - private static float3 ProjectToTangential(float3 push, float3 playerPosition, float3 currentPosition) - { - if (math.lengthsq(push) <= float.Epsilon) - { - return push; - } - - float3 toPlayer = playerPosition - currentPosition; - float toPlayerSqr = math.lengthsq(toPlayer); - if (toPlayerSqr <= float.Epsilon) - { - return push; - } - - float3 radialDirection = toPlayer / math.sqrt(toPlayerSqr); - float radialOffset = math.dot(push, radialDirection); - return push - radialDirection * radialOffset; - } - - private static long SeparationCellKey(int x, int z) - { - return ((long)x << 32) ^ (uint)z; - } - - private static float3 GetZeroDistanceSeparationAxis(int index, int otherIndex) - { - int lowIndex = math.min(index, otherIndex); - int highIndex = math.max(index, otherIndex); - uint pairHash = (uint)(lowIndex * 73856093) ^ (uint)(highIndex * 19349663); - - float axisX = (pairHash & 1023u) / 511.5f - 1f; - float axisZ = ((pairHash >> 10) & 1023u) / 511.5f - 1f; - float3 axis = new float3(axisX, 0f, axisZ); - return math.normalizesafe(axis, new float3(1f, 0f, 0f)); - } - - private static void ExecuteEnemyMovement(int index, NativeArray inputs, - NativeArray outputs, float deltaTime, float3 playerPosition) - { - EnemyJobInputData input = inputs[index]; - float attackRange = input.AttackRange > 0f ? input.AttackRange : DefaultAttackRange; - float attackRangeSqr = attackRange * attackRange; - - float3 currentPosition = input.Position; - float3 horizontalPosition = new float3(currentPosition.x, 0f, currentPosition.z); - float3 toPlayer = playerPosition - horizontalPosition; - float sqrDistance = math.lengthsq(toPlayer); - bool isInAttackRange = sqrDistance <= attackRangeSqr; - bool canChase = !isInAttackRange && input.Speed > 0f && sqrDistance > float.Epsilon; - - float3 forward = input.Forward; - float3 desiredPosition = currentPosition; - quaternion rotation = input.Rotation; - - if (canChase) - { - forward = math.normalizesafe(toPlayer, forward); - desiredPosition = currentPosition + forward * input.Speed * deltaTime; - if (math.lengthsq(forward) > float.Epsilon) - { - rotation = quaternion.LookRotationSafe(forward, math.up()); - } - } - - int nextState; - if (isInAttackRange) - { - nextState = EnemyStateInAttackRange; - } - else if (canChase) - { - nextState = EnemyStateChasing; - } - else - { - nextState = EnemyStateIdle; - } - - outputs[index] = new EnemyJobOutputData - { - EntityId = input.EntityId, - Position = desiredPosition, - Forward = forward, - Rotation = rotation, - Speed = input.Speed, - AttackRange = attackRange, - AvoidEnemyOverlap = input.AvoidEnemyOverlap, - EnemyBodyRadius = input.EnemyBodyRadius, - SeparationIterations = input.SeparationIterations, - TargetType = input.TargetType, - State = nextState - }; - } - } -} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs index 3cf2a39..d8a3290 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs @@ -4,8 +4,9 @@ using UnityGameFramework.Runtime; namespace Simulation { - public partial class SimulationWorld + public sealed partial class SimulationWorld { + // Bridges entity show/hide events into simulation state registration. public sealed class EntitySync { private const string EnemyGroupName = "Enemy"; diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs deleted file mode 100644 index d22b95a..0000000 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs +++ /dev/null @@ -1,232 +0,0 @@ -using CustomEvent; -using Entity; -using Entity.EntityData; -using Entity.Weapon; -using GameFramework.Event; -using UnityEngine; - -namespace Simulation -{ - public partial class SimulationWorld - { - [Header("投射物命中表现")] [Tooltip("是否监听投射物命中表现事件。")] [SerializeField] - private bool _projectileHitPresentationEnabled = true; - - [Tooltip("是否播放投射物命中标记。")] [SerializeField] - private bool _projectileHitMarkerEnabled = true; - - [Tooltip("命中标记尺寸。")] [SerializeField] - private float _projectileHitMarkerSize = 0.2f; - - [Tooltip("命中标记在目标上的高度偏移。")] [SerializeField] - private float _projectileHitMarkerYOffset = 1.2f; - - [Tooltip("命中标记持续时间。")] [SerializeField] - private float _projectileHitMarkerDuration = 0.15f; - - [Tooltip("命中标记颜色。")] [SerializeField] - private Color _projectileHitMarkerColor = new(1f, 0f, 0f, 0.95f); - - [Tooltip("是否播放投射物命中特效实体。")] [SerializeField] - private bool _projectileHitEffectEnabled; - - [Tooltip("投射物命中特效实体类型 Id(<=0 表示不启用)。")] [SerializeField] - private int _projectileHitEffectTypeId; - - private sealed class Presentation - { - private readonly SimulationWorld _world; - private HandgunHitMarkerAttackEffect _projectileHitMarkerEffect; - private bool _isProjectileHitEventSubscribed; - - public Presentation(SimulationWorld world) - { - _world = world; - } - - public void OnStart() - { - if (_world == null || !_world._projectileHitPresentationEnabled) - { - return; - } - - var eventComponent = GameEntry.Event; - if (eventComponent == null) - { - return; - } - - eventComponent.Subscribe(ProjectileHitPresentationEventArgs.EventId, OnProjectileHitPresentationEvent); - _isProjectileHitEventSubscribed = true; - } - - public void OnDestroy() - { - if (!_isProjectileHitEventSubscribed) - { - return; - } - - var eventComponent = GameEntry.Event; - if (eventComponent != null) - { - eventComponent.Unsubscribe(ProjectileHitPresentationEventArgs.EventId, - OnProjectileHitPresentationEvent); - } - - _isProjectileHitEventSubscribed = false; - } - - public void OnLateUpdate() - { - if (_world == null || !_world.UseSimulationMovement) - { - return; - } - - var enemyManager = GameEntry.EnemyManager; - if (enemyManager == null || enemyManager.Enemies == null) - { - return; - } - - var enemies = enemyManager.Enemies; - foreach (var enemy in enemies) - { - if (enemy is not EnemyBase enemyEntity || !enemyEntity.Available) - { - continue; - } - - if (!_world.TryGetEnemyData(enemyEntity.Id, out EnemySimData enemyData)) - { - continue; - } - - ApplyEnemyPresentation(enemyEntity, enemyData); - } - - if (!_world.UseJobSimulation) - { - return; - } - - var projectiles = _world._projectiles; - for (int i = 0; i < projectiles.Count; i++) - { - ProjectileSimData projectileData = projectiles[i]; - if (!projectileData.Active || projectileData.State != ProjectileStateActive) - { - continue; - } - - EntityBase projectileEntity = TryGetEntityById(projectileData.EntityId); - if (projectileEntity == null || !projectileEntity.Available) - { - continue; - } - - ApplyProjectilePresentation(projectileEntity, projectileData); - } - } - - private void OnProjectileHitPresentationEvent(object sender, GameEventArgs e) - { - if (!_isProjectileHitEventSubscribed || _world == null || - e is not ProjectileHitPresentationEventArgs args) - { - return; - } - - if (args.ShowHitMarker && _world._projectileHitMarkerEnabled) - { - PlayHitMarker(args); - } - - if (args.ShowHitEffect && _world._projectileHitEffectEnabled) - { - PlayHitEffect(args); - } - } - - private void PlayHitMarker(ProjectileHitPresentationEventArgs args) - { - EntityBase targetEntity = TryGetEntityById(args.TargetEntityId); - if (targetEntity == null || !targetEntity.Available) - { - return; - } - - _projectileHitMarkerEffect ??= new HandgunHitMarkerAttackEffect( - Mathf.Max(0.01f, _world._projectileHitMarkerSize), - _world._projectileHitMarkerYOffset, - Mathf.Max(0.01f, _world._projectileHitMarkerDuration), - _world._projectileHitMarkerColor); - _projectileHitMarkerEffect.Play(null, args.HitPosition, targetEntity, 0f); - } - - private void PlayHitEffect(ProjectileHitPresentationEventArgs args) - { - int effectTypeId = args.EffectEntityTypeId > 0 ? args.EffectEntityTypeId : _world._projectileHitEffectTypeId; - if (effectTypeId <= 0) - { - return; - } - - var entityComponent = GameEntry.Entity; - if (entityComponent == null) - { - return; - } - - EffectData effectData = new EffectData(entityComponent.GenerateSerialId(), effectTypeId) - { - Position = args.HitPosition - }; - entityComponent.ShowEffect(effectData); - } - - 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; - } - } - - private static void ApplyProjectilePresentation(EntityBase projectileEntity, - in ProjectileSimData projectileData) - { - Transform projectileTransform = projectileEntity.CachedTransform; - if (projectileTransform == null) - { - return; - } - - projectileTransform.position = projectileData.Position; - Vector3 forward = projectileData.Forward; - if (forward.sqrMagnitude <= float.Epsilon) - { - return; - } - - projectileTransform.rotation = Quaternion.LookRotation(forward.normalized, Vector3.up); - } - } - } -} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs new file mode 100644 index 0000000..523fc75 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs @@ -0,0 +1,319 @@ +using Components; +using Entity; +using Entity.EntityData; +using UnityEngine; + +namespace Simulation +{ + public sealed partial class SimulationWorld + { + #region Simulation State Lifecycle + + public void ClearSimulationState() + { + _enemies.Clear(); + _projectiles.Clear(); + _pickups.Clear(); + _projectileRecycleEntityIds.Clear(); + _projectileResolvedEntityIds.Clear(); + _areaCollisionRequests.Clear(); + _areaCollisionHitEvents.Clear(); + _areaCollisionHitDedupKeys.Clear(); + ClearJobDataChannels(); + + EnemyBinding.Clear(); + ProjectileBinding.Clear(); + PickupBinding.Clear(); + } + + #endregion + + #region Enemy Simulation State + + private int AddEnemy(in EnemySimData simData) + { + int simulationIndex = _enemies.Count; + _enemies.Add(simData); + EnemyBinding.Bind(simData.EntityId, simulationIndex); + OnEnemyAddedToSeparationTemporalBuffers(); + MarkEnemyTargetSpatialIndexDirty(); + return simulationIndex; + } + + private int UpsertEnemy(in EnemySimData simData) + { + if (!EnemyBinding.TryGetSimulationIndex(simData.EntityId, out int simulationIndex)) + { + return AddEnemy(simData); + } + + _enemies[simulationIndex] = simData; + MarkEnemyTargetSpatialIndexDirty(); + 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); + OnEnemyRemovedFromSeparationTemporalBuffers(simulationIndex); + EnemyBinding.UnbindByEntityId(entityId); + MarkEnemyTargetSpatialIndexDirty(); + return true; + } + + private void RegisterEnemyLifecycle(EnemyBase enemy, object userData) + { + if (enemy == null || enemy.CachedTransform == null) + { + return; + } + + EnemyData enemyData = userData as EnemyData; + UpsertEnemy(CreateEnemyInitialSimData(enemy, enemyData)); + } + + private void UnregisterEnemyLifecycle(int entityId) + { + RemoveEnemyByEntityId(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 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; + } + + float attackRange = enemy != null && enemy.AttackRange > 0f + ? enemy.AttackRange + : DefaultAttackRange; + + return new EnemySimData + { + EntityId = enemy.Id, + Position = enemyTransform.position, + Forward = enemyTransform.forward, + Rotation = enemyTransform.rotation, + Speed = speed, + AttackRange = attackRange, + AvoidEnemyOverlap = movementComponent != null && movementComponent.AvoidEnemyOverlap, + EnemyBodyRadius = movementComponent != null ? movementComponent.EnemyBodyRadius : 0.45f, + SeparationIterations = movementComponent != null ? movementComponent.SeparationIterations : 2, + TargetType = 0, + State = EnemyStateIdle + }; + } + + #endregion + + #region Projectile Simulation State + + 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 void RegisterProjectileLifecycle(EntityBase projectileEntity, object userData) + { + if (projectileEntity == null || projectileEntity.CachedTransform == null) + { + return; + } + + UpsertProjectile(CreateProjectileInitialSimData(projectileEntity, userData)); + } + + private void UnregisterProjectileLifecycle(int entityId) + { + RemoveProjectileByEntityId(entityId); + } + + private static ProjectileSimData CreateProjectileInitialSimData(EntityBase projectileEntity, object userData) + { + Vector3 forward = projectileEntity.CachedTransform.forward; + int ownerEntityId = 0; + Vector3 velocity = Vector3.zero; + float speed = 0f; + float lifeTime = 0f; + + if (userData is EnemyProjectileData enemyProjectileData) + { + ownerEntityId = enemyProjectileData.OwnerEntityId; + + Vector3 direction = enemyProjectileData.Direction; + direction.y = 0f; + if (direction.sqrMagnitude > Mathf.Epsilon) + { + direction.Normalize(); + forward = direction; + } + else if (forward.sqrMagnitude > Mathf.Epsilon) + { + forward = forward.normalized; + } + else + { + forward = Vector3.forward; + } + + speed = Mathf.Max(0f, enemyProjectileData.Speed); + velocity = forward * speed; + lifeTime = Mathf.Max(0f, enemyProjectileData.LifeTime); + } + + return new ProjectileSimData + { + EntityId = projectileEntity.Id, + OwnerEntityId = ownerEntityId, + Position = projectileEntity.CachedTransform.position, + Forward = forward, + Velocity = velocity, + Speed = speed, + LifeTime = lifeTime, + Age = 0f, + Active = true, + RemainingLifetime = lifeTime, + State = ProjectileStateActive + }; + } + + #endregion + + #region Pickup Simulation State + + 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; + } + + private void RegisterPickupLifecycle(EntityBase pickupEntity) + { + if (pickupEntity == null || pickupEntity.CachedTransform == null) + { + return; + } + + UpsertPickup(CreatePickupInitialSimData(pickupEntity)); + } + + private void UnregisterPickupLifecycle(int entityId) + { + RemovePickupByEntityId(entityId); + } + + private static PickupSimData CreatePickupInitialSimData(EntityBase pickupEntity) + { + return new PickupSimData + { + EntityId = pickupEntity.Id, + Position = pickupEntity.CachedTransform.position, + PickupRadius = 0.35f, + State = 0 + }; + } + + #endregion + } +} diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs.meta b/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs.meta new file mode 100644 index 0000000..2aadcb6 --- /dev/null +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.SimEntityState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9a3f6ab748524f8f8ab6655e294889f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs index 37bf518..ee75224 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.cs @@ -19,7 +19,7 @@ namespace Simulation return false; } - if (!_useSimulationMovement || !_useJobSimulation) + if (!_useSimulationMovement) { return false; } diff --git a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs index eae40a8..9fc40d4 100644 --- a/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs +++ b/Assets/GameMain/Scripts/Simulation/SimulationWorld.cs @@ -1,10 +1,5 @@ using System.Collections.Generic; -using Components; using CustomDebugger; -using CustomUtility; -using Entity; -using Entity.EntityData; -using Procedure; using UnityEngine; using UnityGameFramework.Runtime; @@ -12,6 +7,18 @@ namespace Simulation { public sealed partial class SimulationWorld : GameFrameworkComponent { + // Partial layout: + // - SimulationWorld.cs: 核心状态、常量和 Unity 生命周期入口点。 + // - SimulationWorld.SimEntityState.cs: 模拟状态的增删改查和生命周期注册。 + // - SimulationWorld.EntitySync.cs: GameFramework 实体 show/hide 事件桥。 + // - SimulationWorld.TargetSelectionSpatialIndex.cs: 最近敌空间索引查询。 + // - Presentation/SimulationWorld.TransformSync.cs: late-update transform 同步桥。 + // - Presentation/SimulationWorld.HitPresentation.cs: 投射物命中事件表现桥。 + // - DataChannel/SimulationWorld.JobDataChannel.cs: 本地 通道/缓冲区 持有者和数据的相互转换。 + // - Jobs/SimulationWorld.EnemyJobs.cs: 模拟通道 编排 + 敌人移动/分离 顺序执行 + // - Jobs/SimulationWorld.ProjectileJobs.cs: 投射物移动与回收 + // - Jobs/SimulationWorld.CollisionPipeline.cs: 碰撞请求的构造、过滤、求解流水线 + // - JobStruct/*.cs: burst job 内核和面向 job 的数据结构 private const float DefaultAttackRange = 1f; private const int EnemyStateIdle = 0; private const int EnemyStateChasing = 1; @@ -19,43 +26,18 @@ namespace Simulation private const int ProjectileStateActive = 0; private const int ProjectileStateExpired = 1; - 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; - } - [Header("模拟世界全局设置")] [Tooltip("是否启用世界模拟")] [SerializeField] - private bool _useSimulationMovement; - - [Tooltip("是否启用 Job 运算路径")] [SerializeField] - private bool _useJobSimulation; - - [Tooltip("是否使用 Burst 来完成计算")] [SerializeField] - private bool _useBurstJobs = true; + private bool _useSimulationMovement = true; private EntitySync _entitySync; - private Presentation _presentation; + private TransformSync _transformSync; + private HitPresentation _hitPresentation; private readonly List _enemies = new List(); private readonly List _projectiles = new List(); private readonly List _pickups = new List(); private readonly List _projectileRecycleEntityIds = new List(); private readonly HashSet _projectileResolvedEntityIds = new HashSet(); - private readonly List _enemySeparationAgents = new List(); - private readonly List _enemyTickWorkItems = new List(); private EntityBinding EnemyBinding { get; } = new EntityBinding(); private EntityBinding ProjectileBinding { get; } = new EntityBinding(); @@ -65,255 +47,22 @@ namespace Simulation public IReadOnlyList Projectiles => _projectiles; public IReadOnlyList Pickups => _pickups; public bool UseSimulationMovement => _useSimulationMovement; - public bool UseJobSimulation => _useJobSimulation; - public bool UseBurstJobs => _useBurstJobs; - public void SetUseSimulationMovement(bool enabled) - { - if (IsBattleStateActive()) - { - Log.Warning("SetUseSimulationMovement is ignored during Battle. Change this switch outside Battle."); - return; - } - - _useSimulationMovement = enabled; - } - - public void SetUseJobSimulation(bool enabled) - { - if (IsBattleStateActive()) - { - Log.Warning("SetUseJobSimulation is ignored during Battle. Change this switch outside Battle."); - return; - } - - _useJobSimulation = enabled; - } - - public void SetUseBurstJobs(bool enabled) - { - _useBurstJobs = enabled; - } - - private static bool IsBattleStateActive() - { - var procedureComponent = GameEntry.Procedure; - if (procedureComponent == null || - procedureComponent.CurrentProcedure is not ProcedureGame procedureGame) - { - return false; - } - - return procedureGame.CurrentGameStateType == GameStateType.Battle; - } + #region Lifecycle protected override void Awake() { base.Awake(); _entitySync = new EntitySync(this); - _presentation = new Presentation(this); + _transformSync = new TransformSync(this); + _hitPresentation = new HitPresentation(this); InitializeJobDataChannels(); } private void Start() { _entitySync?.OnStart(); - _presentation?.OnStart(); - } - - private void OnDestroy() - { - _presentation?.OnDestroy(); - _entitySync?.OnDestroy(); - _entitySync = null; - _presentation = null; - DisposeJobDataChannels(); - } - - private void LateUpdate() - { - _presentation?.OnLateUpdate(); - } - - private int AddEnemy(in EnemySimData simData) - { - int simulationIndex = _enemies.Count; - _enemies.Add(simData); - EnemyBinding.Bind(simData.EntityId, simulationIndex); - OnEnemyAddedToSeparationTemporalBuffers(); - MarkEnemyTargetSpatialIndexDirty(); - return simulationIndex; - } - - private int UpsertEnemy(in EnemySimData simData) - { - if (!EnemyBinding.TryGetSimulationIndex(simData.EntityId, out int simulationIndex)) - { - return AddEnemy(simData); - } - - _enemies[simulationIndex] = simData; - MarkEnemyTargetSpatialIndexDirty(); - 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); - OnEnemyRemovedFromSeparationTemporalBuffers(simulationIndex); - EnemyBinding.UnbindByEntityId(entityId); - MarkEnemyTargetSpatialIndexDirty(); - return true; - } - - private void RegisterEnemyLifecycle(EnemyBase enemy, object userData) - { - if (enemy == null || enemy.CachedTransform == null) - { - return; - } - - EnemyData enemyData = userData as EnemyData; - UpsertEnemy(CreateEnemyInitialSimData(enemy, enemyData)); - } - - private void UnregisterEnemyLifecycle(int entityId) - { - RemoveEnemyByEntityId(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 void RegisterProjectileLifecycle(EntityBase projectileEntity, object userData) - { - if (projectileEntity == null || projectileEntity.CachedTransform == null) - { - return; - } - - UpsertProjectile(CreateProjectileInitialSimData(projectileEntity, userData)); - } - - private void UnregisterProjectileLifecycle(int entityId) - { - RemoveProjectileByEntityId(entityId); - } - - 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; - } - - private void RegisterPickupLifecycle(EntityBase pickupEntity) - { - if (pickupEntity == null || pickupEntity.CachedTransform == null) - { - return; - } - - UpsertPickup(CreatePickupInitialSimData(pickupEntity)); - } - - private void UnregisterPickupLifecycle(int entityId) - { - RemovePickupByEntityId(entityId); + _hitPresentation?.OnStart(); } public void Tick(in SimulationTickContext context) @@ -323,307 +72,27 @@ namespace Simulation return; } - if (_useJobSimulation) - { - using (CustomProfilerMarker.TickEnemies.Auto()) - { - TickEnemiesJobified(in context); - } - - return; - } - using (CustomProfilerMarker.TickEnemies.Auto()) { - TickEnemies(in context); + TickSimulationPipeline(in context); } } - public void Clear() + private void OnDestroy() { - _enemies.Clear(); - _projectiles.Clear(); - _pickups.Clear(); - _projectileRecycleEntityIds.Clear(); - _projectileResolvedEntityIds.Clear(); - _areaCollisionRequests.Clear(); - _areaCollisionHitEvents.Clear(); - _areaCollisionHitDedupKeys.Clear(); - _enemySeparationAgents.Clear(); - _enemyTickWorkItems.Clear(); - ClearJobDataChannels(); - - EnemyBinding.Clear(); - ProjectileBinding.Clear(); - PickupBinding.Clear(); + _hitPresentation?.OnDestroy(); + _entitySync?.OnDestroy(); + _entitySync = null; + _transformSync = null; + _hitPresentation = null; + DisposeJobDataChannels(); } - private void TickEnemies(in SimulationTickContext context) + private void LateUpdate() { - if (_enemies.Count == 0 || context.DeltaTime <= 0f) - { - return; - } - - 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(); - } + _transformSync?.OnLateUpdate(); } - private void BuildEnemyTickInput(in Vector3 playerPosition) - { - _enemyTickWorkItems.Clear(); - _enemySeparationAgents.Clear(); - - for (int i = 0; i < _enemies.Count; i++) - { - EnemySimData enemy = _enemies[i]; - - Vector3 currentPosition = enemy.Position; - 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; - - EnemyTickWorkItem workItem = new EnemyTickWorkItem - { - 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; - } - - Vector3 forward = workItem.ToPlayer.normalized; - Vector3 desiredPosition = workItem.CurrentPosition + forward * workItem.Speed * deltaTime; - - if (workItem.AvoidEnemyOverlap) - { - 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 - { - workItem.NextState = EnemyStateIdle; - } - - _enemyTickWorkItems[i] = workItem; - } - } - - private void WriteBackEnemyTickResults() - { - bool hasPositionChanged = false; - - 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 = workItem.Rotation; - } - - hasPositionChanged = true; - } - - enemy.State = workItem.NextState; - _enemies[i] = enemy; - } - - if (hasPositionChanged) - { - MarkEnemyTargetSpatialIndexDirty(); - } - } - - 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; - } - - float attackRange = enemy != null && enemy.AttackRange > 0f - ? enemy.AttackRange - : DefaultAttackRange; - - return new EnemySimData - { - EntityId = enemy.Id, - Position = enemyTransform.position, - Forward = enemyTransform.forward, - Rotation = enemyTransform.rotation, - Speed = speed, - AttackRange = attackRange, - 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, object userData) - { - Vector3 forward = projectileEntity.CachedTransform.forward; - int ownerEntityId = 0; - Vector3 velocity = Vector3.zero; - float speed = 0f; - float lifeTime = 0f; - - if (userData is EnemyProjectileData enemyProjectileData) - { - ownerEntityId = enemyProjectileData.OwnerEntityId; - - Vector3 direction = enemyProjectileData.Direction; - direction.y = 0f; - if (direction.sqrMagnitude > Mathf.Epsilon) - { - direction.Normalize(); - forward = direction; - } - else if (forward.sqrMagnitude > Mathf.Epsilon) - { - forward = forward.normalized; - } - else - { - forward = Vector3.forward; - } - - speed = Mathf.Max(0f, enemyProjectileData.Speed); - velocity = forward * speed; - lifeTime = Mathf.Max(0f, enemyProjectileData.LifeTime); - } - - return new ProjectileSimData - { - EntityId = projectileEntity.Id, - OwnerEntityId = ownerEntityId, - Position = projectileEntity.CachedTransform.position, - Forward = forward, - Velocity = velocity, - Speed = speed, - LifeTime = lifeTime, - Age = 0f, - Active = true, - RemainingLifetime = lifeTime, - State = ProjectileStateActive - }; - } + #endregion } -} +} \ No newline at end of file diff --git a/Assets/Resources/DOTweenSettings.asset b/Assets/Resources/DOTweenSettings.asset index 62ebbaf..4b23505 100644 --- a/Assets/Resources/DOTweenSettings.asset +++ b/Assets/Resources/DOTweenSettings.asset @@ -24,7 +24,7 @@ MonoBehaviour: showUnityEditorReport: 0 logBehaviour: 0 drawGizmos: 1 - defaultRecyclable: 0 + defaultRecyclable: 1 defaultAutoPlay: 3 defaultUpdateType: 0 defaultTimeScaleIndependent: 0 diff --git a/Assets/StreamingAssets/GameData.dat b/Assets/StreamingAssets/GameData.dat index 0589cae..8f4593c 100644 Binary files a/Assets/StreamingAssets/GameData.dat and b/Assets/StreamingAssets/GameData.dat differ diff --git a/Assets/StreamingAssets/GameFrameworkVersion.dat b/Assets/StreamingAssets/GameFrameworkVersion.dat index bb98210..f994c95 100644 Binary files a/Assets/StreamingAssets/GameFrameworkVersion.dat and b/Assets/StreamingAssets/GameFrameworkVersion.dat differ diff --git a/Assets/StreamingAssets/Resources.dat b/Assets/StreamingAssets/Resources.dat index 3cc12ac..e495033 100644 Binary files a/Assets/StreamingAssets/Resources.dat and b/Assets/StreamingAssets/Resources.dat differ diff --git a/Assets/StreamingAssets/UI.dat b/Assets/StreamingAssets/UI.dat index 746fbb9..dc63fc2 100644 Binary files a/Assets/StreamingAssets/UI.dat and b/Assets/StreamingAssets/UI.dat differ diff --git a/Assets/StreamingAssets/UI/UIItems.dat b/Assets/StreamingAssets/UI/UIItems.dat index 78fb5d7..60d9179 100644 Binary files a/Assets/StreamingAssets/UI/UIItems.dat and b/Assets/StreamingAssets/UI/UIItems.dat differ diff --git a/docs/P2 Job System + Burst 落地.md b/docs/P2 Job System + Burst 落地.md new file mode 100644 index 0000000..5597248 --- /dev/null +++ b/docs/P2 Job System + Burst 落地.md @@ -0,0 +1,111 @@ +# P2 Job System + Burst 落地(结项与验收) + +## 1. 文档目的 +本文件用于对齐 `docs/TodoList.md` 的 P2 Checkpoint 9,作为 P2 结项与 P3 输入基线。 + +目标: +- 固化压测口径(1k/2k/3k) +- 给出回归验证结论 +- 给出开关/回滚策略 +- 给出最终验收判定(通过/不通过) + +## 2. 验收标准(对齐 TodoList) +来源:`docs/TodoList.md` 第 171~179 行。 + +- 在 `3k` 敌人规模下,CPU Main Thread 明显下降(目标 `>= 30%`)。 +- Profiler 中战斗帧 `GC Alloc` 接近 `0`(持续帧)。 + +## 3. 测试设备与环境 +- 设备:iQOO Neo8 +- CPU:第一代骁龙 8+ +- 内存:12 GB +- 系统:OriginOS 6(Android 16) +- Profiler 口径:以 CPU `ms` 为主,`fps` 仅作辅助(Android 端存在 60fps 上限) +- Profiler 配置:`Call Stacks = Off` + +## 4. P2 开关与回滚策略 + +### 4.1 运行开关 +- `UseSimulationMovement` +- `UseJobSimulation` +- `UseBurstJobs` + +### 4.2 生效时机约束 +- `UseSimulationMovement` / `UseJobSimulation`:战斗内不支持热切换,需在 Battle 外修改后生效。 +- `UseBurstJobs`:可切换,但建议仅用于战斗外 A/B。 + +### 4.3 回滚策略(建议) +1. 切回非 Job 路径:`UseJobSimulation = false` +2. 若仍异常,切回旧移动:`UseSimulationMovement = false` +3. 保留 `UseBurstJobs` 仅在 Job 路径 A/B 对照 + +## 5. 回归验证(Checkpoint 9) + +| 用例 | 目标 | 状态 | 证据 | +|------------------------------------------|--------------|----|----| +| 10 分钟连续战斗 | 无异常日志、流程稳定 | 待补 | 待补 | +| `Battle -> LevelUp -> Shop -> Battle` 循环 | 状态切换稳定、无卡死 | 待补 | 待补 | +| 掉落拾取链路 | 掉落生成/吸附/回收正常 | 待补 | 待补 | + +建议附证据: +- `Logs/playmode-tests.log` +- 关键流程录屏/截图 +- 回归脚本或人工步骤说明 + +## 6. 压测口径与数据 + +### 6.1 标准口径(必须覆盖) +- 敌人规模:`1k / 2k / 3k` +- 指标: + - Main Thread (`ms`) + - Job Workers (`ms`) + - GC Alloc (`B/frame`) + - 关键 Marker(`BuildInput / MoveSeparation / Complete / WriteBack`) + +### 6.2 当前已测数据(你提供) + +#### CPU 分阶段数据(P2) +| 指标 | `500 enemies` | `1000 enemies` | `1500 enemies` | `2000 enemies` | +|----------------|--------------------:|--------------------:|--------------------:|--------------------:| +| 帧率 | 62.6 fps (15.96 ms) | 52.6 fps (19.00 ms) | 35.0 fps (28.56 ms) | 24.9 fps (40.05 ms) | +| BuildInput | 0.28 ms | 0.58 ms | 0.88 ms | 1.13 ms | +| MoveSeparation | 0.38 ms | 0.94 ms | 1.59 ms | 2.48 ms | +| StateUpdate | 0.01 ms | 0.01 ms | 0.01 ms | 0.01 ms | +| Schedule | 0.00 ms | 0.00 ms | 0.00 ms | 0.00 ms | +| Complete | 0.45 ms | 1.20 ms | 1.86 ms | 3.79 ms | +| WriteBack | 0.15 ms | 0.31 ms | 1.20 ms | 2.00 ms | + +#### CPU 热路径对比(P1.5 -> P2) +说明:P2 以六阶段总和近似对齐 P1.5 四阶段 `TickEnemies ms`。 + +| 敌人数量 | P1.5 TickEnemies | P2 TickEnemies | 降幅 | +|--------|-----------------:|---------------:|-------:| +| `500` | 4.77 ms | 1.30 ms | -72.7% | +| `1000` | 9.86 ms | 3.06 ms | -68.9% | +| `1500` | 15.42 ms | 5.57 ms | -63.8% | +| `2000` | 21.68 ms | 9.44 ms | -56.4% | + +## 7. 验收判定 + +| 验收项 | 标准 | 当前状态 | 判定 | +|--------------------|----------|----------|-----| +| Main Thread 降幅(2k) | `>= 30%` | 缺失 3k 数据 | 不通过 | +| 持续帧 GC Alloc | 接近 0 | 缺失 GC 数据 | 不通过 | + +**当前结论:P2 Checkpoint 9 暂不通过。** + +可确认部分: +- P2 在 `500~2000` 规模的热路径 CPU 优化已显著成立。 +- 但未满足 TodoList 的完整验收口径(3k + GC + 回归证据)。 + +## 8. 下一步补齐动作(建议) +1. 按同一场景补采 `3k` 数据(P1.5 与 P2 各一次,至少 60s 稳态窗口)。 +2. 记录 `Main Thread / Job Workers / GC Alloc` 三项,写入 6.3 对应表。 +3. 完成 5.0 的三个回归用例并填入证据。 +4. 补齐后将第 7 节判定更新为“通过”,再在 `TodoList.md` 把 P2 Checkpoint 9 勾选。 + +## 9. 测试命令(复用) +- PlayMode: + - `Unity -batchmode -nographics -projectPath . -runTests -testPlatform PlayMode -testResults Logs/playmode-test-results.xml -logFile Logs/playmode-tests.log` +- EditMode: + - `Unity -batchmode -nographics -projectPath . -runTests -testPlatform EditMode -testResults Logs/editmode-test-results.xml -logFile Logs/editmode-tests.log` diff --git a/docs/TodoList.md b/docs/TodoList.md index a6f46e4..4b18535 100644 --- a/docs/TodoList.md +++ b/docs/TodoList.md @@ -10,7 +10,7 @@ ## 1. P0 基线修正与性能基准 - [x] 建立性能基准场景(建议复用 `Game.unity` + 压测参数): - - 指标:`1k / 2k / 3k` 敌人时的 FPS、CPU Main Thread、GC Alloc、Draw Calls。 + - 指标:`0.5k / 1k / 1.5k / 2k` 敌人时的 FPS、CPU Main Thread、GC Alloc、Draw Calls。 - 输出:一份基线表格(开发机配置 + Unity Profiler 截图)。 - [x] 修正当前高风险逻辑问题(避免后续优化建立在不稳定行为上): - `ProcedureGame.OnEnter()` 与 `_hudInitialized` 逻辑中有重复初始化状态机风险(`InitGameState()` 被调用两次)。 @@ -62,7 +62,7 @@ - [x] Checkpoint 7:P1 阶段回归与性能记录 - 回归用例:战斗 10 分钟、`Battle -> LevelUp -> Shop -> Battle` 循环、掉落吸附与拾取。 - - Profiling 对比:记录 1k/2k/3k 敌人下 Main Thread、GC Alloc、敌人更新耗时。 + - Profiling 对比:记录 `0.5k / 1k / 1.5k / 2k` 敌人下 Main Thread、GC Alloc、敌人更新耗时。 - 输出文档:`P1 Simulation 分层设计 + 回滚开关说明 + 对比数据`。 - 完成标准:核心流程稳定,无新增 Error/Exception;可一键回滚到旧更新路径。 @@ -74,7 +74,7 @@ - 目标:将 `TickEnemies GC` 从当前 `27~108 KB` 降到 `< 5 KB / frame`。 - 重点文件:`Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`。 - 处理方式:桶容器与临时列表复用(包含 bucket list 复用池),避免每帧重建集合。 - - 完成标准:`2000` 敌人压测下 `TickEnemies GC` 稳定 `< 5 KB / frame`。 + - 完成标准:`2k` 敌人压测下 `TickEnemies GC` 稳定 `< 5 KB / frame`。 - [x] Checkpoint 2:解耦 Simulation 核心与 `Transform` 运行时依赖 - 目标:`SimulationWorld.TickEnemies` 不直接读取或写入 `Transform`。 @@ -142,7 +142,7 @@ - 构建分桶(Build Buckets) - 邻域候选查询(Query Neighbors) - 避免全量最近邻搜索,控制复杂度随敌人数增长的斜率。 - - 完成标准:`3k` 敌人下目标选择阶段耗时稳定,且无索引越界/漏目标回归。 + - 完成标准:`2k` 敌人下目标选择阶段耗时稳定,且无索引越界/漏目标回归。 - [x] Checkpoint 5:投射物批量移动与寿命回收 Job 化 - 投射物数据结构最少包含:`position/velocity/lifeTime/age/active`。 @@ -170,12 +170,12 @@ - [ ] Checkpoint 9:P2 回归、压测与结项文档 - 回归用例:10 分钟战斗、`Battle -> LevelUp -> Shop -> Battle` 循环、掉落拾取链路。 - - 压测口径:`1k/2k/3k` 敌人,记录 Main Thread、Job Workers、GC Alloc、关键 Marker。 + - 压测口径:`0.5k / 1k / 1.5k / 2k` 敌人,记录 Main Thread、Job Workers、GC Alloc、关键 Marker。 - 输出文档:`P2 Job/Burst 改造说明 + 开关/回滚策略 + 前后对比数据`。 - 完成标准:结论可复现,可作为 P3 GPU Instancing 的输入基线。 **验收标准** -- 在 3k 敌人规模下,CPU Main Thread 明显下降(目标 >= 30%)。 +- 在 2k 敌人规模下,CPU Main Thread 明显下降(目标 >= 30%)。 - Profiler 中战斗帧 GC Alloc 接近 0(持续帧)。 ## 4. P3 GPU Instancing 渲染管线(与 Job 并行推进) diff --git a/docs/UI-5层架构设计规范.md b/docs/UI-5层架构设计规范.md new file mode 100644 index 0000000..bc88819 --- /dev/null +++ b/docs/UI-5层架构设计规范.md @@ -0,0 +1,192 @@ +# UI 五层架构设计规范(RawData / Controller / View / Context / UseCase) + +## 1. 适用范围 + +- 适用目录:`Assets/GameMain/Scripts/UI/*` +- 重点对象:采用五层拆分的 UI 模块(`MenuScene`、`GameScene`、`General` 下的分层 UI) +- 本文不展开 Unity GameFramework 底层实现细节,仅约束项目内 UI 代码组织与协作方式 + +## 2. 架构总览 + +UI 模块采用“输入数据 -> 业务编排 -> 展示数据 -> 渲染表现”的分层方式,核心链路如下: + +1. 外部流程(Procedure/GameState)创建并绑定 UseCase +2. 通过 `GameEntry.UIRouter` 打开指定 UI +3. Controller 从 UseCase 取 RawData,并转换为 Context +4. View 使用 Context 渲染 +5. View 通过事件回传交互,Controller 处理后驱动 UseCase 更新,再刷新 View + +简化关系图: + +```text +Procedure/GameState + -> UIRouter + -> Controller <-> UseCase + -> Context -> View +View --(CustomEvent)--> Controller +``` + +## 3. 五层职责定义 + +### 3.1 RawData 层 + +职责:承载“业务原始数据”,作为 UseCase 到 Controller 的传输模型。 + +约束: + +- 命名:`XXXFormRawData` +- 只描述数据,不包含 UI 渲染行为 +- 可保留领域对象或数据表对象(例如 `DRLevelUpReward`、`WeaponBase`) +- 不依赖具体 View 组件 + +参考: + +- `Assets/GameMain/Scripts/UI/GameScene/RawData/ShopFormRawData.cs` +- `Assets/GameMain/Scripts/UI/GameScene/RawData/LevelUpFormRawData.cs` +- `Assets/GameMain/Scripts/UI/MenuScene/RawData/SelectRoleFormRawData.cs` + +### 3.2 UseCase 层 + +职责:封装 UI 对应业务用例,负责业务规则、状态推进、数据生成。 + +约束: + +- 实现 `IUIUseCase` +- 命名:`XXXFormUseCase` +- 对外提供 `CreateInitialModel / TryRefresh / Select / Confirm` 等语义化方法 +- 返回 RawData(或结果对象),不直接操作具体 View + +参考: + +- `Assets/GameMain/Scripts/UI/GameScene/UseCase/ShopFormUseCase.cs` +- `Assets/GameMain/Scripts/UI/GameScene/UseCase/LevelUpFormUseCase.cs` +- `Assets/GameMain/Scripts/UI/MenuScene/UseCase/SelectRoleFormUseCase.cs` + +### 3.3 Controller 层 + +职责:UI 编排层,连接 UseCase 与 View,管理 UI 生命周期、事件订阅、数据转换。 + +约束: + +- 继承 `UIFormControllerCommonBase` +- 命名:`XXXFormController` +- 通过 `BindUseCase(IUIUseCase)` 注入用例并做类型校验 +- `OpenUI(object userData = null)` 支持:`Context`、`RawData`、`null` +- 负责 RawData -> Context 的转换(常见 `BuildContext`) +- 在 `SubscribeCustomEvents / UnsubscribeCustomEvents` 成对管理事件 +- 可做局部刷新(避免整窗重建) + +参考: + +- `Assets/GameMain/Scripts/UI/GameScene/Controller/ShopFormController.cs` +- `Assets/GameMain/Scripts/UI/GameScene/Controller/LevelUpFormController.cs` +- `Assets/GameMain/Scripts/UI/MenuScene/Controller/SelectRoleFormController.cs` + +### 3.4 Context 层 + +职责:承载“可直接驱动 UI 展示”的上下文数据。 + +约束: + +- 继承 `UIContext` +- 命名:`XXXFormContext` 或 `XXXItemContext` +- 字段以展示友好为目标(标题、描述、图标、稀有度、列表等) +- 允许组合子 Context(例如列表区 + 条目) + +参考: + +- `Assets/GameMain/Scripts/UI/GameScene/Context/ShopFormContext.cs` +- `Assets/GameMain/Scripts/UI/GameScene/Context/DisplayListAreaContext.cs` +- `Assets/GameMain/Scripts/UI/MenuScene/Context/SelectRoleFormContext.cs` + +### 3.5 View 层 + +职责:纯表现层,负责控件绑定、显示刷新、交互事件抛出。 + +约束: + +- Form 类继承 `UGuiForm`,子组件通常继承 `MonoBehaviour` +- 命名:`XXXForm` / `XXXItem` / `XXXArea` +- 提供 `RefreshUI(Context)`、`OnInit(Context)`、`OnReset()` 等渲染入口 +- 用户交互通过 `GameEntry.Event.Fire(...)` 通知 Controller +- 不承载业务规则(计算、流程推进、数据筛选应在 UseCase) + +参考: + +- `Assets/GameMain/Scripts/UI/GameScene/View/ShopForm.cs` +- `Assets/GameMain/Scripts/UI/GameScene/View/DisplayListArea.cs` +- `Assets/GameMain/Scripts/UI/MenuScene/View/SelectRoleForm.cs` + +## 4. 标准交互流程 + +### 4.1 初始化与绑定 + +1. Procedure/GameState 创建 UseCase +2. 调用 `GameEntry.UIRouter.BindUIUseCase(UIFormType.X, useCase)` + +示例: + +- `Assets/GameMain/Scripts/Procedure/Game/GameStateShop.cs` +- `Assets/GameMain/Scripts/Procedure/Game/GameStateLevelUp.cs` +- `Assets/GameMain/Scripts/Procedure/ProcedureStartMenu.cs` + +### 4.2 打开 UI + +1. 调用 `GameEntry.UIRouter.OpenUI(UIFormType.X)` +2. Controller 从 UseCase 取 RawData(或接收外部 RawData/Context) +3. Controller 构建 Context 后打开/刷新 Form +4. View 在 `OnOpen` 中校验 Context 类型并执行 `RefreshUI` + +### 4.3 用户交互到刷新 + +1. View 触发事件(如购买、刷新、选择) +2. Controller 监听事件并调用 UseCase +3. UseCase 返回新数据或操作结果 +4. Controller 更新 Context 并刷新全部或局部 UI + +### 4.4 关闭 UI + +1. 调用 `GameEntry.UIRouter.CloseUI(UIFormType.X)` +2. Controller 解除事件订阅并关闭窗体 +3. View `OnClose` 清理本地状态 + +## 5. 目录与命名规范 + +- 目录:`UI//RawData|UseCase|Controller|Context|View` +- 五层同名前缀保持一致:`ShopForm*`、`LevelUpForm*`、`SelectRoleForm*` +- 子组件上下文命名:`RoleItemContext`、`DisplayItemContext`、`LevelUpRewardItemContext` +- 新增 UI Form 时优先建立完整五层;仅纯静态展示可降级为 View-only + +## 6. 依赖方向约束 + +允许依赖: + +- `UseCase -> RawData / 领域对象` +- `Controller -> UseCase + RawData + Context + View + Event` +- `View -> Context + Event` + +禁止依赖: + +- `View -> UseCase` +- `View -> 领域状态修改` +- `Context/RawData -> View` + +## 7. 新增一个五层 UI 的落地步骤 + +1. 在目标场景目录创建 `RawData / UseCase / Context / Controller / View` 对应类型 +2. 在 UseCase 中实现模型创建与交互方法 +3. 在 Controller 中实现 `BindUseCase`、`OpenUI`、`BuildContext`、事件订阅 +4. 在 View 中实现 `RefreshUI` 和交互事件抛出 +5. 在对应 Procedure/GameState 里完成 UseCase 绑定与 Open/Close 调用 +6. 自测三条主链路:首次打开、交互刷新、关闭重开 + +## 8. 项目当前实践说明 + +- `ShopForm`、`LevelUpForm`、`SelectRoleForm` 是当前五层模式的主要样板 +- `DialogForm` 也有 Controller/Context/RawData,但 UseCase 为可选 +- `HudForm`、`StartMenuForm` 当前为轻用例场景,可不强制 UseCase +- `SettingForm`、`AboutForm` 属于历史直连型 UI,不属于五层完整样板 + +--- + +如后续需要统一重构,建议优先把历史直连型 UI(如 `SettingForm`)迁移到五层模板,以降低 UI 逻辑耦合度。 diff --git a/skills/simulation-development/SKILL.md b/skills/simulation-development/SKILL.md index 1a143a3..168c132 100644 --- a/skills/simulation-development/SKILL.md +++ b/skills/simulation-development/SKILL.md @@ -1,40 +1,46 @@ --- 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. +description: Maintain and extend VampireLike SimulationWorld (P2 baseline). Use for Simulation data contracts, lifecycle sync, Job/Burst pipeline, collision settlement, and rollback-safe runtime switches. --- # 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`) +1. Read the design spec first: `./references/SimulationDevelopmentSkill.md`. +2. If performance conclusions change, sync evidence to `../../docs/P2 Job System + Burst 落地.md`. +3. Classify change scope before coding: + - `SimData/JobData` contracts - lifecycle sync (`SimulationWorld.EntitySync`) - - per-frame simulation (`SimulationWorld.Tick`, `TickEnemies`) + - Job/Burst execution pipeline (`SimulationWorld.EnemyJobs`, `SimulationWorld.ProjectileJobs`) + - collision query/settlement semantics - presentation write-back (`SimulationWorld.Presentation`) - - enemy separation solver integration (`EnemySeparationSolverProvider`) -4. Keep rollback path available through `UseSimulationMovement`. +4. Decide rollback behavior up front: + - `UseSimulationMovement` off path + - `UseJobSimulation` off path +5. Add/adjust both EditMode and PlayMode regression tests. ## Source Map - Simulation core: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.cs` +- Job data channel: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.JobDataChannel.cs` +- Enemy jobs: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.EnemyJobs.cs` +- Projectile jobs: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.ProjectileJobs.cs` - Lifecycle sync: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs` - Presentation sync: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs` +- Target selection index: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.TargetSelectionSpatialIndex.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` +- Battle state gate: `../../Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs` +- Damage/collision utility: `../../Assets/GameMain/Scripts/Utility/AIUtility.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` +- Regression tests: + - `../../Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs` + - `../../Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs` ## Non-Negotiable Invariants @@ -44,46 +50,53 @@ description: Maintain and extend the VampireLike Simulation layer. Use when modi - Keep logic/presentation boundary: - Simulation computes logical outputs. - Presentation writes back `Transform`. -- Keep A/B rollback path: - - `UseSimulationMovement == false` must preserve old behavior path. +- Keep A/B rollback path (`UseSimulationMovement`/`UseJobSimulation`). +- `SetUseSimulationMovement` and `SetUseJobSimulation` must not hot-switch during `Battle`. +- Keep area query snapshot semantics (`SourceWasActiveAtQueryTime`) intact. +- Keep dodge semantics using `Value` (additive), not `Percent`. - 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. +1. Update target structs in `Simulation/SimData/` and Job channel structs. +2. Populate defaults in `Create*InitialSimData` / lifecycle registration path. +3. Apply runtime updates in simulation stages. +4. Consume visual fields in `Presentation` only. 5. Ensure backward compatibility when `UseSimulationMovement` is off. -### Extend Enemy Tick Behavior +### Extend Job/Burst Pipeline -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). +1. Keep deterministic stage ownership (Build/Schedule/Complete/Commit). +2. Preserve state semantics; avoid UI/audio/effect side effects in simulation loops. +3. Keep `ProfilerMarker` coverage for new or changed stages. +4. Keep hot paths data-driven (no direct `Transform` reads/writes). -### Implement Projectile/Pickup Tick (from placeholder to real behavior) +### Modify Collision / Area Query 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. +1. Treat broad phase candidate generation and main-thread settlement as separate steps. +2. Preserve `MaxTargets` semantics across player + enemy candidates. +3. If adding query metadata, flow it through: + - request buffer -> collision query input -> candidate -> settlement. +4. Keep area-source snapshot behavior and avoid runtime-state race regressions. -### Refactor Toward Job/Burst +### Add or Adjust Runtime Switches -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. +1. Define exact effective timing (`Battle` or out-of-`Battle`) before implementation. +2. For high-risk switches, enforce out-of-battle-only changes. +3. Provide clear warning logs for ignored runtime switch attempts. ## Validation Checklist - `UseSimulationMovement = false` and `true` both run correctly. +- `UseJobSimulation = false` and `true` both run correctly under simulation mode. - 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`. +- No new per-frame GC spikes in hot paths. - 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. +- Keep these regression tests green in both EditMode and PlayMode: + - `TickProjectiles_LimitsCandidatesToMaxTargets_IncludingPlayerCandidate` + - `SetUseSimulationAndJob_AreIgnored_WhenBattleStateIsActive` + - `EnqueueAreaQuery_CapturesInactiveSourceSnapshot_WhenSourceEntityUnavailable` +- Update `./references/SimulationDevelopmentSkill.md` when contracts, boundaries, or rules change. diff --git a/skills/simulation-development/references/SimulationDevelopmentSkill.md b/skills/simulation-development/references/SimulationDevelopmentSkill.md index c45f4fe..7521492 100644 --- a/skills/simulation-development/references/SimulationDevelopmentSkill.md +++ b/skills/simulation-development/references/SimulationDevelopmentSkill.md @@ -1,159 +1,200 @@ -# Simulation Development Skill(VampireLike) +# Simulation Development Skill (VampireLike) ## 目标 -本文件是 `Simulation` 分层的开发规范与速查手册。 -后续调整敌人移动、补齐投射物/掉落物逻辑、推进 Job/Burst 改造时,优先按本文档执行,避免反复通读全部代码。 +本文件是 SimulationWorld 的正式设计说明和扩展开发规范。 +后续在 Simulation 相关模块做功能扩展、性能优化、回归修复时,统一按本规范执行。 -## 当前架构总览(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`) +## 适用范围 +- Assets/GameMain/Scripts/Simulation/* +- Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs +- Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs +- Assets/GameMain/Scripts/Utility/AIUtility.cs +- Assets/Tests/Simulation/EditMode/* +- Assets/Tests/Simulation/PlayMode/* -## 运行时主链路(按帧) -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` +当前状态:P2 Job/Burst 主体已完成,SimulationWorld 已是战斗核心调度层。 -## 生命周期与数据同步设计 -`EntitySync` 通过事件驱动保持 Simulation 容器与实体生命周期一致: +## 模块分层 +SimulationWorld 使用 partial 拆分,职责如下: -| 事件 | 组名 | 行为 | -|-------------------------------|-------------------------|----------------------------------------------------------------| -| `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` | +- SimulationWorld.cs + - 开关、主容器、绑定、主 Tick 入口。 +- SimulationWorld.EntitySync.cs + - 实体 Show/Hide 到仿真容器的生命周期同步。 +- SimulationWorld.EnemyJobs.cs + - 敌人移动和互斥分离的 Job/Burst 链路。 +- SimulationWorld.ProjectileJobs.cs + - 投射物移动、寿命、碰撞候选、主线程结算。 +- SimulationWorld.JobDataChannel.cs + - Native 容器、拷贝转换、容量管理、运行时统计。 +- SimulationWorld.TargetSelectionSpatialIndex.cs + - 目标选择空间索引(最近敌人查询)。 +- SimulationWorld.Presentation.cs + - 表现层写回(Transform)和命中表现事件消费。 -关键规则: -- 删除容器元素统一使用“末尾覆盖 + `RemoveAt(lastIndex)` + `EntityBinding.RemapIndex`”。 -- `Upsert` 语义:`EntityId` 已存在则覆盖,不存在则追加。 +## 运行时执行链路 +1. GameStateBattle.OnUpdate +- 先执行 EnemyManager.OnUpdate +- 再执行 SimulationWorld.Tick -## EnemySimData 合约(当前实现) -文件:`Assets/GameMain/Scripts/Simulation/SimData/EnemySimData.cs` +2. SimulationWorld.Tick +- UseSimulationMovement = false:直接返回(完全回退旧链路) +- UseSimulationMovement = true 且 UseJobSimulation = false:走主线程敌人仿真 +- UseSimulationMovement = true 且 UseJobSimulation = true:走 Job/Burst 总链路 -- `EntityId`:实体唯一标识 -- `Position / Forward / Rotation`:逻辑输出与表现层写回字段 -- `Speed`:来自 `EnemyData.SpeedBase` -- `AttackRange`:当前固定初始化为 `1f` -- `AvoidEnemyOverlap / EnemyBodyRadius / SeparationIterations`:从 `MovementComponent` 读取 -- `TargetType / State`:状态扩展预留 +3. SimulationWorld.LateUpdate +- 调用 Presentation.OnLateUpdate +- 统一写回 Enemy/Projectile 表现 -当前状态值(`SimulationWorld` 常量): -- `0`:Idle -- `1`:Chasing -- `2`:InAttackRange +## 核心数据契约 +### 主容器 +- List _enemies +- List _projectiles +- List _pickups -## TickEnemies 当前算法(P1.5 分阶段) -文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.cs` +### 绑定关系 +- EntityBinding 维护 EntityId <-> SimulationIndex 双向映射。 +- 删除必须使用 swap-back: + - 尾元素覆盖删除位 + - RemapIndex + - RemoveAt(last) -`TickEnemies` 入口保持纯数据热路径,不直接读写 `Transform`,按四阶段执行: -1. `BuildInput` - - 计算到玩家平面距离 - - 产出 `EnemyTickWorkItem` - - 生成分离输入 `EnemySeparationAgent` -2. `Move/Separation` - - 计算追踪位移与朝向 - - 通过 `EnemySeparationSolverProvider.ResolveSimulation(...)` 做互斥求解 -3. `StateUpdate` - - 按距离与可追逐状态更新 `Idle/Chasing/InAttackRange` -4. `WriteBack` - - 回写 `EnemySimData`(`Position/Forward/Rotation/State`) +### Job 通道 +- EnemyJobInput/Output +- ProjectileJobInput/Output +- CollisionQuery/CollisionCandidate +- NativeParallelMultiHashMap(互斥桶、碰撞桶、目标桶) -## 互斥求解器双通道(Legacy + Simulation) -文件: -- `Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs` -- `Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs` -- `Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs` +统一规则: +- Allocator.Persistent 分配 +- Initialize/Dispose 集中管理 +- Clear 只清容器,不破坏生命周期 -说明: -- `SetSimulationAgents/ResolveSimulation`:供 Simulation 纯数据路径调用。 -- `Register/Unregister/Resolve(Transform, ...)`:保留旧路径兼容与回滚能力。 -- `GridBucketEnemySeparationSolver` 已加入桶列表复用池(`_bucketListPool`)以降低 GC。 +## 不可破坏的设计约束 +### 生命周期单入口 +仿真容器增删只能由 EntitySync 驱动。 +禁止在 Enemy/Weapon/Projectile 业务代码中直接改仿真容器。 -## 表现层回写(Presentation)规则 -文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs` +### 逻辑与表现边界 +- Simulation 只产出逻辑数据,不直接写 Transform。 +- Transform 写回只能在 Presentation。 +- 命中表现通过事件缓冲在主线程提交。 -- 仅在 `UseSimulationMovement == true` 时执行 -- 遍历 `EnemyManager.Enemies`,按 `EntityId` 查找 `EnemySimData` -- 写回顺序: - - 始终写回 `position` - - 优先使用 `rotation` - - 若 `rotation` 无效,则回退到 `forward` +### 开关和回滚 +- UseSimulationMovement:总开关,支持一键回滚。 +- UseJobSimulation:Job 开关,支持 P1.5/ P2 对照。 +- UseBurstJobs:Burst 开关。 -## 与旧移动系统的关系(重要) -- `MeleeEnemy` / `RemoteEnemy` 在 `OnUpdate` 开头门控: - - 开启 Simulation:直接 `return` - - 关闭 Simulation:走旧 `MovementComponent` -- 回滚能力来自同一构建内的 `UseSimulationMovement` A/B 开关。 +### 生效时机约束(重要) +- SetUseSimulationMovement / SetUseJobSimulation 在 Battle 中会被忽略。 +- 这两个开关不支持战斗内热切换,只允许战斗外修改生效。 -## Projectile / Pickup 现状 -- `ProjectileSimData`、`PickupSimData` 已具备容器、绑定与生命周期同步通道 -- 当前仍未接入独立 Tick 行为,仅完成“创建/回收/索引同步”占位目标 +## 敌人和投射物执行模型 +### 敌人(Job) +固定阶段: +- BuildInput +- Move +- Separation +- Commit -## P1.5 实测基线(P2 输入) -基线文档:`docs/P1.5 Simulation-Supplement.md` +约束: +- 热路径禁止 LINQ 和托管分配 +- 不读写 Transform +- 阶段必须可独立 Profile -关键结论: -- `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 上限影响 +### 投射物(Job) +包含: +- 移动更新 +- 寿命和越界回收 +- Broad Phase 候选构建 +- 主线程命中结算和回收 -## 自动化回归(P1.5 已补) -目录: - - `Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs` - - `Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs` +## 碰撞和伤害结算规范 +### Broad Phase +候选由 _collisionQueryInputs + _enemyCollisionBuckets 计算。 +MaxTargets 必须覆盖“玩家候选 + 敌人候选”的总量。 -覆盖点: -- 敌人追踪玩家 -- 进入攻击距离后停止移动 -- 实体移除后的索引 remap 稳定性 +### Area Query 快照语义 +- 入队时记录 SourceWasActiveAtQueryTime。 +- 结算按快照判定来源有效性,避免查询后状态变化导致误判。 -## 后续扩展规范(必须遵守) -1. 先扩数据,再扩行为 -先在 `SimData` 增字段,再改 `EntitySync` 初始化与 `Tick` 逻辑,最后改表现层消费。 +### 主线程结算 +- Projectile 命中:按 ImpactData + AIUtility.CalcDamageHP。 +- Area 命中:调用 AIUtility.PerformCollision(target, source, true)。 -2. 保留 A/B 路径 -任何迁移都必须可在同一构建内通过开关回退到旧路径。 +### 伤害公式约束 +AIUtility.CalcDamageHP: +- 闪避使用 dodgeStat.Value(加算语义),不使用 Percent。 +- 攻击: (attack + AttackStat.Value) * AttackStat.Percent +- 防御: (damage - DefenseStat.Value) / DefenseStat.Percent +- 最终伤害最小值为 1。 -3. 生命周期只走 EntitySync -禁止在敌人业务代码中手动改写 Simulation 容器,避免双写导致索引错乱。 +## 已修复问题(纳入长期约束) +1. 闪避语义修正:使用 Value 而非 Percent。 +2. UseSimulationMovement / UseJobSimulation 战斗内禁止热切换。 +3. MaxTargets 统计覆盖玩家候选,避免超额候选。 +4. Area 查询引入来源活跃快照并按快照结算。 -4. 维持“逻辑输出 / 表现消费”边界 -Simulation 只产出逻辑结果,不直接触发 UI、特效、音频事件。 +后续改动若触碰这些路径,必须保持行为不回退。 -5. 删除策略统一用 swap-back -所有 Simulation 容器删除都必须 remap 索引,严禁 `RemoveAt(i)` 直接删中间项。 +## 扩展开发 SOP +### Step 0:定义模式和回滚 +- 明确功能在哪条路径生效(Simulation / Job / Burst)。 +- 明确开关关闭后的回退行为。 -6. 热路径禁用托管分配 -`TickEnemies`、互斥求解、阶段化循环里禁止 LINQ/临时集合扩张。 +### Step 1:扩数据 +- 先改 SimData 和 JobData。 +- 再改 CreateInitialSimData 与转换函数。 -## P2 前的已知技术债 -- `AttackRange` 目前固定值 `1f`,尚未由配置化数值驱动 -- `EnemySimData.TargetType/State` 语义仍偏轻量,未形成完整状态机合约 -- Projectile/Pickup 尚未迁移真实 Tick 行为 +### Step 2:接生命周期 +- 只在 EntitySync 增加注册/反注册。 +- 保持 group 到容器映射清晰。 -## 提交前检查清单 -- 是否保持了 `UseSimulationMovement` 关闭时行为不变 -- 是否保持了 `EntityId <-> SimulationIndex` 一致性(含移除 remap) -- 是否避免在 Tick 热路径引入新 GC -- 是否将新字段接入了 `EntitySync -> Tick -> Presentation` 全链路 -- 是否补充了最小回归验证(至少 Battle 循环、敌人移除、索引稳定性) -- 是否同步更新本 Skill 文档与 `docs/P1.5 Simulation-Supplement.md` +### Step 3:接执行阶段 +- 优先放入现有阶段(Build/Schedule/Commit)。 +- 新阶段必须补 ProfilerMarker。 + +### Step 4:接结算和表现 +- 逻辑结算收口到主线程。 +- 表现写回放在 Presentation。 + +### Step 5:补测试 +EditMode 和 PlayMode 同步补回归,至少覆盖: +- 行为正确性 +- 开关路径 +- 索引稳定性 +- 新增边界条件 + +### Step 6:更新文档 +- 更新本文件。 +- 需要性能结论时,同步更新 docs/P2 Job System + Burst 落地.md。 + +## 测试命令 +- PlayMode + - Unity -batchmode -nographics -projectPath . -runTests -testPlatform PlayMode -testResults Logs/playmode-test-results.xml -logFile Logs/playmode-tests.log +- EditMode + - Unity -batchmode -nographics -projectPath . -runTests -testPlatform EditMode -testResults Logs/editmode-test-results.xml -logFile Logs/editmode-tests.log + +## 关键回归用例(必须保留) +- TickEnemies_MatchesOutput_WhenBurstJobsToggled +- TryGetNearestEnemyEntityId_SelectsNearestBucketCandidate_WhenJobSimulationEnabled +- TickProjectiles_LimitsCandidatesToMaxTargets_IncludingPlayerCandidate +- SetUseSimulationAndJob_AreIgnored_WhenBattleStateIsActive +- EnqueueAreaQuery_CapturesInactiveSourceSnapshot_WhenSourceEntityUnavailable + +对应文件: +- Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs +- Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs + +## P2 验收口径 +- 3k 敌人下 Main Thread 明显下降(目标 >= 30%)。 +- 战斗持续帧 GC Alloc 接近 0。 +- Battle -> LevelUp -> Shop -> Battle 循环稳定。 + +## 提交前门禁清单 +- 关闭 UseSimulationMovement 是否完全回退旧链路。 +- EntityBinding 是否保持双向一致,删除后是否正确 remap。 +- Job 容器是否无泄漏(Persistent 都可 Dispose)。 +- 是否引入新热路径 GC(LINQ、临时集合、装箱)。 +- 是否破坏“战斗内不热切换 UseSimulationMovement/UseJobSimulation”。 +- 是否同步更新测试和本设计文档。