Checkpoint 9

This commit is contained in:
SepComet 2026-03-16 22:19:57 +08:00
parent 2e4108aebc
commit ccbd27e87e
79 changed files with 2937 additions and 2269 deletions

5
.gitignore vendored
View File

@ -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

View File

@ -2,7 +2,7 @@
<UnityGameFramework>
<ResourceBuilder>
<Settings>
<InternalResourceVersion>3</InternalResourceVersion>
<InternalResourceVersion>5</InternalResourceVersion>
<Platforms>33</Platforms>
<AssetBundleCompression>1</AssetBundleCompression>
<CompressionHelperTypeName>UnityGameFramework.Runtime.DefaultCompressionHelper</CompressionHelperTypeName>

View File

@ -2,19 +2,28 @@
<UnityGameFramework>
<ResourceCollection>
<Resources>
<Resource Name="Configs" FileSystem="GameData" LoadType="0" Packed="True" ResourceGroups="Base" />
<Resource Name="DataTables" FileSystem="GameData" LoadType="0" Packed="True" ResourceGroups="Base" />
<Resource Name="Configs" FileSystem="GameData" LoadType="0" Packed="True"
ResourceGroups="Base" />
<Resource Name="DataTables" FileSystem="GameData" LoadType="0" Packed="True"
ResourceGroups="Base" />
<Resource Name="Entities" FileSystem="Resources" LoadType="0" Packed="True" />
<Resource Name="Fonts" FileSystem="UI" LoadType="0" Packed="True" ResourceGroups="Base" />
<Resource Name="Localization/Dictionaries" Variant="en-us" FileSystem="UI" LoadType="0" Packed="True" ResourceGroups="Base" />
<Resource Name="Localization/Dictionaries" Variant="ko-kr" FileSystem="UI" LoadType="0" Packed="True" ResourceGroups="Base" />
<Resource Name="Localization/Dictionaries" Variant="zh-cn" FileSystem="UI" LoadType="0" Packed="True" ResourceGroups="Base" />
<Resource Name="Localization/Dictionaries" Variant="zh-tw" FileSystem="UI" LoadType="0" Packed="True" ResourceGroups="Base" />
<Resource Name="Localization/Dictionaries" Variant="en-us" FileSystem="UI" LoadType="0"
Packed="True" ResourceGroups="Base" />
<Resource Name="Localization/Dictionaries" Variant="ko-kr" FileSystem="UI" LoadType="0"
Packed="True" ResourceGroups="Base" />
<Resource Name="Localization/Dictionaries" Variant="zh-cn" FileSystem="UI" LoadType="0"
Packed="True" ResourceGroups="Base" />
<Resource Name="Localization/Dictionaries" Variant="zh-tw" FileSystem="UI" LoadType="0"
Packed="True" ResourceGroups="Base" />
<Resource Name="Materials" FileSystem="Resources" LoadType="0" Packed="True" />
<Resource Name="Meshes" FileSystem="Resources" LoadType="0" Packed="True" />
<Resource Name="Music/About" FileSystem="Resources" LoadType="0" Packed="True" ResourceGroups="Music" />
<Resource Name="Music/Background" FileSystem="Resources" LoadType="0" Packed="True" ResourceGroups="Music" />
<Resource Name="Music/Menu" FileSystem="Resources" LoadType="0" Packed="True" ResourceGroups="Music" />
<Resource Name="Music/About" FileSystem="Resources" LoadType="0" Packed="True"
ResourceGroups="Music" />
<Resource Name="Music/Background" FileSystem="Resources" LoadType="0" Packed="True"
ResourceGroups="Music" />
<Resource Name="Music/Menu" FileSystem="Resources" LoadType="0" Packed="True"
ResourceGroups="Music" />
<Resource Name="Scenes" FileSystem="Resources" LoadType="0" Packed="True" />
<Resource Name="SceneSettings" LoadType="0" Packed="False" />
<Resource Name="Sounds" FileSystem="Resources" LoadType="0" Packed="True" />
@ -41,9 +50,11 @@
<Asset Guid="0ed73dc47f4cb38489020d05e9f02c99" ResourceName="Materials" />
<Asset Guid="0f995b3145e0e7247a42da6cef1dbf23" ResourceName="Materials" />
<Asset Guid="1046dcb12e547564d8b54bd15419a787" ResourceName="Entities" />
<Asset Guid="1053b0070685be347ab58587156842dc" ResourceName="Localization/Dictionaries" ResourceVariant="zh-tw" />
<Asset Guid="1053b0070685be347ab58587156842dc" ResourceName="Localization/Dictionaries"
ResourceVariant="zh-tw" />
<Asset Guid="1478894bc9a1ed241b05b0862a7b8bce" ResourceName="Textures" />
<Asset Guid="14869ac0d4433f04db1704e39d03412e" ResourceName="Localization/Dictionaries" ResourceVariant="en-us" />
<Asset Guid="14869ac0d4433f04db1704e39d03412e" ResourceName="Localization/Dictionaries"
ResourceVariant="en-us" />
<Asset Guid="156d241f796508c4da4fc354a7fbf5a8" ResourceName="UI/UISprites/Common" />
<Asset Guid="185f97f18bd603a478461ce9c08bd039" ResourceName="Materials" />
<Asset Guid="18dc0cd2c080841dea60987a38ce93fa" ResourceName="URPAssets" />
@ -92,7 +103,8 @@
<Asset Guid="578af1667322bab45b652b79a40bb4fb" ResourceName="Materials" />
<Asset Guid="583ff7026dac91849b7c7b2468ba456b" ResourceName="Materials" />
<Asset Guid="5b5a6a737c460eb4abc105d6583d405e" ResourceName="Fonts" />
<Asset Guid="5dcd89912e222bf4c87f76db4044bc5e" ResourceName="Localization/Dictionaries" ResourceVariant="ko-kr" />
<Asset Guid="5dcd89912e222bf4c87f76db4044bc5e" ResourceName="Localization/Dictionaries"
ResourceVariant="ko-kr" />
<Asset Guid="5ebb46af6f16ae94e87f64a7dc0a49cb" ResourceName="Entities" />
<Asset Guid="62af9e5c8f39cfa49af9e10ccf42f1da" ResourceName="UI/UISprites/Common" />
<Asset Guid="638ff8ae4a0d15047839cd265d3bc296" ResourceName="Music/Background" />
@ -132,7 +144,9 @@
<Asset Guid="97b1f8b25cca2bc458cb9d6127c8d186" ResourceName="Materials" />
<Asset Guid="99d811b0183246646a2ce8df996f4bca" ResourceName="Fonts" />
<Asset Guid="9afa958d6d8235941b9badb42aae4370" ResourceName="Meshes" />
<Asset Guid="9be2e1e45f4edd74c8764538ad306b78" ResourceName="Localization/Dictionaries" ResourceVariant="zh-cn" />
<Asset Guid="9be2e1e45f4edd74c8764538ad306b78" ResourceName="Localization/Dictionaries"
ResourceVariant="zh-cn" />
<Asset Guid="9d193ac5b4294e0e9ba6e867320944b7" ResourceName="Entities" />
<Asset Guid="9ddab293e2a8af3499dac05f5fd6169c" ResourceName="Meshes" />
<Asset Guid="9f5bba6d2f5c95049a59fcb56df2d38f" ResourceName="UI/UIItems" />
<Asset Guid="9f847ec5e66e03e4ead1d3c5f7b510e8" ResourceName="UI/UISprites/Common" />
@ -157,6 +171,7 @@
<Asset Guid="ba157ba55f72c424a9e88f3c029997c4" ResourceName="Textures" />
<Asset Guid="baedbbad82997f445a8cb4da210404e0" ResourceName="Meshes" />
<Asset Guid="bbfd75fe6fe00e1448fe988173ede7f9" ResourceName="UI/UIForms" />
<Asset Guid="bc065bcf1474d7d4387fafd202678c37" ResourceName="Fonts" />
<Asset Guid="bf75b984df8a84987bcf3a8bf6e2862d" ResourceName="Sounds" />
<Asset Guid="c40be3174f62c4acf8c1216858c64956" ResourceName="URPAssets" />
<Asset Guid="c49cffd4fc1dfb549b2b30448a0becda" ResourceName="UI/UISprites/Icons" />

