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 # Packed Addressables
/[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin* /[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin*
# Temporary auto-generated Android Assets /Assets/StreamingAssets
/[Aa]ssets/[Ss]treamingAssets/aa.meta /Assets/StreamingAssets.meta
/[Aa]ssets/[Ss]treamingAssets/aa/*
/UI参考 /UI参考
/AGENTS.md /AGENTS.md

View File

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

View File

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

View File

@ -11,7 +11,6 @@ GameObject:
- component: {fileID: 7683855655592166216} - component: {fileID: 7683855655592166216}
- component: {fileID: 6418687210998749921} - component: {fileID: 6418687210998749921}
- component: {fileID: 4710806460657047075} - component: {fileID: 4710806460657047075}
- component: {fileID: 8116679074104541426}
- component: {fileID: 1932268889601128120} - component: {fileID: 1932268889601128120}
- component: {fileID: 557030043145096197} - component: {fileID: 557030043145096197}
- component: {fileID: 6353753365317756414} - component: {fileID: 6353753365317756414}
@ -87,33 +86,6 @@ MeshRenderer:
m_SortingLayer: 0 m_SortingLayer: 0
m_SortingOrder: 0 m_SortingOrder: 0
m_AdditionalVertexStreams: {fileID: 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 --- !u!136 &1932268889601128120
CapsuleCollider: CapsuleCollider:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -131,7 +103,7 @@ CapsuleCollider:
m_LayerOverridePriority: 0 m_LayerOverridePriority: 0
m_IsTrigger: 1 m_IsTrigger: 1
m_ProvidesContacts: 0 m_ProvidesContacts: 0
m_Enabled: 1 m_Enabled: 0
serializedVersion: 2 serializedVersion: 2
m_Radius: 0.5 m_Radius: 0.5
m_Height: 2 m_Height: 2

View File

@ -103,7 +103,7 @@ CapsuleCollider:
m_LayerOverridePriority: 0 m_LayerOverridePriority: 0
m_IsTrigger: 0 m_IsTrigger: 0
m_ProvidesContacts: 0 m_ProvidesContacts: 0
m_Enabled: 1 m_Enabled: 0
serializedVersion: 2 serializedVersion: 2
m_Radius: 0.5 m_Radius: 0.5
m_Height: 2 m_Height: 2

View File

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using GameFramework.ObjectPool; using GameFramework.ObjectPool;
using TMPro; using TMPro;
@ -9,7 +10,7 @@ namespace CustomComponent
{ {
public class DamageTextComponent : GameFrameworkComponent public class DamageTextComponent : GameFrameworkComponent
{ {
[SerializeField] private int _instancePoolCapacity = 32; [SerializeField] private int _instancePoolCapacity = 256;
[SerializeField] private string _poolName = "DamageTextItem"; [SerializeField] private string _poolName = "DamageTextItem";
@ -43,14 +44,20 @@ namespace CustomComponent
private DamageTextItem CreateDamageTextItem() private DamageTextItem CreateDamageTextItem()
{ {
if (_activeDamageTextItems.Count == _instancePoolCapacity)
{
_instancePoolCapacity = Mathf.Min(_instancePoolCapacity * 2, 1024);
_damageTextItemPool.Capacity = _instancePoolCapacity;
}
DamageTextItemObject itemObject = _damageTextItemPool.Spawn(); DamageTextItemObject itemObject = _damageTextItemPool.Spawn();
if (itemObject != null) if (itemObject != null)
{ {
return (DamageTextItem)itemObject.Target; return (DamageTextItem)itemObject.Target;
} }
GameObject itemGo = Instantiate(_damageTextItemPrefab, _instanceRoot, false); GameObject itemGo = Instantiate(_damageTextItemPrefab, _instanceRoot, false);
DamageTextItem item = itemGo.GetComponent<DamageTextItem>(); DamageTextItem item = itemGo.GetComponent<DamageTextItem>();
_damageTextItemPool.Register(DamageTextItemObject.Create(item), true); _damageTextItemPool.Register(DamageTextItemObject.Create(item), true);
return item; return item;
@ -63,5 +70,12 @@ namespace CustomComponent
_activeDamageTextItems.Remove(item); _activeDamageTextItems.Remove(item);
_damageTextItemPool.Unspawn(item); _damageTextItemPool.Unspawn(item);
} }
private void OnDestroy()
{
_activeDamageTextItems.Clear();
_damageTextItemPool.Release();
_damageTextItemPool = null;
}
} }
} }

View File

@ -1,6 +1,8 @@
#if UNITY_EDITOR || DEVELOPMENT_BUILD #if UNITY_EDITOR || DEVELOPMENT_BUILD
using System; using System;
using System.Linq; using System.Linq;
using Components;
using CustomEvent;
using DataTable; using DataTable;
using Definition.DataStruct; using Definition.DataStruct;
using Entity; using Entity;
@ -19,6 +21,7 @@ namespace CustomComponent
private const float MinSpawnRate = 0.1f; private const float MinSpawnRate = 0.1f;
private const float CornerTapWindow = 0.6f; private const float CornerTapWindow = 0.6f;
private const int RequiredCornerTapCount = 3; private const int RequiredCornerTapCount = 3;
private const int DebugHealAmount = 200;
private Rect _windowRect = new Rect(20f, 60f, 460f, 800f); private Rect _windowRect = new Rect(20f, 60f, 460f, 800f);
private bool _isPanelVisible; private bool _isPanelVisible;
@ -38,6 +41,7 @@ namespace CustomComponent
private int _cornerTapCount; private int _cornerTapCount;
private float _lastCornerTapTime = -10f; private float _lastCornerTapTime = -10f;
private bool _lockPlayerHealthToMax;
protected override void Awake() protected override void Awake()
{ {
@ -54,6 +58,10 @@ namespace CustomComponent
} }
HandleCornerTapGesture(); HandleCornerTapGesture();
if (_lockPlayerHealthToMax)
{
KeepPlayerHealthAtMax();
}
} }
private void OnGUI() private void OnGUI()
@ -147,6 +155,7 @@ namespace CustomComponent
ProcedureGame procedure = GameEntry.Procedure.CurrentProcedure as ProcedureGame; ProcedureGame procedure = GameEntry.Procedure.CurrentProcedure as ProcedureGame;
EnemyManagerComponent enemyManager = GameEntry.EnemyManager; EnemyManagerComponent enemyManager = GameEntry.EnemyManager;
Player player = FindPlayer(); Player player = FindPlayer();
HealthComponent playerHealth = player != null ? player.GetComponent<HealthComponent>() : null;
if (enemyManager == null) if (enemyManager == null)
{ {
@ -168,8 +177,6 @@ namespace CustomComponent
if (simulationWorld != null) if (simulationWorld != null)
{ {
GUILayout.Space(4f); GUILayout.Space(4f);
GUILayout.Label(
$"Sim Switch: Move={(simulationWorld.UseSimulationMovement ? "On" : "Off")} Job={(simulationWorld.UseJobSimulation ? "On" : "Off")} Burst={(simulationWorld.UseBurstJobs ? "On" : "Off")}");
GUILayout.Label( GUILayout.Label(
$"Collision Queries: total {simulationWorld.LastCollisionQueryCount} (Projectile {simulationWorld.LastProjectileCollisionQueryCount} / Area {simulationWorld.LastAreaCollisionQueryCount})"); $"Collision Queries: total {simulationWorld.LastCollisionQueryCount} (Projectile {simulationWorld.LastProjectileCollisionQueryCount} / Area {simulationWorld.LastAreaCollisionQueryCount})");
GUILayout.Label( GUILayout.Label(
@ -265,6 +272,28 @@ namespace CustomComponent
GUI.enabled = true; GUI.enabled = true;
GUILayout.EndHorizontal(); 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) private void EnsurePropList(bool force = false)
@ -324,6 +353,52 @@ namespace CustomComponent
return UnityEngine.Object.FindObjectOfType<Player>(); 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) private static void AddSelectedBuffToPlayer(DRProp prop, int count)
{ {
Player player = FindPlayer(); 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 class CustomProfilerMarker
{ {
public static readonly ProfilerMarker TickEnemies = new ProfilerMarker("TickEnemies"); public static readonly ProfilerMarker TickEnemies = new("TickEnemies");
public static readonly ProfilerMarker TickEnemies_BuildInput = new ProfilerMarker("TickEnemies.BuildInput"); public static readonly ProfilerMarker TickEnemies_BuildInput = new("TickEnemies.BuildInput");
public static readonly ProfilerMarker TickEnemies_MoveSeparation = new ProfilerMarker("TickEnemies.MoveSeparation"); public static readonly ProfilerMarker TickEnemies_StateUpdate = new("TickEnemies.StateUpdate");
public static readonly ProfilerMarker TickEnemies_StateUpdate = new ProfilerMarker("TickEnemies.StateUpdate"); public static readonly ProfilerMarker TickEnemies_Schedule = new("TickEnemies.Schedule");
public static readonly ProfilerMarker TickEnemies_Schedule = new ProfilerMarker("TickEnemies.Schedule"); public static readonly ProfilerMarker TickEnemies_Complete = new("TickEnemies.Complete");
public static readonly ProfilerMarker TickEnemies_Complete = new ProfilerMarker("TickEnemies.Complete"); public static readonly ProfilerMarker TickEnemies_MainThreadCommit = new("TickEnemies.MainThreadCommit");
public static readonly ProfilerMarker TickEnemies_MainThreadCommit = new ProfilerMarker("TickEnemies.MainThreadCommit"); public static readonly ProfilerMarker TickEnemies_WriteBack = new("TickEnemies.WriteBack");
public static readonly ProfilerMarker TickEnemies_WriteBack = new ProfilerMarker("TickEnemies.WriteBack");
public static readonly ProfilerMarker Collision = new("Collision");
public static readonly ProfilerMarker Collision_BuildQueries = new("Collision.BuildQueries"); public static readonly ProfilerMarker Collision_BuildQueries = new("Collision.BuildQueries");
public static readonly ProfilerMarker Collision_BuildBuckets = new("Collision.BuildBuckets"); public static readonly ProfilerMarker Collision_BuildBuckets = new("Collision.BuildBuckets");
public static readonly ProfilerMarker Collision_QueryCandidates = new("Collision.QueryCandidates"); public static readonly ProfilerMarker Collision_QueryCandidates = new("Collision.QueryCandidates");
public static readonly ProfilerMarker Collision_ResolveProjectile = new("Collision.ResolveProjectile"); public static readonly ProfilerMarker Collision_ResolveProjectile = new("Collision.ResolveProjectile");
public static readonly ProfilerMarker Collision_ResolveArea = new("Collision.ResolveArea"); public static readonly ProfilerMarker Collision_ResolveArea = new("Collision.ResolveArea");
public static readonly ProfilerMarker TargetSelection_BuildBuckets = new("TargetSelection.BuildBuckets"); public static readonly ProfilerMarker TargetSelection_BuildBuckets = new("TargetSelection.BuildBuckets");
public static readonly ProfilerMarker TargetSelection_QueryNeighbors = new("TargetSelection.QueryNeighbors"); 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 ShopUI_Update = new("UGF.ShopUI.Update");
public static readonly ProfilerMarker Inventory_Refresh = new("UGF.Inventory.Refresh"); public static readonly ProfilerMarker Inventory_Refresh = new("UGF.Inventory.Refresh");
} }
} }

View File

@ -117,7 +117,7 @@ namespace Entity
private static bool IsDrivenBySimulationWorld() private static bool IsDrivenBySimulationWorld()
{ {
var simulationWorld = GameEntry.SimulationWorld; var simulationWorld = GameEntry.SimulationWorld;
return simulationWorld != null && simulationWorld.UseSimulationMovement && simulationWorld.UseJobSimulation; return simulationWorld != null && simulationWorld.UseSimulationMovement;
} }
private void SetColliderEnabled(bool enabled) private void SetColliderEnabled(bool enabled)
@ -137,4 +137,4 @@ namespace Entity
} }
} }
} }
} }

View File

@ -1,13 +1,23 @@
using GameFramework.ObjectPool;
using UnityEngine; using UnityEngine;
namespace Entity.Weapon namespace Entity.Weapon
{ {
public sealed class HandgunHitMarkerAttackEffect : IWeaponAttackEffect 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 _size;
private readonly float _yOffset; private readonly float _yOffset;
private readonly float _duration; private readonly float _duration;
private readonly Color _color; private readonly Color _color;
private Material _sharedMaterial;
public HandgunHitMarkerAttackEffect(float size, float yOffset, float duration, Color color) 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) public void Play(WeaponBase weapon, Vector3 position, EntityBase target, float radius)
{ {
if (target == null) return; if (target == null) return;
if (!TrySpawnMarker(out HandgunHitMarkerPooledInstance markerInstance))
{
return;
}
GameObject marker = GameObject.CreatePrimitive(PrimitiveType.Sphere); Transform targetTransform = target.CachedTransform;
marker.name = "HandgunHitMarker"; 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) if (collider != null)
{ {
Object.Destroy(collider); Object.Destroy(collider);
} }
marker.transform.SetParent(target.CachedTransform, false); markerInstance = markerGameObject.AddComponent<HandgunHitMarkerPooledInstance>();
marker.transform.localPosition = new Vector3(0f, _yOffset, 0f); markerGameObject.SetActive(false);
marker.transform.localScale = Vector3.one * Mathf.Max(0.01f, _size); pool.Register(HandgunHitMarkerPoolObject.Create(markerInstance), true);
return true;
}
Renderer renderer = marker.GetComponent<Renderer>(); private static IObjectPool<HandgunHitMarkerPoolObject> EnsurePool()
if (renderer != null) {
var poolComponent = GameEntry.ObjectPool;
if (poolComponent == null)
{ {
Shader shader = Shader.Find("Sprites/Default"); return null;
if (shader == null)
{
shader = Shader.Find("Unlit/Color");
}
Material material = new Material(shader);
material.color = _color;
renderer.material = material;
} }
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; var simulationWorld = GameEntry.SimulationWorld;
if (simulationWorld == null || !simulationWorld.UseSimulationMovement || !simulationWorld.UseJobSimulation) if (simulationWorld == null || !simulationWorld.UseSimulationMovement)
{ {
return false; return false;
} }
@ -65,4 +65,4 @@ namespace Entity.Weapon
return true; return true;
} }
} }
} }

View File

@ -229,7 +229,7 @@ namespace Entity.Weapon
} }
int ownerEntityId = WeaponData != null ? WeaponData.OwnerId : Id; 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, 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; 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); halfAngleDeg, maxTargets);
} }

View File

@ -88,7 +88,7 @@ namespace Procedure
base.OnEnter(procedureOwner); base.OnEnter(procedureOwner);
_procedureOwner = procedureOwner; _procedureOwner = procedureOwner;
GameEntry.SimulationWorld?.Clear(); GameEntry.SimulationWorld?.ClearSimulationState();
GameEntry.Event.Subscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess); GameEntry.Event.Subscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess);
GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, ShowEntitySuccess); GameEntry.Event.Subscribe(ShowEntitySuccessEventArgs.EventId, ShowEntitySuccess);
@ -136,7 +136,7 @@ namespace Procedure
Player = null; Player = null;
_procedureOwner = null; _procedureOwner = null;
GameEntry.SimulationWorld?.Clear(); GameEntry.SimulationWorld?.ClearSimulationState();
GameEntry.Event.Unsubscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess); GameEntry.Event.Unsubscribe(OpenUIFormSuccessEventArgs.EventId, OpenUIFormSuccess);
GameEntry.Event.Unsubscribe(ShowEntitySuccessEventArgs.EventId, ShowEntitySuccess); 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 public sealed partial class SimulationWorld
{ {
private struct EnemyJobInputData // Native buffers used by burst jobs and main-thread collision post-processing.
{
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;
}
private const int CollisionSourceTypeProjectile = 1; private const int CollisionSourceTypeProjectile = 1;
private const int CollisionSourceTypeArea = 2; private const int CollisionSourceTypeArea = 2;
private const int CollisionShapeCircle = 0; private const int CollisionShapeCircle = 0;
@ -352,7 +244,7 @@ namespace Simulation
_lastCollisionHasEnemyTargets = false; _lastCollisionHasEnemyTargets = false;
} }
private void SyncSimulationToJobInput() private void SyncSimulationStateToJobInputs()
{ {
InitializeJobDataChannels(); InitializeJobDataChannels();
EnsureCapacity(ref _enemyJobInputs, _enemies.Count); EnsureCapacity(ref _enemyJobInputs, _enemies.Count);
@ -361,34 +253,14 @@ namespace Simulation
_enemyJobInputs.Clear(); _enemyJobInputs.Clear();
_projectileJobInputs.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])); _projectileJobInputs.Add(ConvertToProjectileJobInput(data));
}
}
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]));
} }
} }
@ -416,19 +288,7 @@ namespace Simulation
} }
} }
private void SyncProjectilesToJobOutput() private void CopyProjectileInputsToOutputs()
{
InitializeJobDataChannels();
EnsureCapacity(ref _projectileJobOutputs, _projectiles.Count);
_projectileJobOutputs.Clear();
for (int i = 0; i < _projectiles.Count; i++)
{
_projectileJobOutputs.Add(ConvertToProjectileJobOutput(_projectiles[i]));
}
}
private void CopyProjectileInputToOutput()
{ {
for (int i = 0; i < _projectileJobInputs.Length; i++) 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(); InitializeJobDataChannels();
EnsureCapacity(ref _collisionQueryInputs, queryCount); EnsureCapacity(ref _collisionQueryInputs, queryCount);
@ -621,7 +482,7 @@ namespace Simulation
} }
} }
private void ApplyJobOutputToSimulation() private void ApplyJobOutputsToSimulationState()
{ {
int enemyCount = Mathf.Min(_enemies.Count, _enemyJobOutputs.Length); int enemyCount = Mathf.Min(_enemies.Count, _enemyJobOutputs.Length);
bool hasEnemyPositionChanged = false; 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) private static EnemySimData ConvertToEnemySimData(in EnemyJobOutputData enemy)
{ {
return new EnemySimData 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) private static ProjectileSimData ConvertToProjectileSimData(in ProjectileJobOutputData projectile)
{ {
return new ProjectileSimData 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.Burst;
using Unity.Collections; using Unity.Collections;
using Unity.Jobs; using Unity.Jobs;
using Unity.Mathematics; using Unity.Mathematics;
using UnityEngine; using UnityEngine;
using CustomDebugger;
using CustomEvent;
using Definition.DataStruct;
using Definition.Enum;
using Entity;
using Entity.Weapon;
using CustomUtility;
using UnityGameFramework.Runtime;
namespace Simulation namespace Simulation
{ {
public sealed partial class SimulationWorld public sealed partial class SimulationWorld
{ {
private const int PlayerEntityId = -1; private const int PlayerEntityId = -1;
private JobHandle _collisionCandidateQueryHandle;
private bool _collisionCandidateQueryScheduled;
[Header("投射物模拟参数")] [Tooltip("投射物距离玩家超过该水平半径时回收。小于等于 0 表示不启用该回收条件。")] [SerializeField] [Header("Projectile Collision Query")]
private float _projectileMaxDistanceFromPlayer = 120f; [Tooltip("Projectile broad-phase collision query radius.")]
[SerializeField]
[Tooltip("投射物与玩家的垂直高度差超过该值时回收。小于等于 0 表示不启用该回收条件。")] [SerializeField]
private float _projectileMaxVerticalOffsetFromPlayer = 30f;
[Tooltip("投射物 Broad Phase 命中查询半径。")] [SerializeField]
private float _projectileCollisionQueryRadius = 0.35f; private float _projectileCollisionQueryRadius = 0.35f;
[Tooltip("每个投射物最多保留的候选目标数量。")] [SerializeField] [Tooltip("Maximum retained candidates per projectile query.")]
[SerializeField]
private int _projectileMaxCandidatesPerQuery = 1; 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; private float _projectileCollisionCellSize = 0f;
[Header("投射物命中事件派发")] [Tooltip("是否派发投射物命中表现事件。")] [SerializeField] [Header("Projectile Hit Event Dispatch")]
[Tooltip("Dispatch projectile hit presentation event.")]
[SerializeField]
private bool _dispatchProjectileHitPresentationEvent = true; private bool _dispatchProjectileHitPresentationEvent = true;
[Tooltip("命中时是否请求命中标记表现。")] [SerializeField] [Tooltip("Request hit marker when projectile hits.")]
[SerializeField]
private bool _dispatchProjectileHitMarkerEvent = true; private bool _dispatchProjectileHitMarkerEvent = true;
[Tooltip("命中时是否请求特效表现。")] [SerializeField] [Tooltip("Request hit effect when projectile hits.")]
[SerializeField]
private bool _dispatchProjectileHitEffectEvent = true; 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; private int _projectileHitPresentationEffectTypeId = 0;
[BurstCompile] #region Area Query Request
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) public bool TryRequestAreaCollision(int sourceEntityId, int sourceOwnerEntityId, in Vector3 center,
{
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,
float radius, int maxTargets = 16) float radius, int maxTargets = 16)
{ {
return TryEnqueueAreaCollisionQueryInternal(sourceEntityId, sourceOwnerEntityId, in center, radius, return TryRequestAreaCollisionInternal(sourceEntityId, sourceOwnerEntityId, in center, radius,
maxTargets, CollisionShapeCircle, Vector3.forward, 180f); 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) 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); 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) float radius, int maxTargets, int shapeType, in Vector3 direction, float halfAngleDeg)
{ {
if (!_useSimulationMovement || !_useJobSimulation) if (!_useSimulationMovement)
{ {
return false; return false;
} }
@ -106,7 +80,7 @@ namespace Simulation
} }
int resolvedOwnerEntityId = sourceOwnerEntityId != 0 ? sourceOwnerEntityId : sourceEntityId; int resolvedOwnerEntityId = sourceOwnerEntityId != 0 ? sourceOwnerEntityId : sourceEntityId;
bool sourceWasActiveAtQueryTime = IsCollisionSourceActiveAtQueryTime(sourceEntityId); bool sourceWasActiveAtQueryTime = WasCollisionSourceActiveAtQueryTime(sourceEntityId);
Vector3 normalizedDirection = direction; Vector3 normalizedDirection = direction;
normalizedDirection.y = 0f; normalizedDirection.y = 0f;
if (normalizedDirection.sqrMagnitude <= Mathf.Epsilon) if (normalizedDirection.sqrMagnitude <= Mathf.Epsilon)
@ -134,12 +108,12 @@ namespace Simulation
return true; return true;
} }
private int GetPendingAreaCollisionQueryCount() private int GetPendingAreaCollisionRequestCount()
{ {
return _areaCollisionRequests.Count; return _areaCollisionRequests.Count;
} }
private int EstimatePendingAreaCollisionCandidateCount() private int EstimatePendingAreaCollisionCandidateCountFromRequests()
{ {
int expectedCount = 0; int expectedCount = 0;
for (int i = 0; i < _areaCollisionRequests.Count; i++) for (int i = 0; i < _areaCollisionRequests.Count; i++)
@ -150,61 +124,19 @@ namespace Simulation
return expectedCount; return expectedCount;
} }
private JobHandle ExecuteProjectileMovementJob(in SimulationTickContext context) #endregion
#region Collision Pipeline
private void PrepareCollisionCandidatesForFrame()
{ {
int projectileCount = _projectileJobInputs.Length; _collisionCandidateQueryScheduled = false;
if (projectileCount == 0)
{
return default;
}
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 || if (!_collisionQueryInputs.IsCreated || !_collisionCandidates.IsCreated ||
!_enemyCollisionBuckets.IsCreated) !_enemyCollisionBuckets.IsCreated)
{ {
ResetCollisionRuntimeStats(); ResetCollisionRuntimeStats();
ClearAreaCollisionFrameBuffers(); ClearAreaCollisionTransientBuffers();
return; return;
} }
@ -278,29 +210,37 @@ namespace Simulation
{ {
if (hasEnemyTargets) if (hasEnemyTargets)
{ {
BuildEnemyCollisionBucketsForProjectiles(cellSize); BuildEnemyCollisionBuckets(cellSize);
} }
} }
int projectileCandidateCount;
int areaCandidateCount;
using (CustomProfilerMarker.Collision_QueryCandidates.Auto()) using (CustomProfilerMarker.Collision_QueryCandidates.Auto())
{ {
QueryProjectileCollisionCandidates(cellSize, hasEnemyTargets, out projectileCandidateCount, _collisionCandidateQueryScheduled = ScheduleCollisionCandidateQueryJob(cellSize, hasEnemyTargets,
out areaCandidateCount); out _collisionCandidateQueryHandle);
}
}
private void CompleteCollisionCandidatesForFrame()
{
if (_collisionCandidateQueryScheduled)
{
_collisionCandidateQueryHandle.Complete();
_collisionCandidateQueryScheduled = false;
} }
CountCollisionCandidatesBySourceType(out int projectileCandidateCount, out int areaCandidateCount);
_lastProjectileCollisionCandidateCount = projectileCandidateCount; _lastProjectileCollisionCandidateCount = projectileCandidateCount;
_lastAreaCollisionCandidateCount = areaCandidateCount; _lastAreaCollisionCandidateCount = areaCandidateCount;
_lastCollisionCandidateCount = projectileCandidateCount + areaCandidateCount; _lastCollisionCandidateCount = projectileCandidateCount + areaCandidateCount;
} }
private void ResolveProjectileCollisionCandidatesMainThread() private void ResolveCollisionCandidatesOnMainThread()
{ {
if (!_collisionCandidates.IsCreated) if (!_collisionCandidates.IsCreated)
{ {
_lastResolvedAreaHitCount = 0; _lastResolvedAreaHitCount = 0;
ClearAreaCollisionFrameBuffers(); ClearAreaCollisionTransientBuffers();
return; return;
} }
@ -311,7 +251,7 @@ namespace Simulation
if (_collisionCandidates.Length == 0) if (_collisionCandidates.Length == 0)
{ {
_lastResolvedAreaHitCount = 0; _lastResolvedAreaHitCount = 0;
ClearAreaCollisionFrameBuffers(); ClearAreaCollisionTransientBuffers();
return; return;
} }
@ -328,7 +268,7 @@ namespace Simulation
continue; continue;
} }
if (!TryGetActiveProjectileData(projectileEntityId, out _, out ProjectileSimData projectile)) if (!TryGetActiveProjectileSimData(projectileEntityId, out _, out ProjectileSimData projectile))
{ {
_projectileResolvedEntityIds.Add(projectileEntityId); _projectileResolvedEntityIds.Add(projectileEntityId);
continue; continue;
@ -338,11 +278,13 @@ namespace Simulation
bool shouldDispatchPresentation = false; bool shouldDispatchPresentation = false;
int damage = 0; int damage = 0;
Vector3 hitPosition = projectile.Position; Vector3 hitPosition = projectile.Position;
if (TryGetTargetableEntity(candidate.TargetEntityId, out TargetableObject target)) if (TryGetAliveTargetableEntity(candidate.TargetEntityId, out TargetableObject target))
{ {
EntityBase sourceEntity = TryGetEntityById(candidate.SourceEntityId); EntityBase sourceEntity = TryGetEntityById(candidate.SourceEntityId);
EntityBase ownerEntity = TryGetEntityById(candidate.SourceOwnerEntityId); 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); out damage, out hitPosition, out shouldDispatchPresentation);
} }
@ -354,9 +296,10 @@ namespace Simulation
if (shouldExpireProjectile) if (shouldExpireProjectile)
{ {
MarkProjectileExpired(projectileEntityId); MarkProjectileAsExpired(projectileEntityId);
_projectileResolvedEntityIds.Add(projectileEntityId); _projectileResolvedEntityIds.Add(projectileEntityId);
} }
continue; continue;
} }
@ -383,49 +326,20 @@ namespace Simulation
int resolvedAreaHitCount; int resolvedAreaHitCount;
using (CustomProfilerMarker.Collision_ResolveArea.Auto()) using (CustomProfilerMarker.Collision_ResolveArea.Auto())
{ {
resolvedAreaHitCount = ResolveAreaCollisionHitsMainThread(); resolvedAreaHitCount = ResolveAreaCollisionHitsOnMainThread();
} }
_lastResolvedAreaHitCount = resolvedAreaHitCount; _lastResolvedAreaHitCount = resolvedAreaHitCount;
_projectileResolvedEntityIds.Clear(); _projectileResolvedEntityIds.Clear();
ClearAreaCollisionFrameBuffers(); ClearAreaCollisionTransientBuffers();
} }
private void RecycleInactiveProjectiles() #endregion
{
_projectileRecycleEntityIds.Clear();
for (int i = 0; i < _projectiles.Count; i++)
{
ProjectileSimData projectile = _projectiles[i];
if (!ShouldRecycleProjectile(projectile))
{
continue;
}
_projectileRecycleEntityIds.Add(projectile.EntityId); #region Collision Resolve Helpers
}
if (_projectileRecycleEntityIds.Count == 0) private bool ResolveProjectileHitAgainstTarget(TargetableObject target, EntityBase sourceEntity,
{ EntityBase ownerEntity,
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,
in ProjectileSimData projectile, out int damage, out Vector3 hitPosition, in ProjectileSimData projectile, out int damage, out Vector3 hitPosition,
out bool shouldDispatchPresentation) out bool shouldDispatchPresentation)
{ {
@ -540,7 +454,7 @@ namespace Simulation
return false; return false;
} }
private static bool TryGetTargetableEntity(int entityId, out TargetableObject target) private static bool TryGetAliveTargetableEntity(int entityId, out TargetableObject target)
{ {
target = null; target = null;
@ -570,7 +484,7 @@ namespace Simulation
return entityComponent != null ? entityComponent.GetGameEntity(entityId) : null; 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) out ProjectileSimData projectile)
{ {
simulationIndex = -1; simulationIndex = -1;
@ -593,7 +507,7 @@ namespace Simulation
return true; return true;
} }
private bool MarkProjectileExpired(int projectileEntityId) private bool MarkProjectileAsExpired(int projectileEntityId)
{ {
if (!ProjectileBinding.TryGetSimulationIndex(projectileEntityId, out int simulationIndex) || if (!ProjectileBinding.TryGetSimulationIndex(projectileEntityId, out int simulationIndex) ||
simulationIndex < 0 || simulationIndex >= _projectiles.Count) simulationIndex < 0 || simulationIndex >= _projectiles.Count)
@ -614,7 +528,7 @@ namespace Simulation
return true; return true;
} }
private int ResolveAreaCollisionHitsMainThread() private int ResolveAreaCollisionHitsOnMainThread()
{ {
if (_areaCollisionHitEvents.Count == 0) if (_areaCollisionHitEvents.Count == 0)
{ {
@ -625,7 +539,7 @@ namespace Simulation
for (int i = 0; i < _areaCollisionHitEvents.Count; i++) for (int i = 0; i < _areaCollisionHitEvents.Count; i++)
{ {
AreaCollisionHitEventData hitEvent = _areaCollisionHitEvents[i]; AreaCollisionHitEventData hitEvent = _areaCollisionHitEvents[i];
if (!TryGetCollisionQueryById(hitEvent.QueryId, out CollisionQueryData query) || if (!TryGetCollisionQueryByQueryId(hitEvent.QueryId, out CollisionQueryData query) ||
query.SourceType != CollisionSourceTypeArea) query.SourceType != CollisionSourceTypeArea)
{ {
continue; continue;
@ -642,7 +556,7 @@ namespace Simulation
continue; continue;
} }
if (!TryGetTargetableEntity(hitEvent.TargetEntityId, out TargetableObject target)) if (!TryGetAliveTargetableEntity(hitEvent.TargetEntityId, out TargetableObject target))
{ {
continue; continue;
} }
@ -659,7 +573,7 @@ namespace Simulation
return resolvedHitCount; return resolvedHitCount;
} }
private bool TryGetCollisionQueryById(int queryId, out CollisionQueryData query) private bool TryGetCollisionQueryByQueryId(int queryId, out CollisionQueryData query)
{ {
query = default; query = default;
if (!_collisionQueryInputs.IsCreated || queryId < 0 || queryId >= _collisionQueryInputs.Length) if (!_collisionQueryInputs.IsCreated || queryId < 0 || queryId >= _collisionQueryInputs.Length)
@ -734,139 +648,107 @@ namespace Simulation
return angle <= halfAngle; return angle <= halfAngle;
} }
private void ClearAreaCollisionFrameBuffers() private void ClearAreaCollisionTransientBuffers()
{ {
_areaCollisionRequests.Clear(); _areaCollisionRequests.Clear();
_areaCollisionHitEvents.Clear(); _areaCollisionHitEvents.Clear();
_areaCollisionHitDedupKeys.Clear(); _areaCollisionHitDedupKeys.Clear();
} }
private void BuildEnemyCollisionBucketsForProjectiles(float cellSize) private void BuildEnemyCollisionBuckets(float cellSize)
{ {
_enemyCollisionBuckets.Clear(); _enemyCollisionBuckets.Clear();
for (int i = 0; i < _enemyJobOutputs.Length; i++) int enemyCount = _enemyJobOutputs.Length;
if (enemyCount <= 0)
{ {
EnemyJobOutputData enemy = _enemyJobOutputs[i]; return;
int cellX = (int)math.floor(enemy.Position.x / cellSize); }
int cellZ = (int)math.floor(enemy.Position.z / cellSize);
_enemyCollisionBuckets.Add(SeparationCellKey(cellX, cellZ), i); 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, [BurstCompile]
out int projectileCandidateCount, out int areaCandidateCount) 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; projectileCandidateCount = 0;
areaCandidateCount = 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]; return;
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 && for (int i = 0; i < _collisionCandidates.Length; i++)
query.SourceOwnerEntityId != playerTargetEntityId) {
CollisionCandidateData candidate = _collisionCandidates[i];
if (candidate.SourceType == CollisionSourceTypeProjectile)
{ {
playerPosition.y = query.Position.y; projectileCandidateCount++;
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;
}
}
} }
else if (candidate.SourceType == CollisionSourceTypeArea)
if (!hasEnemyTargets || reachedLimit)
{ {
continue; areaCandidateCount++;
}
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));
}
} }
} }
} }
@ -876,7 +758,7 @@ namespace Simulation
playerEntityId = PlayerEntityId; playerEntityId = PlayerEntityId;
playerPosition = default; playerPosition = default;
if (!TryGetTargetableEntity(playerEntityId, out TargetableObject playerTarget)) if (!TryGetAliveTargetableEntity(playerEntityId, out TargetableObject playerTarget))
{ {
return false; return false;
} }
@ -892,22 +774,7 @@ namespace Simulation
return true; return true;
} }
private static bool ShouldRecycleProjectile(in ProjectileSimData projectile) private static bool WasCollisionSourceActiveAtQueryTime(int sourceEntityId)
{
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)
{ {
EntityBase sourceEntity = TryGetEntityById(sourceEntityId); EntityBase sourceEntity = TryGetEntityById(sourceEntityId);
if (sourceEntity == null || !sourceEntity.Available) if (sourceEntity == null || !sourceEntity.Available)
@ -928,84 +795,6 @@ namespace Simulation
return true; return true;
} }
private static void ExecuteProjectileMovement(int index, NativeArray<ProjectileJobInputData> inputs, #endregion
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,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 namespace Simulation
{ {
public partial class SimulationWorld public sealed partial class SimulationWorld
{ {
// Bridges entity show/hide events into simulation state registration.
public sealed class EntitySync public sealed class EntitySync
{ {
private const string EnemyGroupName = "Enemy"; 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; return false;
} }
if (!_useSimulationMovement || !_useJobSimulation) if (!_useSimulationMovement)
{ {
return false; return false;
} }

View File

@ -1,10 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using Components;
using CustomDebugger; using CustomDebugger;
using CustomUtility;
using Entity;
using Entity.EntityData;
using Procedure;
using UnityEngine; using UnityEngine;
using UnityGameFramework.Runtime; using UnityGameFramework.Runtime;
@ -12,6 +7,18 @@ namespace Simulation
{ {
public sealed partial class SimulationWorld : GameFrameworkComponent 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 float DefaultAttackRange = 1f;
private const int EnemyStateIdle = 0; private const int EnemyStateIdle = 0;
private const int EnemyStateChasing = 1; private const int EnemyStateChasing = 1;
@ -19,43 +26,18 @@ namespace Simulation
private const int ProjectileStateActive = 0; private const int ProjectileStateActive = 0;
private const int ProjectileStateExpired = 1; 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] [Header("模拟世界全局设置")] [Tooltip("是否启用世界模拟")] [SerializeField]
private bool _useSimulationMovement; private bool _useSimulationMovement = true;
[Tooltip("是否启用 Job 运算路径")] [SerializeField]
private bool _useJobSimulation;
[Tooltip("是否使用 Burst 来完成计算")] [SerializeField]
private bool _useBurstJobs = true;
private EntitySync _entitySync; private EntitySync _entitySync;
private Presentation _presentation; private TransformSync _transformSync;
private HitPresentation _hitPresentation;
private readonly List<EnemySimData> _enemies = new List<EnemySimData>(); private readonly List<EnemySimData> _enemies = new List<EnemySimData>();
private readonly List<ProjectileSimData> _projectiles = new List<ProjectileSimData>(); private readonly List<ProjectileSimData> _projectiles = new List<ProjectileSimData>();
private readonly List<PickupSimData> _pickups = new List<PickupSimData>(); private readonly List<PickupSimData> _pickups = new List<PickupSimData>();
private readonly List<int> _projectileRecycleEntityIds = new List<int>(); private readonly List<int> _projectileRecycleEntityIds = new List<int>();
private readonly HashSet<int> _projectileResolvedEntityIds = new HashSet<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 EnemyBinding { get; } = new EntityBinding();
private EntityBinding ProjectileBinding { get; } = new EntityBinding(); private EntityBinding ProjectileBinding { get; } = new EntityBinding();
@ -65,255 +47,22 @@ namespace Simulation
public IReadOnlyList<ProjectileSimData> Projectiles => _projectiles; public IReadOnlyList<ProjectileSimData> Projectiles => _projectiles;
public IReadOnlyList<PickupSimData> Pickups => _pickups; public IReadOnlyList<PickupSimData> Pickups => _pickups;
public bool UseSimulationMovement => _useSimulationMovement; public bool UseSimulationMovement => _useSimulationMovement;
public bool UseJobSimulation => _useJobSimulation;
public bool UseBurstJobs => _useBurstJobs;
public void SetUseSimulationMovement(bool enabled) #region Lifecycle
{
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;
}
protected override void Awake() protected override void Awake()
{ {
base.Awake(); base.Awake();
_entitySync = new EntitySync(this); _entitySync = new EntitySync(this);
_presentation = new Presentation(this); _transformSync = new TransformSync(this);
_hitPresentation = new HitPresentation(this);
InitializeJobDataChannels(); InitializeJobDataChannels();
} }
private void Start() private void Start()
{ {
_entitySync?.OnStart(); _entitySync?.OnStart();
_presentation?.OnStart(); _hitPresentation?.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);
} }
public void Tick(in SimulationTickContext context) public void Tick(in SimulationTickContext context)
@ -323,307 +72,27 @@ namespace Simulation
return; return;
} }
if (_useJobSimulation)
{
using (CustomProfilerMarker.TickEnemies.Auto())
{
TickEnemiesJobified(in context);
}
return;
}
using (CustomProfilerMarker.TickEnemies.Auto()) using (CustomProfilerMarker.TickEnemies.Auto())
{ {
TickEnemies(in context); TickSimulationPipeline(in context);
} }
} }
public void Clear() private void OnDestroy()
{ {
_enemies.Clear(); _hitPresentation?.OnDestroy();
_projectiles.Clear(); _entitySync?.OnDestroy();
_pickups.Clear(); _entitySync = null;
_projectileRecycleEntityIds.Clear(); _transformSync = null;
_projectileResolvedEntityIds.Clear(); _hitPresentation = null;
_areaCollisionRequests.Clear(); DisposeJobDataChannels();
_areaCollisionHitEvents.Clear();
_areaCollisionHitDedupKeys.Clear();
_enemySeparationAgents.Clear();
_enemyTickWorkItems.Clear();
ClearJobDataChannels();
EnemyBinding.Clear();
ProjectileBinding.Clear();
PickupBinding.Clear();
} }
private void TickEnemies(in SimulationTickContext context) private void LateUpdate()
{ {
if (_enemies.Count == 0 || context.DeltaTime <= 0f) _transformSync?.OnLateUpdate();
{
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();
}
} }
private void BuildEnemyTickInput(in Vector3 playerPosition) #endregion
{
_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
};
}
} }
} }

View File

@ -24,7 +24,7 @@ MonoBehaviour:
showUnityEditorReport: 0 showUnityEditorReport: 0
logBehaviour: 0 logBehaviour: 0
drawGizmos: 1 drawGizmos: 1
defaultRecyclable: 0 defaultRecyclable: 1
defaultAutoPlay: 3 defaultAutoPlay: 3
defaultUpdateType: 0 defaultUpdateType: 0
defaultTimeScaleIndependent: 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 基线修正与性能基准 ## 1. P0 基线修正与性能基准
- [x] 建立性能基准场景(建议复用 `Game.unity` + 压测参数): - [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 截图)。 - 输出:一份基线表格(开发机配置 + Unity Profiler 截图)。
- [x] 修正当前高风险逻辑问题(避免后续优化建立在不稳定行为上): - [x] 修正当前高风险逻辑问题(避免后续优化建立在不稳定行为上):
- `ProcedureGame.OnEnter()``_hudInitialized` 逻辑中有重复初始化状态机风险(`InitGameState()` 被调用两次)。 - `ProcedureGame.OnEnter()``_hudInitialized` 逻辑中有重复初始化状态机风险(`InitGameState()` 被调用两次)。
@ -62,7 +62,7 @@
- [x] Checkpoint 7P1 阶段回归与性能记录 - [x] Checkpoint 7P1 阶段回归与性能记录
- 回归用例:战斗 10 分钟、`Battle -> LevelUp -> Shop -> Battle` 循环、掉落吸附与拾取。 - 回归用例:战斗 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 分层设计 + 回滚开关说明 + 对比数据`。 - 输出文档:`P1 Simulation 分层设计 + 回滚开关说明 + 对比数据`。
- 完成标准:核心流程稳定,无新增 Error/Exception可一键回滚到旧更新路径。 - 完成标准:核心流程稳定,无新增 Error/Exception可一键回滚到旧更新路径。
@ -74,7 +74,7 @@
- 目标:将 `TickEnemies GC` 从当前 `27~108 KB` 降到 `< 5 KB / frame` - 目标:将 `TickEnemies GC` 从当前 `27~108 KB` 降到 `< 5 KB / frame`
- 重点文件:`Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`。 - 重点文件:`Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`。
- 处理方式:桶容器与临时列表复用(包含 bucket list 复用池),避免每帧重建集合。 - 处理方式:桶容器与临时列表复用(包含 bucket list 复用池),避免每帧重建集合。
- 完成标准:`2000` 敌人压测下 `TickEnemies GC` 稳定 `< 5 KB / frame` - 完成标准:`2k` 敌人压测下 `TickEnemies GC` 稳定 `< 5 KB / frame`
- [x] Checkpoint 2解耦 Simulation 核心与 `Transform` 运行时依赖 - [x] Checkpoint 2解耦 Simulation 核心与 `Transform` 运行时依赖
- 目标:`SimulationWorld.TickEnemies` 不直接读取或写入 `Transform` - 目标:`SimulationWorld.TickEnemies` 不直接读取或写入 `Transform`
@ -142,7 +142,7 @@
- 构建分桶Build Buckets - 构建分桶Build Buckets
- 邻域候选查询Query Neighbors - 邻域候选查询Query Neighbors
- 避免全量最近邻搜索,控制复杂度随敌人数增长的斜率。 - 避免全量最近邻搜索,控制复杂度随敌人数增长的斜率。
- 完成标准:`3k` 敌人下目标选择阶段耗时稳定,且无索引越界/漏目标回归。 - 完成标准:`2k` 敌人下目标选择阶段耗时稳定,且无索引越界/漏目标回归。
- [x] Checkpoint 5投射物批量移动与寿命回收 Job 化 - [x] Checkpoint 5投射物批量移动与寿命回收 Job 化
- 投射物数据结构最少包含:`position/velocity/lifeTime/age/active`。 - 投射物数据结构最少包含:`position/velocity/lifeTime/age/active`。
@ -170,12 +170,12 @@
- [ ] Checkpoint 9P2 回归、压测与结项文档 - [ ] Checkpoint 9P2 回归、压测与结项文档
- 回归用例10 分钟战斗、`Battle -> LevelUp -> Shop -> Battle` 循环、掉落拾取链路。 - 回归用例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 改造说明 + 开关/回滚策略 + 前后对比数据`。 - 输出文档:`P2 Job/Burst 改造说明 + 开关/回滚策略 + 前后对比数据`。
- 完成标准:结论可复现,可作为 P3 GPU Instancing 的输入基线。 - 完成标准:结论可复现,可作为 P3 GPU Instancing 的输入基线。
**验收标准** **验收标准**
- 在 3k 敌人规模下CPU Main Thread 明显下降(目标 >= 30%)。 - 在 2k 敌人规模下CPU Main Thread 明显下降(目标 >= 30%)。
- Profiler 中战斗帧 GC Alloc 接近 0持续帧 - Profiler 中战斗帧 GC Alloc 接近 0持续帧
## 4. P3 GPU Instancing 渲染管线(与 Job 并行推进) ## 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 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 # Simulation Development
## Quick Start ## Quick Start
1. Read baseline design doc: `./references/SimulationDevelopmentSkill.md`. 1. Read the design spec first: `./references/SimulationDevelopmentSkill.md`.
2. Read latest measured baseline: `../../docs/P1.5 Simulation-Supplement.md`. 2. If performance conclusions change, sync evidence to `../../docs/P2 Job System + Burst 落地.md`.
3. Confirm your change scope is one or more of: 3. Classify change scope before coding:
- `SimData` contracts (`EnemySimData`, `ProjectileSimData`, `PickupSimData`) - `SimData/JobData` contracts
- lifecycle sync (`SimulationWorld.EntitySync`) - 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`) - presentation write-back (`SimulationWorld.Presentation`)
- enemy separation solver integration (`EnemySeparationSolverProvider`) 4. Decide rollback behavior up front:
4. Keep rollback path available through `UseSimulationMovement`. - `UseSimulationMovement` off path
- `UseJobSimulation` off path
5. Add/adjust both EditMode and PlayMode regression tests.
## Source Map ## Source Map
- Simulation core: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.cs` - 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` - Lifecycle sync: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.EntitySync.cs`
- Presentation sync: `../../Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.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` - Tick context: `../../Assets/GameMain/Scripts/Simulation/SimulationTickContext.cs`
- Index binding: `../../Assets/GameMain/Scripts/Simulation/EntityBinding.cs` - Index binding: `../../Assets/GameMain/Scripts/Simulation/EntityBinding.cs`
- Battle entry: `../../Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.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` - Global component init: `../../Assets/GameMain/Scripts/Base/GameEntry.Custom.cs`
- Enemy old path gate: - Enemy old path gate:
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/MeleeEnemy.cs` - `../../Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/MeleeEnemy.cs`
- `../../Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/RemoteEnemy.cs` - `../../Assets/GameMain/Scripts/Entity/EntityLogic/Enemy/RemoteEnemy.cs`
- Separation solver: - Regression tests:
- `../../Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs` - `../../Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs`
- `../../Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs` - `../../Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs`
- `../../Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`
- P1.5 baseline doc:
- `../../docs/P1.5 Simulation-Supplement.md`
## Non-Negotiable Invariants ## Non-Negotiable Invariants
@ -44,46 +50,53 @@ description: Maintain and extend the VampireLike Simulation layer. Use when modi
- Keep logic/presentation boundary: - Keep logic/presentation boundary:
- Simulation computes logical outputs. - Simulation computes logical outputs.
- Presentation writes back `Transform`. - Presentation writes back `Transform`.
- Keep A/B rollback path: - Keep A/B rollback path (`UseSimulationMovement`/`UseJobSimulation`).
- `UseSimulationMovement == false` must preserve old behavior path. - `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. - Avoid new managed allocations in Tick hot paths.
## Change Recipes ## Change Recipes
### Add or Change SimData Fields ### Add or Change SimData Fields
1. Update target struct in `Simulation/SimData/`. 1. Update target structs in `Simulation/SimData/` and Job channel structs.
2. Populate default/initial values in `EntitySync` create methods. 2. Populate defaults in `Create*InitialSimData` / lifecycle registration path.
3. Apply runtime updates in `Tick` phase. 3. Apply runtime updates in simulation stages.
4. Consume outputs in `Presentation` only if visual write-back is needed. 4. Consume visual fields in `Presentation` only.
5. Ensure backward compatibility when `UseSimulationMovement` is off. 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`). 1. Keep deterministic stage ownership (Build/Schedule/Complete/Commit).
2. Preserve state semantics and avoid direct UI/event side effects in Tick. 2. Preserve state semantics; avoid UI/audio/effect side effects in simulation loops.
3. Keep `ProfilerMarker` coverage for each stage. 3. Keep `ProfilerMarker` coverage for new or changed stages.
4. Keep Tick hot path data-driven (no direct `Transform` read/write). 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). 1. Treat broad phase candidate generation and main-thread settlement as separate steps.
2. Add dedicated tick methods in `SimulationWorld` for each data type. 2. Preserve `MaxTargets` semantics across player + enemy candidates.
3. Keep outputs in data containers; write visuals in presentation phase. 3. If adding query metadata, flow it through:
4. Ensure removal path and binding remap rules are identical to enemy path. - 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. 1. Define exact effective timing (`Battle` or out-of-`Battle`) before implementation.
2. Keep `ProfilerMarker` and stage boundaries stable for P1.5/P2 comparison. 2. For high-risk switches, enforce out-of-battle-only changes.
3. Leave transform write-back to presentation-only stage. 3. Provide clear warning logs for ignored runtime switch attempts.
4. Keep managed allocations and virtual dispatch out of core loops.
## Validation Checklist ## Validation Checklist
- `UseSimulationMovement = false` and `true` both run correctly. - `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. - No duplicate registration or stale index after entity hide/destroy.
- Battle loop remains stable (`Battle -> LevelUp -> Shop -> Battle`). - 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. - 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` 分层的开发规范与速查手册。 本文件是 SimulationWorld 的正式设计说明和扩展开发规范。
后续调整敌人移动、补齐投射物/掉落物逻辑、推进 Job/Burst 改造时,优先按本文档执行,避免反复通读全部代码 后续在 Simulation 相关模块做功能扩展、性能优化、回归修复时,统一按本规范执行
## 当前架构总览P1.5 已落地) ## 适用范围
- Simulation 主目录:`Assets/GameMain/Scripts/Simulation/` - Assets/GameMain/Scripts/Simulation/*
- 核心组件:`SimulationWorld``GameFrameworkComponent` - Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs
- 数据容器: - Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs
- `List<EnemySimData> _enemies` - Assets/GameMain/Scripts/Utility/AIUtility.cs
- `List<ProjectileSimData> _projectiles` - Assets/Tests/Simulation/EditMode/*
- `List<PickupSimData> _pickups` - Assets/Tests/Simulation/PlayMode/*
- Tick 临时缓冲:
- `List<EnemyTickWorkItem> _enemyTickWorkItems`
- `List<EnemySeparationAgent> _enemySeparationAgents`
- 索引绑定:`EntityBinding``EntityId <-> SimulationIndex` 双向映射)
- 生命周期同步:`SimulationWorld.EntitySync`(监听实体 Show/Hide 事件)
- 表现层回写:`SimulationWorld.Presentation``LateUpdate` 写回 `Transform`
- Tick 上下文:`SimulationTickContext``DeltaTime`、`RealDeltaTime`、`PlayerPosition`
## 运行时主链路(按帧) 当前状态P2 Job/Burst 主体已完成SimulationWorld 已是战斗核心调度层。
1. `GameEntry.InitCustomComponents()` 获取或自动挂载 `SimulationWorld`
文件:`Assets/GameMain/Scripts/Base/GameEntry.Custom.cs`
2. `ProcedureGame.OnEnter()` 清理旧 Simulation 数据
文件:`Assets/GameMain/Scripts/Procedure/Game/ProcedureGame.cs`
3. `GameStateBattle.OnUpdate()` 中先执行刷怪,再执行 `SimulationWorld.Tick(...)`
文件:`Assets/GameMain/Scripts/Procedure/Game/GameStateBattle.cs`
4. `SimulationWorld.Tick()` 仅在 `UseSimulationMovement == true` 时执行敌人 Tick
5. `SimulationWorld.LateUpdate()` 执行 `Presentation.OnLateUpdate()`,将仿真结果写回敌人 `Transform`
## 生命周期与数据同步设计 ## 模块分层
`EntitySync` 通过事件驱动保持 Simulation 容器与实体生命周期一致 SimulationWorld 使用 partial 拆分,职责如下:
| 事件 | 组名 | 行为 | - SimulationWorld.cs
|-------------------------------|-------------------------|----------------------------------------------------------------| - 开关、主容器、绑定、主 Tick 入口。
| `ShowEntitySuccessEventArgs` | `Enemy` | `RegisterEnemyLifecycle` + `UpsertEnemy` | - SimulationWorld.EntitySync.cs
| `HideEntityCompleteEventArgs` | `Enemy` | `UnregisterEnemyLifecycle` + `RemoveEnemyByEntityId` | - 实体 Show/Hide 到仿真容器的生命周期同步。
| `ShowEntitySuccessEventArgs` | `Drop` | `RegisterPickupLifecycle` + `UpsertPickup` | - SimulationWorld.EnemyJobs.cs
| `HideEntityCompleteEventArgs` | `Drop` | `UnregisterPickupLifecycle` + `RemovePickupByEntityId` | - 敌人移动和互斥分离的 Job/Burst 链路。
| `ShowEntitySuccessEventArgs` | `Bullet` / `Projectile` | `RegisterProjectileLifecycle` + `UpsertProjectile` | - SimulationWorld.ProjectileJobs.cs
| `HideEntityCompleteEventArgs` | `Bullet` / `Projectile` | `UnregisterProjectileLifecycle` + `RemoveProjectileByEntityId` | - 投射物移动、寿命、碰撞候选、主线程结算。
- SimulationWorld.JobDataChannel.cs
- Native 容器、拷贝转换、容量管理、运行时统计。
- SimulationWorld.TargetSelectionSpatialIndex.cs
- 目标选择空间索引(最近敌人查询)。
- SimulationWorld.Presentation.cs
- 表现层写回Transform和命中表现事件消费。
关键规则: ## 运行时执行链路
- 删除容器元素统一使用“末尾覆盖 + `RemoveAt(lastIndex)` + `EntityBinding.RemapIndex`”。 1. GameStateBattle.OnUpdate
- `Upsert` 语义:`EntityId` 已存在则覆盖,不存在则追加。 - 先执行 EnemyManager.OnUpdate
- 再执行 SimulationWorld.Tick
## EnemySimData 合约(当前实现) 2. SimulationWorld.Tick
文件:`Assets/GameMain/Scripts/Simulation/SimData/EnemySimData.cs` - UseSimulationMovement = false直接返回完全回退旧链路
- UseSimulationMovement = true 且 UseJobSimulation = false走主线程敌人仿真
- UseSimulationMovement = true 且 UseJobSimulation = true走 Job/Burst 总链路
- `EntityId`:实体唯一标识 3. SimulationWorld.LateUpdate
- `Position / Forward / Rotation`:逻辑输出与表现层写回字段 - 调用 Presentation.OnLateUpdate
- `Speed`:来自 `EnemyData.SpeedBase` - 统一写回 Enemy/Projectile 表现
- `AttackRange`:当前固定初始化为 `1f`
- `AvoidEnemyOverlap / EnemyBodyRadius / SeparationIterations`:从 `MovementComponent` 读取
- `TargetType / State`:状态扩展预留
当前状态值(`SimulationWorld` 常量): ## 核心数据契约
- `0`Idle ### 主容器
- `1`Chasing - List<EnemySimData> _enemies
- `2`InAttackRange - 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`,按四阶段执行: ### Job 通道
1. `BuildInput` - EnemyJobInput/Output
- 计算到玩家平面距离 - ProjectileJobInput/Output
- 产出 `EnemyTickWorkItem` - CollisionQuery/CollisionCandidate
- 生成分离输入 `EnemySeparationAgent` - NativeParallelMultiHashMap互斥桶、碰撞桶、目标桶
2. `Move/Separation`
- 计算追踪位移与朝向
- 通过 `EnemySeparationSolverProvider.ResolveSimulation(...)` 做互斥求解
3. `StateUpdate`
- 按距离与可追逐状态更新 `Idle/Chasing/InAttackRange`
4. `WriteBack`
- 回写 `EnemySimData``Position/Forward/Rotation/State`
## 互斥求解器双通道Legacy + Simulation 统一规则:
文件: - Allocator.Persistent 分配
- `Assets/GameMain/Scripts/Utility/EnemySeperator/IEnemySeparationSolver.cs` - Initialize/Dispose 集中管理
- `Assets/GameMain/Scripts/Utility/EnemySeperator/EnemySeparationSolverProvider.cs` - Clear 只清容器,不破坏生命周期
- `Assets/GameMain/Scripts/Utility/EnemySeperator/GridBucketEnemySeparationSolver.cs`
说明: ## 不可破坏的设计约束
- `SetSimulationAgents/ResolveSimulation`:供 Simulation 纯数据路径调用。 ### 生命周期单入口
- `Register/Unregister/Resolve(Transform, ...)`:保留旧路径兼容与回滚能力 仿真容器增删只能由 EntitySync 驱动
- `GridBucketEnemySeparationSolver` 已加入桶列表复用池(`_bucketListPool`)以降低 GC 禁止在 Enemy/Weapon/Projectile 业务代码中直接改仿真容器
## 表现层回写Presentation规则 ### 逻辑与表现边界
文件:`Assets/GameMain/Scripts/Simulation/SimulationWorld.Presentation.cs` - Simulation 只产出逻辑数据,不直接写 Transform。
- Transform 写回只能在 Presentation。
- 命中表现通过事件缓冲在主线程提交。
- 仅在 `UseSimulationMovement == true` 时执行 ### 开关和回滚
- 遍历 `EnemyManager.Enemies`,按 `EntityId` 查找 `EnemySimData` - UseSimulationMovement总开关支持一键回滚。
- 写回顺序: - UseJobSimulationJob 开关,支持 P1.5/ P2 对照。
- 始终写回 `position` - UseBurstJobsBurst 开关。
- 优先使用 `rotation`
- 若 `rotation` 无效,则回退到 `forward`
## 与旧移动系统的关系(重要) ### 生效时机约束(重要)
- `MeleeEnemy` / `RemoteEnemy``OnUpdate` 开头门控: - SetUseSimulationMovement / SetUseJobSimulation 在 Battle 中会被忽略。
- 开启 Simulation直接 `return` - 这两个开关不支持战斗内热切换,只允许战斗外修改生效。
- 关闭 Simulation走旧 `MovementComponent`
- 回滚能力来自同一构建内的 `UseSimulationMovement` A/B 开关。
## Projectile / Pickup 现状 ## 敌人和投射物执行模型
- `ProjectileSimData`、`PickupSimData` 已具备容器、绑定与生命周期同步通道 ### 敌人Job
- 当前仍未接入独立 Tick 行为,仅完成“创建/回收/索引同步”占位目标 固定阶段:
- BuildInput
- Move
- Separation
- Commit
## P1.5 实测基线P2 输入) 约束:
基线文档:`docs/P1.5 Simulation-Supplement.md` - 热路径禁止 LINQ 和托管分配
- 不读写 Transform
- 阶段必须可独立 Profile
关键结论: ### 投射物Job
- `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 上限影响 - Broad Phase 候选构建
- 主线程命中结算和回收
## 自动化回归P1.5 已补) ## 碰撞和伤害结算规范
目录: ### Broad Phase
- `Assets/Tests/Simulation/EditMode/SimulationWorldTickTests.cs` 候选由 _collisionQueryInputs + _enemyCollisionBuckets 计算。
- `Assets/Tests/Simulation/PlayMode/SimulationWorldPlayModeTests.cs` MaxTargets 必须覆盖“玩家候选 + 敌人候选”的总量。
覆盖点: ### Area Query 快照语义
- 敌人追踪玩家 - 入队时记录 SourceWasActiveAtQueryTime。
- 进入攻击距离后停止移动 - 结算按快照判定来源有效性,避免查询后状态变化导致误判。
- 实体移除后的索引 remap 稳定性
## 后续扩展规范(必须遵守) ### 主线程结算
1. 先扩数据,再扩行为 - Projectile 命中:按 ImpactData + AIUtility.CalcDamageHP。
先在 `SimData` 增字段,再改 `EntitySync` 初始化与 `Tick` 逻辑,最后改表现层消费 - 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 ## 扩展开发 SOP
所有 Simulation 容器删除都必须 remap 索引,严禁 `RemoveAt(i)` 直接删中间项。 ### Step 0定义模式和回滚
- 明确功能在哪条路径生效Simulation / Job / Burst
- 明确开关关闭后的回退行为。
6. 热路径禁用托管分配 ### Step 1扩数据
`TickEnemies`、互斥求解、阶段化循环里禁止 LINQ/临时集合扩张。 - 先改 SimData 和 JobData。
- 再改 CreateInitialSimData 与转换函数。
## P2 前的已知技术债 ### Step 2接生命周期
- `AttackRange` 目前固定值 `1f`,尚未由配置化数值驱动 - 只在 EntitySync 增加注册/反注册。
- `EnemySimData.TargetType/State` 语义仍偏轻量,未形成完整状态机合约 - 保持 group 到容器映射清晰。
- Projectile/Pickup 尚未迁移真实 Tick 行为
## 提交前检查清单 ### Step 3接执行阶段
- 是否保持了 `UseSimulationMovement` 关闭时行为不变 - 优先放入现有阶段Build/Schedule/Commit
- 是否保持了 `EntityId <-> SimulationIndex` 一致性(含移除 remap - 新阶段必须补 ProfilerMarker。
- 是否避免在 Tick 热路径引入新 GC
- 是否将新字段接入了 `EntitySync -> Tick -> Presentation` 全链路 ### Step 4接结算和表现
- 是否补充了最小回归验证(至少 Battle 循环、敌人移除、索引稳定性) - 逻辑结算收口到主线程。
- 是否同步更新本 Skill 文档与 `docs/P1.5 Simulation-Supplement.md` - 表现写回放在 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”。
- 是否同步更新测试和本设计文档。