View File

@ -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

View File

@ -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

View File

@ -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<DamageTextItem>();
_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;
}
}
}

View File

@ -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<HealthComponent>() : 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<Player>();
}
private void KeepPlayerHealthAtMax()
{
Player player = FindPlayer();
if (player == null) return;
HealthComponent playerHealth = player.GetComponent<HealthComponent>();
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
#endif

View File

@ -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");
}
}
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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<HandgunHitMarkerPoolObject> 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<Collider>();
private bool TrySpawnMarker(out HandgunHitMarkerPooledInstance markerInstance)
{
markerInstance = null;
IObjectPool<HandgunHitMarkerPoolObject> 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<Collider>();
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<HandgunHitMarkerPooledInstance>();
markerGameObject.SetActive(false);
pool.Register(HandgunHitMarkerPoolObject.Create(markerInstance), true);
return true;
}
Renderer renderer = marker.GetComponent<Renderer>();
if (renderer != null)
private static IObjectPool<HandgunHitMarkerPoolObject> 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<HandgunHitMarkerPoolObject>(PoolName))
{
return s_Pool;
}
s_Pool = poolComponent.HasObjectPool<HandgunHitMarkerPoolObject>(PoolName)
? poolComponent.GetObjectPool<HandgunHitMarkerPoolObject>(PoolName)
: poolComponent.CreateSingleSpawnObjectPool<HandgunHitMarkerPoolObject>(
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;
}
}
}
}

View File

@ -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<HandgunHitMarkerPoolObject>();
pooledObject.Initialize(target);
return pooledObject;
}
protected override void Release(bool isShutdown)
{
HandgunHitMarkerPooledInstance markerInstance = Target as HandgunHitMarkerPooledInstance;
if (markerInstance == null)
{
return;
}
Object.Destroy(markerInstance.gameObject);
}
}
}

View File

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

View File

@ -0,0 +1,78 @@
using GameFramework.ObjectPool;
using UnityEngine;
namespace Entity.Weapon
{
public sealed class HandgunHitMarkerPooledInstance : MonoBehaviour
{
private Renderer _renderer;
private IObjectPool<HandgunHitMarkerPoolObject> _ownerPool;
private float _expireTime;
private bool _isActive;
private void Awake()
{
_renderer = GetComponent<Renderer>();
}
private void Update()
{
if (!_isActive)
{
return;
}
if (Time.time < _expireTime)
{
return;
}
ReturnToPool();
}
public void ApplyMaterial(Material material)
{
if (_renderer == null)
{
_renderer = GetComponent<Renderer>();
}
if (_renderer == null)
{
return;
}
_renderer.sharedMaterial = material;
}
public void Activate(float duration, IObjectPool<HandgunHitMarkerPoolObject> 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<HandgunHitMarkerPoolObject> pool = _ownerPool;
_ownerPool = null;
transform.SetParent(null, false);
if (pool == null)
{
gameObject.SetActive(false);
return;
}
pool.Unspawn(this);
}
}
}

View File

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

View File

@ -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;
}
}
}
}

View File

@ -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);
}

View File

@ -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);

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5f28ff313e954720a04246191b7f9ddd
timeCreated: 1771901064

View File

@ -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

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3f549ca9decb4f38933329abacbf78ac
timeCreated: 1771901120

View File

@ -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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5140d989cba042e6bcb217893491273d
timeCreated: 1771901785

View File

@ -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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e32f4e0724314c70882f5ed043a4dca4
timeCreated: 1771901749

View File

@ -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<EnemyJobOutputData> Inputs;
public NativeParallelMultiHashMap<long, int>.ParallelWriter Buckets;
public float CellSize;
public void Execute(int index)
{
BuildEnemySeparationBucket(
index,
Inputs,
Buckets,
CellSize
);
}
}
private static void BuildEnemySeparationBucket(
int index,
NativeArray<EnemyJobOutputData> inputs,
NativeParallelMultiHashMap<long, int>.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);
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e833e91677bb49a4a458c9c8b90099e9
timeCreated: 1771901500

View File

@ -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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 453c14964a4b4aed89c0abd7b25cd26c
timeCreated: 1771901722

View File

@ -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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 58abe0d4fd7a49acb716fbd26e0fb19f
timeCreated: 1771901691

View File

@ -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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 171936b8a6b84a8fbacc7651ce473b98
timeCreated: 1771901146

View File

@ -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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 34ef6fbf123f45ca88d8e7ab8311d543
timeCreated: 1771901204

View File

@ -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<EnemyJobInputData> Inputs;
public NativeArray<EnemyJobOutputData> 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<EnemyJobInputData> inputs,
NativeArray<EnemyJobOutputData> 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
};
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: abd5519779ed41d4947280eec326ffb1
timeCreated: 1771901472

View File

@ -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<EnemyJobOutputData> Inputs;
[ReadOnly] public NativeParallelMultiHashMap<long, int> Buckets;
[ReadOnly] public NativeArray<float2> PreviousPushes;
public NativeArray<EnemyJobOutputData> Outputs;
public NativeArray<float2> 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<EnemyJobOutputData> inputs,
NativeParallelMultiHashMap<long, int> buckets,
NativeArray<EnemyJobOutputData> outputs,
float cellSize,
float maxRadius,
float3 playerPosition,
float pushDamping,
float maxStepScale,
bool useTangentialInAttackRange,
NativeArray<float2> previousPushes,
NativeArray<float2> 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<long> 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));
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 94a77477c3a8410cb58b5028dd5d2c63
timeCreated: 1771901543

View File

@ -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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 334ca3869d7940058256ba5419573637
timeCreated: 1771901235

View File

@ -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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b5c4a144914548379d9991d8ef061da7
timeCreated: 1771901266

View File

@ -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<ProjectileJobInputData> Inputs;
public NativeArray<ProjectileJobOutputData> 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<ProjectileJobInputData> inputs,
NativeArray<ProjectileJobOutputData> 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;
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 49001e849c1b4afdaa37d851b9b76866
timeCreated: 1771901884

View File

@ -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<CollisionQueryData> Queries;
[ReadOnly] public NativeParallelMultiHashMap<long, int> EnemyBuckets;
[ReadOnly] public NativeArray<EnemyJobOutputData> EnemyOutputs;
public NativeList<CollisionCandidateData>.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<long> 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));
}
}
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 81aa43bb35f547c48b867eafa17a3857
timeCreated: 1771914779

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c66a8c10fffb404c9f4819adffea45fd
timeCreated: 1771900893

View File

@ -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<ProjectileJobInputData> Inputs;
public NativeArray<ProjectileJobOutputData> 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<ProjectileJobInputData> Inputs;
public NativeArray<ProjectileJobOutputData> 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<ProjectileJobInputData> inputArray = _projectileJobInputs.AsArray();
NativeArray<ProjectileJobOutputData> 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<EnemyJobOutputData> EnemyOutputs;
public NativeParallelMultiHashMap<long, int>.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<long> 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<ProjectileJobInputData> inputs,
NativeArray<ProjectileJobOutputData> 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
}
}

View File

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

View File

@ -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<EnemyJobInputData> inputArray = _enemyJobInputs.AsArray();
NativeArray<EnemyJobOutputData> 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<EnemyJobOutputData> inputArray = _enemyJobOutputs.AsArray();
NativeArray<EnemyJobOutputData> separatedOutputArray = _enemyJobSeparationOutputs.AsArray();
NativeArray<float2> previousPushes = _enemySeparationPreviousPushes.AsArray();
NativeArray<float2> 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;
}
}
}

View File

@ -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<ProjectileJobInputData> inputArray = _projectileJobInputs.AsArray();
NativeArray<ProjectileJobOutputData> 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
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4bf448696a8641268abb660b16def6f5
timeCreated: 1771911417

View File

@ -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);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 99cec505913140a2b4bdf1498b28c044
timeCreated: 1771911517

View File

@ -0,0 +1,9 @@
using UnityEngine;
namespace Simulation
{
public sealed partial class SimulationWorld
{
}
}

View File

@ -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);
}
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 21da1bcb02d247358738e9b562af5512
timeCreated: 1771911431

View File

@ -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<EnemyJobInputData> Inputs;
public NativeArray<EnemyJobOutputData> 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<EnemyJobInputData> Inputs;
public NativeArray<EnemyJobOutputData> 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<EnemyJobOutputData> Inputs;
public NativeParallelMultiHashMap<long, int>.ParallelWriter Buckets;
public float CellSize;
public void Execute(int index)
{
BuildEnemySeparationBucket(index, Inputs, Buckets, CellSize);
}
}
private struct BuildEnemySeparationBucketsJob : IJobParallelFor
{
[ReadOnly] public NativeArray<EnemyJobOutputData> Inputs;
public NativeParallelMultiHashMap<long, int>.ParallelWriter Buckets;
public float CellSize;
public void Execute(int index)
{
BuildEnemySeparationBucket(index, Inputs, Buckets, CellSize);
}
}
[BurstCompile]
private struct EnemySeparationBurstJob : IJobParallelFor
{
[ReadOnly] public NativeArray<EnemyJobOutputData> Inputs;
[ReadOnly] public NativeParallelMultiHashMap<long, int> Buckets;
[ReadOnly] public NativeArray<float2> PreviousPushes;
public NativeArray<EnemyJobOutputData> Outputs;
public NativeArray<float2> 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<EnemyJobOutputData> Inputs;
[ReadOnly] public NativeParallelMultiHashMap<long, int> Buckets;
[ReadOnly] public NativeArray<float2> PreviousPushes;
public NativeArray<EnemyJobOutputData> Outputs;
public NativeArray<float2> 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<EnemyJobInputData> inputArray = _enemyJobInputs.AsArray();
NativeArray<EnemyJobOutputData> 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<EnemyJobOutputData> inputArray = _enemyJobOutputs.AsArray();
NativeArray<EnemyJobOutputData> separatedOutputArray = _enemyJobSeparationOutputs.AsArray();
NativeArray<float2> previousPushes = _enemySeparationPreviousPushes.AsArray();
NativeArray<float2> 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<EnemyJobOutputData> inputs,
NativeParallelMultiHashMap<long, int>.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<EnemyJobOutputData> inputs,
NativeParallelMultiHashMap<long, int> buckets, NativeArray<EnemyJobOutputData> outputs,
float cellSize, float maxRadius, float3 playerPosition, float pushDamping, float maxStepScale,
bool useTangentialInAttackRange, NativeArray<float2> previousPushes,
NativeArray<float2> 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<long> 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<EnemyJobInputData> inputs,
NativeArray<EnemyJobOutputData> 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
};
}
}
}

View File

@ -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";

View File

@ -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);
}
}
}
}

View File

@ -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<MovementComponent>();
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
}
}

View File

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

View File

@ -19,7 +19,7 @@ namespace Simulation
return false;
}
if (!_useSimulationMovement || !_useJobSimulation)
if (!_useSimulationMovement)
{
return false;
}

View File

@ -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<EnemySimData> _enemies = new List<EnemySimData>();
private readonly List<ProjectileSimData> _projectiles = new List<ProjectileSimData>();
private readonly List<PickupSimData> _pickups = new List<PickupSimData>();
private readonly List<int> _projectileRecycleEntityIds = new List<int>();
private readonly HashSet<int> _projectileResolvedEntityIds = new HashSet<int>();
private readonly List<EnemySeparationAgent> _enemySeparationAgents = new List<EnemySeparationAgent>();
private readonly List<EnemyTickWorkItem> _enemyTickWorkItems = new List<EnemyTickWorkItem>();
private EntityBinding EnemyBinding { get; } = new EntityBinding();
private EntityBinding ProjectileBinding { get; } = new EntityBinding();
@ -65,255 +47,22 @@ namespace Simulation
public IReadOnlyList<ProjectileSimData> Projectiles => _projectiles;
public IReadOnlyList<PickupSimData> 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<MovementComponent>();
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
}
}
}

View File

@ -24,7 +24,7 @@ MonoBehaviour:
showUnityEditorReport: 0
logBehaviour: 0
drawGizmos: 1
defaultRecyclable: 0
defaultRecyclable: 1
defaultAutoPlay: 3
defaultUpdateType: 0
defaultTimeScaleIndependent: 0

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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 6Android 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`

View File

@ -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 7P1 阶段回归与性能记录
- 回归用例:战斗 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 9P2 回归、压测与结项文档
- 回归用例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 并行推进)

View File

@ -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<TContext, TForm>`
- 命名:`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/<SceneDomain>/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 逻辑耦合度。

View File

@ -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.

View File

@ -1,159 +1,200 @@
# Simulation Development SkillVampireLike
# Simulation Development Skill (VampireLike)
## 目标
本文件是 `Simulation` 分层的开发规范与速查手册。
后续调整敌人移动、补齐投射物/掉落物逻辑、推进 Job/Burst 改造时,优先按本文档执行,避免反复通读全部代码
本文件是 SimulationWorld 的正式设计说明和扩展开发规范。
后续在 Simulation 相关模块做功能扩展、性能优化、回归修复时,统一按本规范执行
## 当前架构总览P1.5 已落地)
- Simulation 主目录:`Assets/GameMain/Scripts/Simulation/`
- 核心组件:`SimulationWorld``GameFrameworkComponent`
- 数据容器:
- `List<EnemySimData> _enemies`
- `List<ProjectileSimData> _projectiles`
- `List<PickupSimData> _pickups`
- Tick 临时缓冲:
- `List<EnemyTickWorkItem> _enemyTickWorkItems`
- `List<EnemySeparationAgent> _enemySeparationAgents`
- 索引绑定:`EntityBinding``EntityId <-> SimulationIndex` 双向映射)
- 生命周期同步:`SimulationWorld.EntitySync`(监听实体 Show/Hide 事件)
- 表现层回写:`SimulationWorld.Presentation``LateUpdate` 写回 `Transform`
- Tick 上下文:`SimulationTickContext``DeltaTime`、`RealDeltaTime`、`PlayerPosition`
## 适用范围
- 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<EnemySimData> _enemies
- List<ProjectileSimData> _projectiles
- List<PickupSimData> _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总开关支持一键回滚。
- UseJobSimulationJob 开关,支持 P1.5/ P2 对照。
- UseBurstJobsBurst 开关。
## 与旧移动系统的关系(重要)
- `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
- 是否引入新热路径 GCLINQ、临时集合、装箱
- 是否破坏“战斗内不热切换 UseSimulationMovement/UseJobSimulation”。
- 是否同步更新测试和本设计文档。